Ctrl K
ComunidadGuías…

docs/plan/2026-07-03-cloud-workspace-git-init.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 云端模式 Git 初始化工作区方案

## 一、背景

当前云端模式创建会话时,用户只能输入一个项目名称(`cloudProjectKey`),后端创建一个空目录作为工作区。用户希望在云端模式下也能通过 Git 地址(`git clone`)来初始化工作区,从而快速从已有仓库开始工作。

---

## 二、现有架构分析

### 2.1 当前 CLOUD 模式创建流程

```
用户 → ChatInput/NewTaskDialog → createSession API
  → SessionService.createSession()
     ├─ 有 cloudProjectKey → 创建 /workspace-root/{userId}/projects/{slug}
     └─ 无 cloudProjectKey → 创建 /workspace-root/{userId}/{sessionId}
  → environmentInfoProvider.detect() 检测环境信息
```

### 2.2 关键现有字段

| 字段 | 类型 | 说明 |
|------|------|------|
| `cloudProjectKey` | String | 用户输入的项目名(slug),对应 `projects/{slug}` 目录 |
| `isGit` | Boolean | 标记工作区是否处于 Git 仓库中 |
| `projectKey` | String | 存储到 session 表的项目标识 |

---

## 三、方案总览

在创建工作区时,**不增加并行输入框**,而是采用**先选择模式、再输入对应内容**的交互方式:

```
用户选择工作区来源:
  ┌───────────────────────────────────────┐
  │ ○ 选择已有工作区     (已有工作区时默认)  │
  │ ○ 创建新工作区       (无已有工作区时默认) │
  │ ○ 初始化 Git 工作区                    │
  └───────────────────────────────────────┘
        ↓ 根据选择,展示不同输入
  ┌───────────────────────────────────────┐
  │ 选择已有工作区 → 下拉列表选择项目名       │
  │ 创建新工作区   → 输入项目名(可选)       │
  │ 初始化 Git    → 输入 Git 地址 + 分支名   │
  └───────────────────────────────────────┘
```

**关键原则**:
- 三个选项互斥,同一时间只展示一个输入区域
- 如果当前已存在工作区,默认选中"选择已有工作区"
- 如果当前不存在工作区,默认选中"创建新工作区"

---

## 四、后端改动

### 4.1 SessionController — 新增请求参数

**文件**: `backend/src/main/java/cn/etarch/mao/session/controller/SessionController.java`

`CreateSessionRequest` DTO 新增字段:

```java
@Data
public static class CreateSessionRequest {
    // ... 现有字段 ...
    private String cloudProjectKey;   // 已有:项目名(slug)
    private String workspaceMode;     // 新增:existing | new | git
    private String gitCloneUrl;       // 新增:Git 仓库地址(支持 https 和 ssh)
    private String gitBranch;         // 新增:指定分支(可选,默认使用默认分支)
}
```

### 4.2 SessionService — 核心逻辑

**文件**: `backend/src/main/java/cn/etarch/mao/session/service/SessionService.java`

修改 `createSession()` 中 CLOUD 模式分支:

```java
if ("CLOUD".equals(session.getExecutionMode())) {
    if ("git".equals(workspaceMode) && gitCloneUrl != null && !gitCloneUrl.isBlank()) {
        // === Git 初始化工作区 ===
        String slug = GitUrlParser.extractSlug(gitCloneUrl);
        String projectPath = CloudWorkspaceResolver.resolveProjectWorkspace(
                pathSandbox, userId, slug);
        ensureWorkspaceDirectory(projectPath);
        // 执行 git clone(同步,带超时)
        GitCloneResult result = gitClone(gitCloneUrl, gitBranch, projectPath);
        if (!result.success) {
            // 清理已创建目录
            deleteWorkspaceDirectory(projectPath);
            throw new BusinessException(ErrorCode.GIT_CLONE_FAILED, result.error);
        }
        session.setWorkspace(projectPath);
        session.setProjectKey(slug);
        session.setIsGit(true);
    } else if ("existing".equals(workspaceMode) && cloudProjectKey != null) {
        // === 选择已有工作区 ===
        String slug = CloudWorkspaceResolver.normalizeAndValidate(cloudProjectKey);
        String projectPath = CloudWorkspaceResolver.resolveProjectWorkspace(
                pathSandbox, userId, slug);
        if (!Files.exists(Paths.get(projectPath))) {
            throw new BusinessException(ErrorCode.PARAM_INVALID, "工作区不存在");
        }
        session.setWorkspace(projectPath);
        session.setProjectKey(slug);
    } else {
        // === 新建工作区(现有逻辑) ===
        if (cloudProjectKey != null && !cloudProjectKey.isBlank()) {
            String slug = CloudWorkspaceResolver.normalizeAndValidate(cloudProjectKey);
            String projectPath = CloudWorkspaceResolver.resolveProjectWorkspace(
                    pathSandbox, userId, slug);
            ensureWorkspaceDirectory(projectPath);
            session.setWorkspace(projectPath);
            session.setProjectKey(slug);
        } else {
            // 临时工作区(无项目名)
            String autoPath = pathSandbox.getWorkspaceRoot()
                    .resolve(String.valueOf(userId))
                    .resolve(String.valueOf(session.getId()))
                    .toString();
            ensureWorkspaceDirectory(autoPath);
            session.setWorkspace(autoPath);
            session.setProjectKey(deriveProjectKey(autoPath));
        }
    }
    sessionMapper.updateById(session);
}
```

### 4.3 Git Clone 实现

**新增文件**: `backend/src/main/java/cn/etarch/mao/session/service/GitOperationService.java`

```java
@Service
@Slf4j
public class GitOperationService {

    private static final long CLONE_TIMEOUT_SECONDS = 120;

    /**
     * Execute git clone synchronously.
     * Supports both HTTPS and SSH protocols.
     * SSH keys are assumed to be pre-configured on the server.
     */
    public GitCloneResult clone(String url, String branch, Path targetDir) {
        List<String> command = new ArrayList<>();
        command.add("git");
        command.add("clone");
        if (branch != null && !branch.isBlank()) {
            command.add("--branch");
            command.add(branch);
        }
        command.add(url);
        command.add(targetDir.toString());

        try {
            ProcessBuilder pb = new ProcessBuilder(command);
            pb.redirectErrorStream(true);
            Process process = pb.start();

            StringBuilder output = new StringBuilder();
            try (BufferedReader reader = new BufferedReader(
                    new InputStreamReader(process.getInputStream()))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    output.append(line).append("\n");
                    log.debug("git clone [{}]: {}", url, line);
                }
            }

            boolean finished = process.waitFor(CLONE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
            if (!finished) {
                process.destroyForcibly();
                return GitCloneResult.fail("Git clone timeout (>" +
                        CLONE_TIMEOUT_SECONDS + "s)");
            }

            int exitCode = process.exitValue();
            if (exitCode == 0) {
                log.info("git clone success: {} → {}", url, targetDir);
                return GitCloneResult.success();
            } else {
                log.warn("git clone failed: exit={}, output={}", exitCode, output);
                return GitCloneResult.fail("Git clone failed: " + output.toString());
            }
        } catch (IOException e) {
            log.error("git clone IO error for {}: {}", url, e.getMessage());
            return GitCloneResult.fail("Git clone error: " + e.getMessage());
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            process.destroyForcibly();
            return GitCloneResult.fail("Git clone interrupted");
        }
    }

    public record GitCloneResult(boolean success, String error) {
        public static GitCloneResult success() {
            return new GitCloneResult(true, null);
        }
        public static GitCloneResult fail(String error) {
            return new GitCloneResult(false, error);
        }
    }
}
```

### 4.4 Git URL 解析与校验

**新增文件**: `backend/src/main/java/cn/etarch/mao/session/util/GitUrlParser.java`

```java
public final class GitUrlParser {

    // HTTPS: https://github.com/user/repo.git  or  https://github.com/user/repo
    private static final Pattern HTTPS_PATTERN =
            Pattern.compile("^https://[\\w.-]+(/[\\w.-]+)*\\.git$",
                    Pattern.CASE_INSENSITIVE);

    // SSH: git@github.com:user/repo.git
    private static final Pattern SSH_PATTERN =
            Pattern.compile("^git@[\\w.-]+:[\\w.-]+(/[\\w.-]+)*\\.git$");

    /**
     * Extract repository name from Git URL.
     * https://github.com/user/my-repo.git → my-repo
     * git@github.com:user/my-repo.git      → my-repo
     */
    public static String extractSlug(String url) {
        if (url == null || url.isBlank()) {
            throw new BusinessException(ErrorCode.PARAM_INVALID, "Git URL 不能为空");
        }

        String path;
        if (url.startsWith("https://") || url.startsWith("http://")) {
            if (!url.startsWith("https://")) {
                throw new BusinessException(ErrorCode.PARAM_INVALID,
                        "仅支持 HTTPS 协议的 Git 地址");
            }
            // Extract path part after host
            try {
                URI uri = new URI(url);
                path = uri.getPath();
            } catch (URISyntaxException e) {
                throw new BusinessException(ErrorCode.PARAM_INVALID,
                        "Git URL 格式无效");
            }
        } else if (url.startsWith("git@")) {
            // SSH format: git@host:path → extract path after ':'
            int colonIdx = url.indexOf(':');
            if (colonIdx < 0) {
                throw new BusinessException(ErrorCode.PARAM_INVALID,
                        "SSH Git 地址格式无效");
            }
            path = url.substring(colonIdx + 1);
        } else {
            throw new BusinessException(ErrorCode.PARAM_INVALID,
                    "不支持的 Git 地址协议,请使用 HTTPS 或 SSH");
        }

        // Extract repo name: strip leading / and trailing .git
        if (path.startsWith("/")) path = path.substring(1);
        if (path.endsWith(".git")) path = path.substring(0, path.length() - 4);

        // Take last segment as repo name
        int lastSlash = path.lastIndexOf('/');
        String name = lastSlash >= 0 ? path.substring(lastSlash + 1) : path;

        if (name.isBlank()) {
            throw new BusinessException(ErrorCode.PARAM_INVALID,
                    "无法从 Git URL 提取仓库名");
        }

        // Reuse existing slug validation
        return CloudWorkspaceResolver.normalizeAndValidate(name);
    }

    /**
     * Validate git URL format (HTTPS or SSH).
     */
    public static void validate(String url) {
        if (url == null || url.isBlank()) {
            throw new BusinessException(ErrorCode.PARAM_INVALID, "Git URL 不能为空");
        }
        if (url.startsWith("https://")) {
            // basic sanity: must contain a host and path
            if (!url.matches("^https://[^\\s]+/[^\\s]+")) {
                throw new BusinessException(ErrorCode.PARAM_INVALID,
                        "Git URL 格式无效,示例: https://github.com/user/repo.git");
            }
        } else if (url.startsWith("git@")) {
            if (!url.matches("^git@[^\\s]+:[^\\s]+")) {
                throw new BusinessException(ErrorCode.PARAM_INVALID,
                        "SSH Git 地址格式无效,示例: git@github.com:user/repo.git");
            }
        } else {
            throw new BusinessException(ErrorCode.PARAM_INVALID,
                    "不支持的协议,仅支持 HTTPS 和 SSH");
        }
    }
}
```

### 4.5 获取已有工作区列表 API

**新增 API**: `GET /v1/sessions/cloud-projects`

**文件**: `SessionController.java`

```java
@GetMapping("/cloud-projects")
public Result<List<CloudProjectVO>> listCloudProjects(
        @AuthenticationPrincipal Long userId) {
    Path userRoot = pathSandbox.getWorkspaceRoot().resolve(String.valueOf(userId));
    Path projectsDir = userRoot.resolve("projects");
    List<CloudProjectVO> projects = new ArrayList<>();

    if (Files.exists(projectsDir)) {
        try (var stream = Files.list(projectsDir)) {
            stream.filter(Files::isDirectory)
                  .map(dir -> {
                      CloudProjectVO vo = new CloudProjectVO();
                      vo.setName(dir.getFileName().toString());
                      vo.setPath(dir.toString());
                      // Check if contains .git
                      vo.setIsGit(Files.exists(dir.resolve(".git")));
                      return vo;
                  })
                  .sorted(Comparator.comparing(CloudProjectVO::getName))
                  .forEach(projects::add);
        } catch (IOException e) {
            log.warn("Failed to list cloud projects for user {}: {}", userId, e.getMessage());
        }
    }
    return Result.ok(projects);
}
```

### 4.6 安全考量

| 约束 | 说明 |
|------|------|
| SSH Key | 由运维在服务器端预配置(`~/.ssh/config` 或 `ssh-agent`),不在代码中管理 |
| 协议白名单 | 仅允许 `https://` 和 `git@`(SSH)开头 |
| 路径隔离 | 目标路径始终在 `PathSandbox` 沙箱内,由 `assertUnderUserSandbox` 保证 |
| 超时控制 | 默认 120 秒超时,防止资源占用 |
| 错误清理 | Clone 失败时自动清理已创建的目录 |
| 命令注入防护 | 使用 `ProcessBuilder` + 参数列表(非 shell 拼接),Git URL 作为单独参数传入 |

---

## 五、前端改动

### 5.1 交互设计

核心思路:**先选择模式,再展示对应输入内容**——一个始终只有一个主输入区域的表单。

```
┌──────────────────────────────────────────────┐
│  新建任务                                  ✕  │
├──────────────────────────────────────────────┤
│                                              │
│  ○ 云端模式    ○ 本地模式                     │
│                                              │
│  ── 工作区来源 ─────────────────────────────  │
│                                              │
│  ┌─ 选择已有工作区 ──────────────────────┐    │
│  │ ┌──────────────────────────────────┐ │    │
│  │ │ my-project                 ▼    │ │    │
│  │ └──────────────────────────────────┘ │    │
│  └──────────────────────────────────────┘    │
│                                              │
│  ┌─ 创建新工作区 ────────────────────────┐    │
│  │ 输入项目名(可留空,留空=临时工作区)    │    │
│  │ ┌──────────────────────────────────┐ │    │
│  │ │ new-project                      │ │    │
│  │ └──────────────────────────────────┘ │    │
│  └──────────────────────────────────────┘    │
│                                              │
│  ┌─ 初始化 Git 工作区 ───────────────────┐   │
│  │ ┌──────────────────────────────────┐ │    │
│  │ │ https://github.com/user/repo.git │ │    │
│  │ └──────────────────────────────────┘ │    │
│  │ 分支(可选,默认使用默认分支)          │    │
│  │ ┌──────────────────────────────────┐ │    │
│  │ │ main                             │ │    │
│  │ └──────────────────────────────────┘ │    │
│  └──────────────────────────────────────┘    │
│                                              │
│                    [取消]  [开始(克隆中...)]  │
└──────────────────────────────────────────────┘
```

**交互规则**:

| 规则 | 说明 |
|------|------|
| 默认选中 | 有已有工作区 → 选中"选择已有工作区";无已有工作区 → 选中"创建新工作区" |
| 互斥展开 | 三个选项是 radio 行为,选中一个时自动折叠另外两个 |
| 单个输入区 | 同一时间只有一个输入区域可见 |
| Loading 状态 | 点击"开始"后,按钮变为 loading 态,显示"正在克隆仓库..."或"正在初始化..." |
| Git URL 格式化 | 用户输入时实时校验格式,自动 trim、补全 `.git` 后缀 |

### 5.2 NewTaskDialog 改动

**文件**: `desktop/src/components/task/NewTaskDialog.vue`

新增状态:

```typescript
// 工作区来源模式
type WorkspaceMode = 'existing' | 'new' | 'git'
const workspaceMode = ref<WorkspaceMode>('new')

// 已有工作区列表
interface CloudProject {
  name: string
  path: string
  isGit: boolean
}
const cloudProjects = ref<CloudProject[]>([])

// Git 相关
const gitCloneUrl = ref('')
const gitBranch = ref('')

// Loading 状态
const isCreating = ref(false)
const createLoadingText = ref('')
```

关键逻辑:

```typescript
async function onOpen() {
  // ... 现有初始化逻辑 ...
  // 加载已有云端工作区列表
  cloudProjects.value = await sessionStore.fetchCloudProjects()
  // 默认选中逻辑
  workspaceMode.value = cloudProjects.value.length > 0 ? 'existing' : 'new'
}

async function confirm() {
  isCreating.value = true

  if (workspaceMode.value === 'git') {
    createLoadingText.value = '正在克隆仓库...'
  } else {
    createLoadingText.value = '正在初始化工作区...'
  }

  try {
    const session = await sessionStore.createSession(
      selectedAgent.value!.id,
      selectedMode.value,
      /* workspace */ undefined,
      /* environmentInfo */ undefined,
      /* modelId */ undefined,
      /* permissionLevel */ undefined,
      /* cloudProjectKey */ getCloudProjectKey(),
      /* gitCloneUrl */ workspaceMode.value === 'git' ? gitCloneUrl.value : undefined,
      /* gitBranch */ workspaceMode.value === 'git' ? gitBranch.value || undefined : undefined,
      /* workspaceMode */ workspaceMode.value,
    )
    if (session) emit('created', session)
  } finally {
    isCreating.value = false
    createLoadingText.value = ''
  }
}

function getCloudProjectKey(): string | undefined {
  if (workspaceMode.value === 'existing') {
    return selectedCloudProject.value
  }
  if (workspaceMode.value === 'new') {
    return newProjectName.value || undefined
  }
  return undefined // git mode — 后端从 URL 提取
}
```

### 5.3 sessionStore 改动

**文件**: `desktop/src/stores/session.ts`

```typescript
async function createSession(
    agentId: string,
    executionMode: string,
    workspace?: string,
    environmentInfo?: SessionEnvironmentInfo,
    modelId?: number,
    permissionLevel?: string,
    cloudProjectKey?: string,
    gitCloneUrl?: string,          // 新增
    gitBranch?: string,            // 新增
    workspaceMode?: string,        // 新增: 'existing' | 'new' | 'git'
) {
    const payload: Record<string, unknown> = {
        agentId,
        executionMode,
        modelId: modelId || undefined,
        permissionLevel: permissionLevel || undefined,
        // ...
    }
    if (executionMode === 'CLOUD') {
        payload.workspaceMode = workspaceMode || 'new'
        if (workspaceMode === 'git' && gitCloneUrl) {
            payload.gitCloneUrl = gitCloneUrl
            if (gitBranch) payload.gitBranch = gitBranch
        } else if (cloudProjectKey) {
            payload.cloudProjectKey = cloudProjectKey
        }
    }
    // ...
}

async function fetchCloudProjects(): Promise<CloudProject[]> {
    const { data } = await api.get('/sessions/cloud-projects')
    return data || []
}
```

### 5.4 useChat 改动

**文件**: `desktop/src/composables/useChat.ts`

新增状态:

```typescript
const workspaceMode = ref<'existing' | 'new' | 'git'>('new')
const gitCloneUrl = ref('')
const gitBranch = ref('')
```

创建 session 时传递:

```typescript
const sessionData = await sessionStore.createSession(
    agentId.value,
    executionMode.value,
    executionMode.value === 'LOCAL' ? workspace.value || undefined : undefined,
    environmentInfo,
    selectedModelId?.value,
    permissionLevel?.value,
    executionMode.value === 'CLOUD' ? cloudProjectKey.value || undefined : undefined,
    executionMode.value === 'CLOUD' ? gitCloneUrl.value || undefined : undefined,
    executionMode.value === 'CLOUD' ? gitBranch.value || undefined : undefined,
    executionMode.value === 'CLOUD' ? workspaceMode.value : undefined,
)
```

### 5.5 ChatInput 改动

**文件**: `desktop/src/components/chat/ChatInput.vue`

当前 ChatInput 中已有 `el-autocomplete` 用于输入项目名。改造为与 NewTaskDialog 一致的三选一交互。

新增 props:

```typescript
const props = defineProps<{
    // ... 现有 props ...
    workspaceMode?: 'existing' | 'new' | 'git'
    gitCloneUrl?: string
    gitBranch?: string
    cloudProjects?: CloudProject[]
}>()

const emit = defineEmits<{
    // ... 现有 emits ...
    'update:workspaceMode': [mode: string]
    'update:gitCloneUrl': [url: string]
    'update:gitBranch': [branch: string]
}>()
```

### 5.6 Loading 提示

在整个创建流程中(从点击"开始"到获得 session 响应),前端需要显示 loading 状态:

```
场景 A:新建空工作区
  → 按钮显示 "正在初始化...",约 1-2 秒

场景 B:Git Clone 工作区
  → 按钮显示 "正在克隆仓库...(可能需要 1-2 分钟)"
  → 后端同步 clone,前端等待 API 响应
  → 超时/失败时显示错误 toast:"克隆失败:xxx"
```

实现方式:在 `NewTaskDialog.confirm()` 和 `ChatInput` 发送流程中,创建 session 前设置 `isCreating = true`,并在确认按钮上展示 loading spinner + 状态文字。

---

## 六、数据库改动

**无需新增表或字段**。现有 `session` 表已有足够字段支撑:

| 字段 | 用途 |
|------|------|
| `workspace` | 存储工作区路径(clone 目标路径) |
| `project_key` | 存储从 Git URL 提取的项目名 |
| `is_git` | Git Clone 成功后自动设为 `true` |

---

## 七、接口汇总

| 方法 | 路径 | 说明 | 新增/修改 |
|------|------|------|-----------|
| POST | `/v1/sessions` | 创建会话 | 修改:新增 `workspaceMode`、`gitCloneUrl`、`gitBranch` |
| GET | `/v1/sessions/cloud-projects` | 获取已有云端工作区列表 | **新增** |

---

## 八、实施步骤

| 步骤 | 内容 | 涉及文件 |
|------|------|----------|
| **1** | 新建 `GitUrlParser` 工具类 | `backend/.../session/util/GitUrlParser.java`(新增) |
| **2** | 新建 `GitOperationService` | `backend/.../session/service/GitOperationService.java`(新增) |
| **3** | `CreateSessionRequest` 新增字段 | `SessionController.java` |
| **4** | `SessionService.createSession()` 增加三模式分支 | `SessionService.java` |
| **5** | 新增 `GET /cloud-projects` 接口 | `SessionController.java` |
| **6** | `sessionStore.createSession()` 传递新参数 | `desktop/src/stores/session.ts` |
| **7** | `sessionStore.fetchCloudProjects()` | `desktop/src/stores/session.ts` |
| **8** | `NewTaskDialog` 增加三选一 + loading | `desktop/src/components/task/NewTaskDialog.vue` |
| **9** | `ChatInput` 改造为三选一交互 | `desktop/src/components/chat/ChatInput.vue` |
| **10** | `useChat` 传递新参数 | `desktop/src/composables/useChat.ts` |

---

## 九、边界情况与风险

| 场景 | 处理策略 |
|------|----------|
| 项目名与已有项目冲突 | `projects/{slug}` 目录已存在时,复用 `normalizeAndValidate` 报错,提示用户项目名已被占用 |
| Git URL 格式无效 | 前端实时校验 + 后端 `GitUrlParser.validate()`,返回明确错误信息 |
| Clone 超时(>120s) | 返回超时错误,清理已创建目录 |
| 私有仓库无权限(SSH Key 未配置) | Git 返回认证错误,透传给前端:"Authentication failed. Please check SSH key configuration." |
| 克隆大仓库 | 120 秒超时适用于大多数仓库;超大仓库可后续改为异步模式 |
| 工作区目录安全 | 目标路径始终在 `PathSandbox` 沙箱内,由 `assertUnderUserSandbox` 保证 |
| 命令注入 | 使用 `ProcessBuilder` + 参数列表,Git URL 作为单独参数,不拼接 shell 命令 |
| 字符编码 | Git clone 输出使用 UTF-8 读取,兼容中英文仓库名 |

---

## 十、后续增强(可选 Phase 2)

### 10.1 异步 Clone

对于大型仓库,可将 clone 改为异步:

```
1. createSession 快速返回(workspace 目录已创建但为空)
2. 后台异步 clone,WebSocket 推送 workspace_init 事件
3. 前端显示 "正在克隆仓库..." 进度
4. clone 完成后,前端收到通知,刷新文件树
```

新增 WebSocket 事件:`workspace_init`

```json
{
  "status": "CLONING" | "READY" | "FAILED",
  "progress": "Cloning repository...",
  "error": "Authentication failed"
}
```

### 10.2 Clone 进度输出

后端在执行 clone 时,通过 WebSocket 实时推送 git clone 的标准输出,让前端展示进度条或日志行。

### 10.3 支持直接打开已有工作区

在 ChatInput 中,当前"项目名输入"已支持 autocomplete 建议。可进一步增强:输入已有项目名时直接复用该工作区,无需重新创建 session,而是直接在该项目下新建一个会话。