docs/plan/technical-design/skill-workspace-sync.md
Rama main · réplica de lectura del repositorio original.
# Skills 工作区同步方案
## 1. 背景与问题
当前 Skill 文件存放在服务端外部目录(`app.harness.skills-dir`),由 `SkillLoader` 扫描加载。System prompt 中注入的是**服务端绝对路径**。
- **CLOUD 模式**:`read_file` 在服务端执行,可以正常读取。但路径是服务端绝对路径,耦合了部署环境。
- **LOCAL 模式**:`read_file` 通过 WebSocket 转发到客户端执行,客户端本地不存在这些文件,读取必然失败。
## 2. 核心思路
**统一路径模型**:Skills 同步到工作区的 `.skills/` 隐藏目录下,system prompt 注入**相对路径**(`.skills/<name>/SKILL.md`)。两种模式下 `read_file` 都读取工作区内的文件。
```
<workspace>/
.skills/
bigdata-cli/
SKILL.md
bin/bigdata-cli.js
lib/...
reference/...
another-skill/
SKILL.md
...
<用户项目文件>
```
## 3. CLOUD 模式
### 3.1 流程
```
用户发送消息
→ StreamingWsHandler.handleSendMessage()
→ agentExecutor.submit(() -> {
// 新增:同步 Skills 到工作区
skillSyncService.syncToWorkspace(agent, workspace)
// 原有逻辑
harnessService.executeFromEvent(...)
})
```
### 3.2 SkillSyncService(新增)
```java
@Component
public class SkillSyncService {
// 从 app.harness.skills-dir 读取源 Skills
// 按 agent.skillNames 过滤(为空则同步全部)
// 逐个拷贝到 <workspace>/.skills/<skillName>/
// 增量同步:对比目录最后修改时间,跳过未变化的
public void syncToWorkspace(Agent agent, String workspace) { ... }
// 将指定 Skills 打包为 zip 流(供 LOCAL 模式 REST 接口使用)
public void writeSyncZip(Agent agent, OutputStream out) { ... }
// 获取需要删除的 Skill 名称列表
public List<String> getRemovedSkillNames(Agent agent, String workspace) { ... }
// 会话结束或 Agent 切换时清理
public void cleanWorkspace(String workspace) { ... }
}
```
### 3.3 PromptEngine 变更
```java
// 改前:注入服务端绝对路径
sb.append(" File: `").append(doc.getFilePath()).append("`");
// 改后:注入相对路径
sb.append(" File: `.skills/").append(doc.getName()).append("/SKILL.md`");
sb.append(" Folder: `.skills/").append(doc.getName()).append("`");
```
### 3.4 PathSandbox
无需改动。`.skills/` 在 workspace 内部,`PathSandbox.resolve()` 天然允许访问。
## 4. LOCAL 模式
### 4.1 流程
```
客户端发送 send_message(streaming WS)
→ StreamingWsHandler.handleSendMessage()
→ [新增] 通知客户端同步 Skills:
1. 服务端通过 streaming WS 发送 skill_sync_required 事件
2. 客户端 POST /v1/skills/sync-package?sessionId=X 下载 zip
3. 客户端解压到 .skills/ 目录
4. 客户端通过 streaming WS 发送 skill_sync_done
5. 服务端收到确认后继续
→ agentExecutor.submit(() -> {
harnessService.executeFromEvent(...)
})
```
### 4.2 REST 接口:`POST /v1/skills/sync-package`
**Controller**:`SkillSyncController`(新增)
```java
@RestController
@RequestMapping("/v1/skills")
@RequiredArgsConstructor
public class SkillSyncController {
private final SkillSyncService skillSyncService;
private final SessionService sessionService;
@PostMapping(value = "/sync-package", produces = "application/zip")
public void downloadSyncPackage(
@RequestParam Long sessionId,
HttpServletResponse response) {
Session session = sessionService.getSession(sessionId);
Agent agent = agentService.getAgent(session.getAgentId());
response.setStatus(HttpStatus.OK.value());
response.setHeader("Content-Disposition",
"attachment; filename=\"skills.zip\"");
skillSyncService.writeSyncZip(agent, response.getOutputStream());
}
}
```
**zip 包结构**:
```
skills.zip
├── .sync-manifest.json ← 同步元信息(见 4.5)
├── bigdata-cli/
│ ├── SKILL.md
│ ├── bin/bigdata-cli.js
│ ├── lib/...
│ └── reference/...
└── another-skill/
├── SKILL.md
└── ...
```
### 4.3 WebSocket 消息协议
**服务端 → 客户端**(`skill_sync_required` 事件,通过 streaming WS):
```json
{
"type": "skill_sync_required",
"sessionId": 123,
"data": {
"syncUrl": "/api/v1/skills/sync-package?sessionId=123",
"removed": ["old-skill-name"]
}
}
```
**客户端 → 服务端**(确认消息,通过 streaming WS):
```json
{
"type": "skill_sync_done",
"sessionId": 123
}
```
### 4.4 服务端实现
`StreamingWsHandler.handleSendMessage()` 中,agentExecutor.submit 之前增加同步步骤:
```java
private void handleSendMessage(Long userId, JsonNode root) {
// ... 原有校验逻辑 ...
// 新增:LOCAL 模式下同步 Skills
if ("LOCAL".equals(session.getExecutionMode())) {
boolean synced = syncSkillsToClient(userId, sessionId, session);
if (!synced) {
registry.send(userId, WsEvent.of("error", sessionId,
Map.of("message", "Skill sync failed")));
return;
}
}
// 原有逻辑:提交到线程池执行
agentExecutor.submit(() -> { ... });
}
private boolean syncSkillsToClient(Long userId, Long sessionId, Session session) {
// 1. 发送 skill_sync_required 事件,告知客户端下载地址
String syncUrl = "/api/v1/skills/sync-package?sessionId=" + sessionId;
List<String> removed = skillSyncService.getRemovedSkillNames(
session.getAgentId(), session.getWorkspace());
CompletableFuture<Void> syncFuture = new CompletableFuture<>();
pendingSkillSyncs.put(sessionId, syncFuture);
registry.send(userId, WsEvent.of("skill_sync_required", sessionId,
Map.of("syncUrl", syncUrl, "removed", removed)));
// 2. 等待客户端确认(超时 60 秒,zip 下载 + 解压需要时间)
try {
syncFuture.get(60, TimeUnit.SECONDS);
return true;
} catch (Exception e) {
log.warn("Skill sync timeout for session {}", sessionId);
return false;
} finally {
pendingSkillSyncs.remove(sessionId);
}
}
```
收到客户端 `skill_sync_done` 时:
```java
case "skill_sync_done" -> {
CompletableFuture<Void> future = pendingSkillSyncs.get(sessionId);
if (future != null) future.complete(null);
}
```
### 4.5 客户端实现(Electron main.cjs)
在 `ws.on('message')` 处理中新增 `skill_sync_required` 类型:
```javascript
case 'skill_sync_required': {
const { syncUrl, removed } = msg.data
const sessionId = msg.sessionId
// 异步执行:下载 zip → 解压 → 确认
handleSkillSync(sessionId, syncUrl, removed).catch(err => {
console.error('Skill sync failed:', err)
})
break
}
```
新增 `handleSkillSync` 函数:
```javascript
async function handleSkillSync(sessionId, syncUrl, removed) {
const skillsDir = path.join(currentWorkspace, '.skills')
// 1. 删除已移除的 Skills
for (const name of (removed || [])) {
const dir = path.join(skillsDir, name)
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true })
}
// 2. 下载 zip 包
const baseUrl = getApiBaseUrl() // 复用项目的 API 基础地址
const token = getAuthToken() // 从 store 或 localStorage 获取 JWT
const response = await fetch(`${baseUrl}${syncUrl}`, {
headers: { 'Authorization': `Bearer ${token}` }
})
if (!response.ok) {
throw new Error(`Skill sync download failed: ${response.status}`)
}
// 3. 解压到 .skills/ 目录
const zipBuffer = Buffer.from(await response.arrayBuffer())
await extractZip(zipBuffer, skillsDir)
// 4. 通知服务端同步完成
if (wsClient && wsClient.readyState === WebSocket.OPEN) {
wsClient.send(JSON.stringify({
type: 'skill_sync_done',
sessionId
}))
}
}
```
新增 `extractZip` 函数(使用 `adm-zip`,Electron 项目已有 Node 环境):
```javascript
const AdmZip = require('adm-zip')
async function extractZip(zipBuffer, targetDir) {
fs.mkdirSync(targetDir, { recursive: true })
const zip = new AdmZip(zipBuffer)
zip.extractAllTo(targetDir, true) // true = overwrite
}
```
> 注:`adm-zip` 需加入 `desktop/package.json` 的 dependencies。也可选用 `yauzl`(流式解压,内存更友好),但 `adm-zip` API 更简单。
### 4.6 同步元信息(.sync-manifest.json)
zip 包内包含 `.sync-manifest.json`,记录本次同步的元信息:
```json
{
"syncedAt": "2026-05-31T10:00:00Z",
"agentId": 42,
"skills": [
{ "name": "bigdata-cli", "version": "20260531-100000" },
{ "name": "another-skill", "version": "20260531-100000" }
]
}
```
用途:
- 客户端可读取此文件判断当前 Skills 版本
- 后续支持增量同步时,客户端可将此版本号回传服务端做对比
### 4.7 路径解析
客户端 `resolveWorkspacePath()` 已有逻辑:相对路径拼接 `currentWorkspace`。`.skills/bigdata-cli/SKILL.md` 会被正确解析为 `<workspace>/.skills/bigdata-cli/SKILL.md`。
## 5. 增量同步策略
### 5.1 服务端判断
`SkillSyncService` 维护每个 workspace 的同步状态:
```java
// "agentId:workspace" → (skillName → lastSyncTime)
private final Map<String, Map<String, Long>> syncState = new ConcurrentHashMap<>();
```
对比源目录的 `lastModified` 与上次同步时间:
- 新增:源目录存在但上次未同步 → 包含在 zip 中
- 更新:源目录 lastModified > lastSyncTime → 包含在 zip 中
- 删除:上次同步过但源目录已不存在 → 加入 `removed` 列表
- 未变:跳过,不包含在 zip 中
### 5.2 首次同步
首次同步发送全部文件,无增量逻辑。
## 6. System Prompt 注入变更
`PromptEngine.buildSystemPrompt()` 中,skill catalog 改为使用相对路径:
```java
sb.append("- **").append(doc.getName()).append("**: ");
sb.append(doc.getDescription());
sb.append("\n Folder: `.skills/").append(doc.getName()).append("`");
sb.append("\n File: `.skills/").append(doc.getName()).append("/SKILL.md`");
sb.append("\n To learn more, read: `.skills/").append(doc.getName()).append("/SKILL.md`");
```
Agent 通过 `read_file` 读取 `.skills/<name>/SKILL.md` 获取完整内容,以及 `.skills/<name>/reference/*.md` 等附属文件。
## 7. SkillLoader 变更
`SkillLoader` 的职责保持不变(扫描源目录、解析 SKILL.md、缓存)。新增方法供 `SkillSyncService` 使用:
```java
/**
* 获取指定 Skill 的文件夹路径。
*/
public Path getSkillFolder(String skillName) { ... }
/**
* 获取所有已加载 Skill 的名称集合。
*/
public Set<String> getAllNames() { ... }
```
`SkillSyncService` 直接读取文件系统打包 zip,不需要 `SkillLoader` 额外提供文件内容读取方法。
## 8. 清理机制
| 触发时机 | 动作 |
|---------|------|
| Session 删除 | `SkillSyncService.cleanWorkspace(workspace)` 删除 `.skills/` 目录 |
| Agent 切换(同一 workspace) | 增量同步自动处理:删除旧 Skill、添加新 Skill |
| 服务端重启 | CLOUD 模式下次 execute 时重新同步;LOCAL 模式客户端保留文件,下次消息时增量更新 |
## 9. 改动文件清单
### 新增
| 文件 | 说明 |
|------|------|
| `backend/.../harness/skill/SkillSyncService.java` | Skills 工作区同步核心逻辑(CLOUD 拷贝 + LOCAL zip 打包) |
| `backend/.../skill/controller/SkillSyncController.java` | REST 接口 `POST /v1/skills/sync-package` |
### 修改
| 文件 | 改动 |
|------|------|
| `backend/.../harness/skill/SkillLoader.java` | 新增 `getSkillFolder()` 方法 |
| `backend/.../harness/core/PromptEngine.java` | skill catalog 改用相对路径 |
| `backend/.../session/ws/StreamingWsHandler.java` | LOCAL 模式下发送 `skill_sync_required`、接收 `skill_sync_done` |
| `desktop/electron/main.cjs` | 新增 `skill_sync_required` 处理:下载 zip → 解压 → 确认 |
| `desktop/package.json` | 新增 `adm-zip` 依赖 |
## 10. 限制与注意事项
1. **zip 包大小**:流式打包,不落盘,内存占用可控。单个 Skill 目录建议 < 50MB(含 node_modules 等)。
2. **二进制文件**:zip 天然支持二进制,无限制。
3. **并发安全**:同一 workspace 可能有多个 session 并发同步。CLOUD 模式通过文件系统原子写入保证一致;LOCAL 模式 zip 解压是覆盖写入,后写入者生效。
4. **超时处理**:客户端 60 秒未完成下载 + 解压 + 确认,服务端中止本次执行并报错。
5. **认证**:REST 接口需复用现有 JWT 认证机制,客户端请求时携带 `Authorization` header。
> 注:本文档描述的 `.skills/` 工作区路径为早期方案。当前实现改为使用会话运行时目录
> (CLOUD:`{runtime-dir}/{userId}/{sessionId}/skills/`;LOCAL:`~/.mao/runtime/{sessionId}/skills/`),
> 详见 `RuntimeDataResolver` 与 `PromptEngine.buildSkillCatalog()`。整体同步流程(`skill_sync_required`/`skill_sync_done`)保持一致。
## 11. LOCAL 模式:本地未上传 Skill 直接可用(无需上传)
### 11.1 背景
桌面端「技能管理」抽屉的「本地未上传」Tab 会扫描 `~/.agents/skills` 下的 Skill 目录,但此前必须先上传到服务端(写入 `user-skills-dir`)才能被 Agent 使用——即便是 LOCAL 模式任务,工具执行本就委托给桌面端 Electron 执行,理论上完全可以直接读取本地文件,无需先上传一份到服务端。
### 11.2 方案
LOCAL 模式下,`read_file` 等工具本就通过 `LocalToolExecutor` 委托给桌面端执行,路径解析(`resolveWorkspacePath`)支持任意 `~` 前缀的绝对路径。因此只需:
1. 桌面端在 LOCAL 模式下发送 `send_message` / `edit_and_resend` 时,附带当前扫描到的本地未上传 Skill 列表(`{name, description, folderName}`),随消息一起上报。
2. 服务端 `LocalSkillRegistry`(内存态,按 `sessionId` 存储)接收上报,`HarnessService.buildContext()` 将其与系统 Skill、已上传的用户 Skill 合并(系统/已上传优先,避免同名冲突)。
3. `PromptEngine.buildSkillCatalog()` 对这些「本地未同步」Skill 使用桌面端本机路径 `~/.agents/skills/{folderName}/SKILL.md`(而不是 runtime 目录路径),并在描述中注明「本地未同步,仅本次本地任务可用」。
4. Agent 调用 `read_file` 读取该路径时,桌面端 Electron 直接从 `~/.agents/skills/{folderName}/SKILL.md` 读取,无需任何服务端同步(不生成 `.sync-manifest.json`、不打包 zip)。
若用户希望在 **CLOUD 模式**任务中使用这些 Skill,仍需按原流程上传(`POST /user-skills/upload`),因为 CLOUD 模式下工具在服务端执行,服务端磁盘上必须存在对应文件。
### 11.3 关键改动
| 文件 | 改动 |
|------|------|
| `backend/.../harness/skill/LocalSkillRef.java`(新增) | 本地未上传 Skill 的引用 POJO:`name`/`description`/`folderName` |
| `backend/.../harness/skill/LocalSkillRegistry.java`(新增) | 内存态会话级登记表,按 `sessionId` 存储桌面端上报的本地 Skill 列表 |
| `backend/.../harness/runtime/RuntimeDataResolver.java` | 新增 `formatLocalUnsyncedSkillsDir/Path()`,格式化 `~/.agents/skills/{folderName}` 路径 |
| `backend/.../harness/core/AgentExecutionContext.java` | 新增 `localUnsyncedSkills` 字段 |
| `backend/.../harness/core/HarnessService.java` | `buildContext()` 合并本地未同步 Skill(不覆盖系统/已上传同名 Skill) |
| `backend/.../harness/core/PromptEngine.java` | Skill catalog 对本地未同步 Skill 使用本机路径并附加提示;`${skill}$` 标记识别新增本地未同步 Skill |
| `backend/.../session/ws/StreamingWsHandler.java` | `send_message`/`edit_and_resend`/`create_side_session` 解析 `data.localSkills`,写入 `LocalSkillRegistry`(按各自的 `sessionId`,Side Task 用其子会话 ID);执行结束后清理 |
| `desktop/src/composables/useStreamWS.ts` | `sendMessage`/`sendEditMessage`/`createSideSession` 新增可选 `localSkills` 参数 |
| `desktop/src/utils/localSkills.ts`(新增) | `collectLocalUnsyncedSkills()` 共享工具函数,供主聊天与 Side Task 共用 |
| `desktop/src/composables/useChat.ts` | LOCAL 模式发送/编辑消息前调用共享工具收集本地未上传 Skill 并随消息上报 |
| `desktop/src/components/chat/SideChatPanel.vue` | Side Task 首条消息(`createSideSession`)与后续消息(`sendMessage`)均收集并上报本地未上传 Skill |
| `desktop/src/components/chat/ChatInput.vue` | 快捷指令面板(`/`)合并本地未上传 Skill,便于插入 `${skill}$` 标记 |
| `desktop/src/components/skill/SkillDrawer.vue` | 「本地未上传」Tab 提示文案,说明可直接用于本地任务 |
### 11.4 限制
- 仅覆盖 `send_message` / `edit_and_resend` / `create_side_session` 三个消息发送路径;消息队列自动消费(`enqueue_message` 自动出队)复用会话上一次上报的快照,不会重新扫描。
- 本地未同步 Skill 的上报为内存态、按 `sessionId` 覆盖式存储,服务重启后丢失(与现有 `SkillSyncService.syncState` 一致的设计取舍)。
- Side Task 创建时(`handleCreateSideSession`)新增 LOCAL 模式桌面端连接性校验:未连接直接返回错误、不创建子会话,避免创建后才发现无法执行工具。