docs/plan/2026-07-01-cloud-file-browser-design.md
Rama main · réplica de lectura del repositorio original.
# 云端模式文件树与文件预览设计方案
> 版本:v1.0 | 日期:2026-07-01 | 状态:待评审
## 1. 背景与问题
### 1.1 现状
桌面端已实现完整的**工作区文件浏览器**(右侧面板文件树 + 中心区域文件预览 Tab),但**仅支持 LOCAL 模式**:
| 能力 | LOCAL 模式 | CLOUD 模式 |
|------|-----------|-----------|
| 右侧「文件」Tab | ✅ 有 workspace 时显示 | ❌ 刻意隐藏 |
| 目录树懒加载 | Electron `list-directory` IPC | 无 |
| 文件预览 Tab | Electron `localReadFile` IPC | 无 |
| `@` 文件引用 | IPC `listWorkspaceFiles` | ✅ 已有 `GET /files/workspace-list` |
| 文件变更面板点击预览 | ✅ | ❌ 代码中直接 `return` |
关键拦截点:
```124:124:desktop/src/components/task/TaskInspector.vue
const showFileTreeTab = computed(() => !!props.workspace && props.executionMode !== 'CLOUD')
```
```67:75:desktop/src/components/chat/FileChangePanel.vue
function handleFileClick(changePath: string) {
const workspace = sessionStore.activeSession?.workspace
const executionMode = sessionStore.activeSession?.executionMode
if (!workspace || executionMode === 'CLOUD') return
// ...
}
```
文件 I/O 全部走 Electron 主进程本地文件系统,云端工作区在**服务端磁盘**(如 `/opt/mao/data/workspace/{userId}/...`),客户端无法直接 `fs.readdir`。
### 1.2 目标
在云端模式下,提供与 LOCAL 模式**一致的用户体验**:
1. 右侧边栏展示文件树 Tab,支持懒加载展开、筛选、刷新
2. 点击文件在中心区域打开预览 Tab(语法高亮、Markdown 渲染、行号、5000 行截断等现有能力复用)
3. 文件变更面板点击可跳转预览
4. 安全:仅能访问当前会话 workspace 沙箱内的文件,且校验会话归属
### 1.3 非目标(V1 不做)
- 管理后台(admin)云端文件浏览
- 文件编辑、创建、删除
- 在 Finder 中打开、复制绝对路径等仅适用于本地的右键菜单项
- 语法高亮 / Markdown 之外的格式升级
- WebSocket 推送文件树实时变更(V1 用手动刷新)
---
## 2. 可行性结论
**可以实现,改动量中等,无需重构 Agent 运行时或工具链。**
| 维度 | 评估 |
|------|------|
| 技术可行性 | ✅ 后端已有 `PathSandbox`、`ReadFileTool`、扁平文件列表 API,补齐「单层目录列表 + 按行读文件」REST 即可 |
| 前端复用度 | ✅ `FileTree`、`FileTreeNode`、`FileViewer`、`useCenterTabs` 等 UI 组件可直接复用,主要改 I/O 层 |
| 架构影响 | 低 — 不改变 `Session.workspace` 语义,不碰 `ToolDispatcher` / WebSocket 流 |
| 预估工作量 | 后端 1~2 人日 + 前端 1.5~2 人日,合计约 **3~4 人日** |
---
## 3. 现状能力盘点
### 3.1 后端(可复用)
```text
Session.workspace(服务端绝对路径)
↓
PathSandbox.resolve(relativePath, sessionWorkspace) — 防路径穿越
ReadFileTool.execute(args, workspace) — 按行读取,返回 { content, total_lines }
FileService.listWorkspaceFiles(workspace, filter) — 递归 flat 列表(用于 @ 引用)
FileController GET /v1/files/workspace-list — 按 sessionId 查 workspace 再列文件
```
| 模块 | 说明 |
|------|------|
| `PathSandbox` | 以 session workspace 为根解析相对路径,阻止 `../` 逃逸 |
| `ReadFileTool` | 与预览需求高度一致(offset/limit/total_lines) |
| `FileService.IGNORED_DIRS` | 与 Electron `list-directory` 排除规则一致 |
| `Session.workspace` | CLOUD 会话创建时已写入(`{userId}/{sessionId}` 或 `projects/{slug}`) |
### 3.2 前端(可复用)
| 组件 / 模块 | 职责 |
|------------|------|
| `TaskInspector` | 右侧 Tab 容器,已有「任务 / 文件」切换 |
| `FileTree` + `FileTreeNode` | 目录树 UI、筛选、右键菜单 |
| `useFileBrowser` | 懒加载、展开/折叠、刷新逻辑 |
| `FileViewer` | 加载、二进制检测、高亮、Markdown |
| `useCenterTabs` | 多文件 Tab 管理(按 sessionId 隔离) |
| `ChatInput.fetchWorkspaceFiles` | **已演示** CLOUD 模式走 HTTP API 的模式 |
### 3.3 缺口
| 缺口 | 说明 |
|------|------|
| 单层目录列表 REST API | 现有 `workspace-list` 是递归 flat,无目录项,无法驱动树形懒加载 |
| 文件内容读取 REST API | 预览需专用 HTTP 接口(不宜复用 Agent 工具调用链路) |
| 前端 I/O 抽象层 | `useFileBrowser` / `FileViewer` 硬编码 `electronAPI` |
| 会话归属校验 | 现有 `workspace-list` 未校验 `session.userId === 当前用户`(需一并修复) |
| CLOUD 路径语义 | Tab 中 `filePath` 当前存本地绝对路径;云端应使用**相对路径** |
---
## 4. 总体架构
### 4.1 设计原则
1. **UI 复用、I/O 抽象**:树和预览组件不变,通过 `WorkspaceFileProvider` 切换本地 IPC / 云端 HTTP
2. **路径统一用相对路径**:Tab 与事件传递 workspace 内的相对路径(如 `src/main.ts`),由 Provider 结合上下文解析
3. **sessionId 作为云端鉴权锚点**:所有云端文件 API 必须带 `sessionId`,服务端校验归属后取 `session.workspace`
4. **与工具层规则对齐**:排除目录、单目录条目上限、预览大小/行数限制与 LOCAL 保持一致
### 4.2 架构图
```text
┌─────────────────────────────────────────────────────────────────┐
│ Desktop 客户端 │
├─────────────────────────────────────────────────────────────────┤
│ TaskInspector ──► FileTree ──► useFileBrowser(provider) │
│ CenterTabContainer ──► FileViewer(provider) │
│ FileChangePanel ──► useCenterTabs.openFileTab(relativePath) │
├─────────────────────────────────────────────────────────────────┤
│ WorkspaceFileProvider(接口) │
│ ┌────────────────────────┬────────────────────────────┐ │
│ │ LocalWorkspaceProvider │ CloudWorkspaceProvider │ │
│ │ electronAPI.listDirectory│ GET /files/workspace-dir │ │
│ │ electronAPI.localReadFile│ GET /files/workspace-read │ │
│ └────────────────────────┴────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ HTTP(仅 CLOUD)
▼
┌─────────────────────────────────────────────────────────────────┐
│ Spring Boot 后端 │
│ FileController ──► WorkspaceBrowseService │
│ │ ├── listDirectory(session, relDir) │
│ │ └── readFile(session, relPath) │
│ └── SessionService.assertOwned(sessionId, userId) │
│ PathSandbox.resolve(path, session.workspace) │
└─────────────────────────────────────────────────────────────────┘
│
▼
服务端文件系统(workspace-root)
```
---
## 5. 后端设计
### 5.1 新增服务:`WorkspaceBrowseService`
从 `ReadFileTool` 和 Electron `list-directory` 逻辑抽取,供 REST 与(未来)其他调用方共用。
```java
public class WorkspaceBrowseService {
// 与 FileService / Electron 保持一致的排除规则
private static final Set<String> IGNORED_DIRS = Set.of(
"node_modules", "__pycache__", ".git", "target", "dist", "build",
".next", ".nuxt", ".venv", "venv", ".idea", ".vscode");
private static final int MAX_ENTRIES = 500;
public DirectoryListingDTO listDirectory(String sessionWorkspace, String relativeDir);
public FileContentDTO readFile(String sessionWorkspace, String relativePath, int offset, int limit);
}
```
**`listDirectory` 行为**(对齐 `main.cjs` `list-directory`):
| 规则 | 说明 |
|------|------|
| `relativeDir` 为空或 `.` | 列出 workspace 根目录 |
| 路径解析 | `PathSandbox.resolve(relativeDir, sessionWorkspace)` |
| 排除 | 隐藏文件(`.` 开头)、`IGNORED_DIRS` 中的目录 |
| 符号链接 | 返回 `isSymlink: true`,`isDirectory: false`,前端不可展开 |
| 排序 | 文件夹在前,同类型按名称字母序 |
| 上限 | 单目录最多 500 条,超出返回 `truncated: true` |
| 返回字段 | `name`, `path`(相对 workspace), `isDirectory`, `size`, `isSymlink` |
**`readFile` 行为**(对齐 `ReadFileTool` + `FileViewer` 限制):
| 规则 | 说明 |
|------|------|
| 参数 | `path`(相对路径)、`offset`(默认 0)、`limit`(默认 5000) |
| 返回 | `{ content, total_lines }` |
| 文件不存在 | 业务错误码 + 可读 message |
| 非普通文件 | 同上 |
| 服务端保护 | 单次 `content` 字符串上限 512KB(与前端大文件策略一致) |
### 5.2 新增 REST API
#### `GET /api/v1/files/workspace-directory`
列出 workspace 内单层目录内容。
**Query 参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `sessionId` | Long | 是 | 当前会话 ID |
| `dir` | String | 否 | 相对 workspace 的目录路径,默认根目录 |
**响应示例:**
```json
{
"code": 0,
"data": {
"entries": [
{ "name": "src", "path": "src", "isDirectory": true, "size": 0, "isSymlink": false },
{ "name": "README.md", "path": "README.md", "isDirectory": false, "size": 1234, "isSymlink": false }
],
"truncated": false
}
}
```
#### `GET /api/v1/files/workspace-read`
读取 workspace 内文件内容(预览专用)。
**Query 参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `sessionId` | Long | 是 | 当前会话 ID |
| `path` | String | 是 | 相对 workspace 的文件路径 |
| `offset` | Integer | 否 | 起始行号,默认 0 |
| `limit` | Integer | 否 | 最多行数,默认 5000,最大 5000 |
**响应示例:**
```json
{
"code": 0,
"data": {
"content": "package com.example;\n...",
"total_lines": 128
}
}
```
### 5.3 鉴权与校验
在 `FileController` 中统一封装:
```java
private Session requireOwnedSession(Long userId, Long sessionId) {
Session session = sessionService.getSession(sessionId);
if (!Objects.equals(session.getUserId(), userId)) {
throw new BusinessException(ErrorCode.FORBIDDEN, "无权访问该会话");
}
if (session.getWorkspace() == null || session.getWorkspace().isBlank()) {
throw new BusinessException(ErrorCode.PARAM_INVALID, "会话未配置工作区");
}
return session;
}
```
**同步修复**:现有 `GET /files/workspace-list` 增加相同的 `userId` 校验(当前仅 `getSession(sessionId)`,存在越权读取风险)。
### 5.4 后端改动清单
| 文件 | 改动 |
|------|------|
| `WorkspaceBrowseService.java`(新增) | 目录列表 + 文件读取核心逻辑 |
| `FileController.java` | 新增 2 个 endpoint;`workspace-list` 补鉴权 |
| `ReadFileTool.java`(可选) | 委托 `WorkspaceBrowseService.readFile`,减少重复 |
**不需要改动**:`ToolDispatcher`、`AgentLoop`、`StreamingWsHandler`、`PathSandbox` 核心逻辑。
---
## 6. 前端设计
### 6.1 `WorkspaceFileProvider` 抽象
```typescript
// desktop/src/composables/workspace-file-provider.ts
export interface DirectoryEntry {
name: string
path: string // 相对 workspace
isDirectory: boolean
size: number
isSymlink: boolean
}
export interface DirectoryResult {
entries?: DirectoryEntry[]
truncated?: boolean
error?: string
}
export interface ReadFileResult {
content: string
total_lines: number
error?: string
}
export interface WorkspaceFileProvider {
listDirectory(relativeDir: string): Promise<DirectoryResult>
readFile(relativePath: string, opts?: { offset?: number; limit?: number }): Promise<ReadFileResult>
}
export function createLocalProvider(workspace: string): WorkspaceFileProvider { ... }
export function createCloudProvider(sessionId: string): WorkspaceFileProvider { ... }
```
**`LocalWorkspaceProvider`**:将现有 `electronAPI.listDirectory(absoluteDir, workspace)` / `localReadFile` 包装为相对路径接口(内部做 `workspace + '/' + relativePath` 拼接)。
**`CloudWorkspaceProvider`**:调用新增 REST API,`sessionId` 闭包绑定。
**`useWorkspaceFileProvider` composable**:
```typescript
export function useWorkspaceFileProvider(
executionMode: Ref<string>,
workspace: Ref<string>,
sessionId: Ref<string | null>
) {
return computed(() => {
if (executionMode.value === 'CLOUD' && sessionId.value) {
return createCloudProvider(sessionId.value)
}
if (workspace.value) {
return createLocalProvider(workspace.value)
}
return null
})
}
```
### 6.2 `useFileBrowser` 改造
- 入参从 `workspace: Ref<string>` 改为 `provider: Ref<WorkspaceFileProvider | null>`
- 所有 `window.electronAPI.listDirectory(...)` 替换为 `provider.value.listDirectory(relativeDir)`
- `getAbsolutePath` 可保留给 LOCAL 右键菜单,或下沉到 Provider
### 6.3 `FileTree` 改造
| 项 | 改动 |
|----|------|
| Props | 新增 `executionMode`、`sessionId`;或接收 `provider` |
| 空状态 | CLOUD 无 sessionId 时提示「请先开始对话」;LOCAL 无 workspace 时保持「请先选择工作区」 |
| 右键菜单 | CLOUD 模式隐藏「复制绝对路径」「在 Finder 中打开」;保留「复制相对路径」「添加到聊天」 |
| `open-file` 事件 | 改为传递**相对路径**:`{ path: string, title: string }` |
### 6.4 `FileViewer` 改造
- Props 新增 `provider: WorkspaceFileProvider`(或 `executionMode` + `sessionId` + `workspace` 内部创建)
- `filePath` 语义改为 **相对 workspace 的路径**(LOCAL 同步调整)
- `loadFile()` 调用 `provider.readFile(props.filePath, { limit: 5000 })`
- Markdown 内部链接解析继续使用相对路径逻辑(`resolveMarkdownLink` 已基于相对路径)
### 6.5 `TaskInspector` 改造
```typescript
// 改造前
const showFileTreeTab = computed(() => !!props.workspace && props.executionMode !== 'CLOUD')
// 改造后
const showFileTreeTab = computed(() => {
if (props.executionMode === 'CLOUD') {
return !!sessionId // 有活跃会话即可(workspace 由服务端持有)
}
return !!props.workspace
})
```
`FileTree` 传入 `provider` 而非裸 `workspace` 字符串。
### 6.6 `FileChangePanel` 改造
```typescript
function handleFileClick(changePath: string) {
const session = sessionStore.activeSession
if (!session?.workspace && session?.executionMode !== 'CLOUD') return
const title = changePath.split(/[/\\]/).pop() || changePath
openFileTab(changePath, title) // changePath 本身已是相对路径
}
```
### 6.7 `useCenterTabs` / Tab 类型
```typescript
export interface Tab {
id: string
type: 'chat' | 'file'
title: string
filePath?: string // 改为:相对 workspace 的路径(两种模式统一)
}
```
`openFileTab` 的 `id` 生成保持 `'file:' + filePath`。
**LOCAL 模式迁移**:`FileTree` 打开文件时传 `node.path`(已是相对路径),不再拼接绝对路径。`FileViewer` 通过 Provider 读取,无需感知绝对/相对差异。
### 6.8 前端改动清单
| 文件 | 改动量 | 说明 |
|------|--------|------|
| `workspace-file-provider.ts`(新增) | 中 | 双实现 + 工厂 |
| `useFileBrowser.ts` | 中 | 替换 IPC 调用 |
| `FileTree.vue` | 小 | provider 注入、事件路径、右键菜单条件 |
| `FileViewer.vue` | 小 | provider 读取 |
| `TaskInspector.vue` | 小 | 显示条件、传参 |
| `FileChangePanel.vue` | 极小 | 去掉 CLOUD 拦截 |
| `CenterTabContainer.vue` | 小 | 向 FileViewer 传递 provider |
| `TaskView.vue` | 小 | 组装 provider、调整 open-file 处理 |
---
## 7. 交互与限制(与 LOCAL 对齐)
| 场景 | 行为 |
|------|------|
| 文件 > 512KB | 树节点标记「大文件」,点击提示无法预览 |
| 二进制文件 | 读取后前端检测 null 字节,显示「二进制文件,无法预览」 |
| 文件 > 5000 行 | 仅加载前 5000 行,底部提示截断 |
| 目录不存在 / 权限错误 | 节点或预览区显示错误 + 重试 |
| Agent 执行中切换 Tab | 不影响(现有 keep-alive + session store 机制) |
| 刷新文件树 | 重新请求已展开目录(与 LOCAL 一致) |
---
## 8. 安全考量
| 风险 | 应对 |
|------|------|
| 越权访问他人会话 workspace | `requireOwnedSession(userId, sessionId)` |
| 路径穿越 | `PathSandbox.resolve`(已有) |
| 读取 workspace 外技能文件 | 浏览 API 仅允许 session workspace,不含 `allowedRoots` |
| 大文件 DoS | 目录 500 条上限、读取 5000 行 + 512KB 内容上限 |
| 只读权限会话 | 预览为读操作,READ_ONLY 权限不受影响 |
---
## 9. 实施计划
### Phase 1 — 后端 API(可独立联调)
1. 新增 `WorkspaceBrowseService`
2. 新增 `workspace-directory`、`workspace-read` 两个 endpoint
3. 修复 `workspace-list` 鉴权
4. 用 Swagger / curl 验证:创建 CLOUD 会话 → Shell 写文件 → API 列表/读取
### Phase 2 — 前端 I/O 抽象
1. 实现 `WorkspaceFileProvider` 双实现
2. 改造 `useFileBrowser`、`FileViewer`
3. LOCAL 模式回归测试(确保路径语义迁移后行为不变)
### Phase 3 — 打通 UI
1. `TaskInspector` 放开 CLOUD 文件 Tab
2. `FileChangePanel` 支持 CLOUD 点击预览
3. 右键菜单按模式裁剪
### Phase 4 — 验收
见 §10。
---
## 10. 验收标准
1. CLOUD 会话右侧出现「文件」Tab,可展开目录树、筛选、刷新
2. 点击文本文件打开中心预览 Tab,语法高亮 / Markdown 正常
3. Agent 修改文件后,文件变更面板点击可跳转预览
4. 二进制文件、超大文件、空文件展示与 LOCAL 一致
5. LOCAL 模式文件树与预览功能无回归
6. 使用他人 `sessionId` 调用新 API 返回 403
7. `../etc/passwd` 类路径被 `PathSandbox` 拒绝
8. 共享云端项目(`projects/{slug}`)多会话下,各会话文件树均指向同一 workspace 内容
---
## 11. 改动量评估
### 11.1 代码量(估算)
| 层级 | 新增 | 修改 | 删除 |
|------|------|------|------|
| 后端 Java | ~200 行 | ~50 行 | 0 |
| 前端 TS/Vue | ~150 行 | ~120 行 | ~30 行 |
### 11.2 风险等级
| 风险 | 等级 | 说明 |
|------|------|------|
| LOCAL 模式回归 | 中 | 路径从绝对改相对,需完整走查文件树、预览、@ 引用 |
| 性能(大仓库) | 低 | 懒加载 + 500 条上限,与 LOCAL 相同 |
| 安全 | 低 | 复用成熟 PathSandbox,补齐鉴权即可 |
| 架构债务 | 低 | Provider 抽象反而降低后续 admin / Web 端复用成本 |
### 11.3 与现有文档关系
| 文档 | 关系 |
|------|------|
| `2026-06-25-workspace-file-browser-design.md` | LOCAL 实现规格;本方案是其 CLOUD 扩展 |
| `2026-07-01-cloud-workspace-project-design.md` | 共享项目 workspace 已落地;本方案直接受益(多会话看到同一文件树) |
| `2026-06-18-file-reference-design.md` | `@` 引用已支持 CLOUD API;本方案与其 I/O 模式一致 |
---
## 12. 可选后续(V2)
| 项 | 说明 |
|----|------|
| WebSocket `workspace_changed` 事件 | Agent 写文件后推送,文件树自动刷新 |
| 文件变更高亮 | 树节点标记 Agent 最近修改的文件 |
| Admin 会话详情页预览 | 复用同一套 `WorkspaceBrowseService` |
| 预览 API 合并 | 将 `workspace-list` 扁平搜索也迁入 `WorkspaceBrowseService` |
---
## 13. 总结
云端模式**不支持**文件树/预览的根本原因,不是 UI 缺失,而是**文件 I/O 层只实现了 Electron 本地通道**。后端对云端 workspace 的读写能力(`PathSandbox` + `ReadFileTool` + `Session.workspace`)已经完备,仅缺少面向客户端的**浏览专用 REST API** 和前端的 **Provider 抽象**。
按本方案实施:
- **不需要**改动 Agent 循环、工具分发、WebSocket 协议
- **可以复用**现有文件树、预览、Tab 系统 UI
- **改动集中**在 `FileController` + 一个新 Service + 前端一个 composable 及少量组件传参调整
整体属于**中等规模、低风险**的功能扩展,建议在云端共享项目(`cloudProjectKey`)能力稳定后一并交付,用户体验更完整。