docs/plan/2026-06-01-local-tool-ws-merge.md
Rama main · réplica de lectura del repositorio original.
# Local Tool WS 合并到 Streaming WS 技术方案
> Note: The `bash` tool has been removed. `shell` is now the only command execution tool. See `2026-06-03-shell-unification-design.md`.
## 1. 背景与问题
桌面客户端当前维护两条 WebSocket 连接:
| 连接 | 端点 | 作用 | 生命周期 |
|------|------|------|----------|
| Streaming WS | `/ws/stream` | 推送对话消息、Agent 执行事件 | 全局单例,切换会话只发 subscribe/unsubscribe |
| Local Tool WS | `/ws/local-tool` | 本地工具执行请求/响应(Bash 审批、文件操作等) | 每次切换会话断开重连 |
切换会话时 Local Tool WS 必须断开重连(后端按 sessionId 绑定连接),导致用户看到"本地执行连接已断开,正在重连..."的误报提示。且当前设计下,切走到其他会话后,旧会话的 Agent 如需本地工具调用,因连接已断会直接失败。
## 2. 目标
- 消除 Local Tool WS,所有消息统一走 Streaming WS(单连接)
- 切换会话不再断连,无感切换
- 非活跃会话的本地工具调用正常执行
- 需审批的工具调用(如 Bash)在非活跃会话时延迟展示,用户切回时回显
## 3. 架构变更总览
```
变更前:
渲染进程 ── /ws/stream ──► StreamingWsHandler (消息/事件)
Electron main ── /ws/local-tool ──► LocalToolWebSocketHandler (工具执行)
变更后:
渲染进程 ── /ws/stream ──► StreamingWsHandler (消息/事件 + 工具执行)
│
├─ tool_execute (下行): 服务端 → 客户端
├─ tool_result (上行): 客户端 → 服务端
└─ tool_error (上行): 客户端 → 服务端
```
## 4. 后端改动
### 4.1 废弃 LocalToolWebSocketHandler
删除 `LocalToolWebSocketHandler.java`,移除 WebSocket 配置中 `/ws/local-tool` 端点注册。
### 4.2 改造 LocalToolSessionRegistry
当前 registry 是 sessionId → WebSocketSession 映射。合并后不再持有连接,改为 sessionId → userId 映射,通过 StreamingWsRegistry 发送消息。
```java
// 变更前
private final ConcurrentHashMap<Long, LocalToolConnection> connections = new ConcurrentHashMap<>();
// 变更后:sessionId → userId(用于路由工具请求)
private final ConcurrentHashMap<Long, Long> sessionUsers = new ConcurrentHashMap<>();
// sessionId → requestId → CompletableFuture(保持不变)
private final ConcurrentHashMap<Long, ConcurrentHashMap<String, CompletableFuture<String>>> pendingRequests = new ConcurrentHashMap<>();
```
关键方法变更:
- `register(sessionId, userId)` — 记录 sessionId → userId 映射,不再持有 WebSocketSession
- `unregister(sessionId)` — 清除映射,failAllPending 不变
- `sendToolRequest(sessionId, toolName, arguments, workspace)` — 通过 StreamingWsRegistry.send(userId, event) 发送 `tool_execute` 消息
- `completeToolRequest(sessionId, requestId, result)` — 不变
- `completeToolRequestError(sessionId, requestId, error)` — 不变
- `isConnected(sessionId)` — 检查 sessionUsers 中是否存在该 sessionId 且 StreamingWsRegistry.hasConnection(userId) 为 true
- 新增 `setUserForSession(sessionId, userId)` — 由 StreamingWsHandler 在 LOCAL 模式会话启动时调用
- 新增 `getUserIdForSession(sessionId)` — 返回关联的 userId
### 4.3 StreamingWsHandler 新增消息类型
在 `handleTextMessage` 的 switch 中新增:
```java
case "tool_result" -> handleToolResult(userId, root);
case "tool_error" -> handleToolError(userId, root);
```
```java
private void handleToolResult(Long userId, JsonNode root) {
Long sessionId = getLong(root, "sessionId");
String requestId = root.get("requestId").asText();
String result = root.has("result") ? root.get("result").toString() : "{}";
if (sessionId != null) {
localToolSessionRegistry.completeToolRequest(sessionId, requestId, result);
}
}
private void handleToolError(Long userId, JsonNode root) {
Long sessionId = getLong(root, "sessionId");
String requestId = root.get("requestId").asText();
String error = root.has("error") ? root.get("error").asText() : "Unknown error";
if (sessionId != null) {
localToolSessionRegistry.completeToolRequestError(sessionId, requestId, error);
}
}
```
### 4.4 LOCAL 模式会话启动时注册 userId
在 `handleSendMessage` 中,Agent 提交执行前,对 LOCAL 模式会话调用:
```java
if ("LOCAL".equals(session.getExecutionMode())) {
localToolSessionRegistry.setUserForSession(sessionId, userId);
// ... 原有的 isConnected 检查改为走新逻辑
}
```
### 4.5 StreamingWsHandler 连接断开处理
`afterConnectionClosed` 中,当用户连接断开时,需要 failAllPending 该用户所有关联 session 的 pending 请求:
```java
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
Long userId = registry.getUserId(session);
if (userId != null) {
localToolSessionRegistry.failAllForUser(userId);
}
registry.unregister(session);
}
```
### 4.6 LocalToolSessionRegistry 发送逻辑
```java
public CompletableFuture<String> sendToolRequest(Long sessionId, String toolName, String arguments, String workspace) {
Long userId = sessionUsers.get(sessionId);
if (userId == null || !streamingWsRegistry.hasConnection(userId)) {
CompletableFuture<String> f = new CompletableFuture<>();
f.complete("{\"error\":\"Local client is not connected\"}");
return f;
}
String requestId = UUID.randomUUID().toString();
CompletableFuture<String> future = new CompletableFuture<>();
pendingRequests.computeIfAbsent(sessionId, k -> new ConcurrentHashMap<>()).put(requestId, future);
// 通过 StreamingWsRegistry 发送,带上 sessionId 用于客户端路由
streamingWsRegistry.send(userId, WsEvent.of("tool_execute", sessionId, Map.of(
"requestId", requestId,
"toolName", toolName,
"arguments", arguments != null ? arguments : "{}",
"workspace", workspace != null ? workspace : ""
)));
return future;
}
```
## 5. Electron 主进程改动
### 5.1 移除 Local Tool WS 连接管理
删除 `main.cjs` 中以下内容:
- `wsClient` 变量及相关状态(`wsReconnectTimer`, `wsPingInterval`, `wsExplicitDisconnect`)
- `ws-connect` IPC handler
- `ws-disconnect` IPC handler
- `connectWebSocket()` 函数
- `requestBashApproval()` 函数(保留,但触发方式变更)
### 5.2 工具执行改为 IPC 调用
原来工具执行由 WS `tool_execute` 消息触发,改为由渲染进程通过 IPC 调用。
保留所有 `local-*` IPC handler(`local-execute-bash`, `local-read-file`, `local-write-file`, `local-edit-file`, `local-http-request`)不变。
Bash 审批流程保持不变:main process 通过 `bash-approval-request` 通知渲染进程,渲染进程通过 `bash-approval-response` IPC 返回结果。
### 5.3 新增 tool-execute IPC handler
```javascript
ipcMain.handle('tool-execute', async (event, { toolName, args, requestId }) => {
try {
let result
switch (toolName) {
case 'bash': result = await handleBashFromWebSocket(args); break
case 'shell': result = await handleShellFromWebSocket(args); break
case 'read_file': result = await handleLocalReadFile(args); break
case 'write_file': result = await handleLocalWriteFile(args); break
case 'edit_file': result = await handleLocalEditFile(args); break
case 'http_request': result = await handleLocalHttpRequest(args); break
default: result = { error: `Unknown tool: ${toolName}` }
}
return { requestId, result: JSON.stringify(result) }
} catch (e) {
return { requestId, error: e.message }
}
})
```
## 6. Preload 桥接改动
`preload.cjs` 新增:
```javascript
// 移除
// connectLocalSession, disconnectLocalSession, onWsConnectionChange, removeWsConnectionChangeListener
// 新增
toolExecute: (toolName, args, requestId) =>
ipcRenderer.invoke('tool-execute', { toolName, args, requestId }),
```
保留 Bash 审批相关 API 不变(`onBashApprovalRequest`, `respondBashApproval` 等)。
## 7. 渲染进程改动
### 7.1 useStreamWS.ts — 新增工具消息处理
在 `routeEvent` 中新增:
```typescript
case 'tool_execute': {
if (sessionId) {
// 通过 IPC 调用 Electron main process 执行工具
const { requestId, toolName, arguments: args, workspace } = data
handleLocalToolExecute(sessionId, requestId, toolName, args, workspace)
}
break
}
```
新增 `handleLocalToolExecute` 函数:
```typescript
async function handleLocalToolExecute(
sessionId: string, requestId: string, toolName: string, args: string, workspace: string
) {
const isElectron = typeof window !== 'undefined' && (window as any).electronAPI
if (!isElectron) return
try {
let parsedArgs = typeof args === 'string' ? JSON.parse(args) : args
if (workspace) parsedArgs.workdir = workspace
const result = await (window as any).electronAPI.toolExecute(toolName, parsedArgs, requestId)
// 将结果通过 WS 发回服务端
if (result.error) {
send({ type: 'tool_error', sessionId: Number(sessionId), requestId, error: result.error })
} else {
send({ type: 'tool_result', sessionId: Number(sessionId), requestId, result: result.result })
}
} catch (e: any) {
send({ type: 'tool_error', sessionId: Number(sessionId), requestId, error: e.message })
}
}
```
### 7.2 useChat.ts — 大幅简化
删除以下内容:
- `explicitDisconnect` 变量
- `connectLocalWebSocket()` 函数
- `setupBashApprovalListener()` 函数中的 WS 连接相关逻辑
- `newSession()` 中的 WS 断连逻辑
- `restoreSession()` 中的 `connectLocalWebSocket` 调用
- `cleanup()` 中的 WS 断连逻辑
- `wsConnected` ref
Bash 审批监听改为全局初始化一次(在 useStreamWS 或 app 启动时),不再跟 session 绑定。
`restoreSession()` 简化为:
```typescript
function restoreSession(sessionIdVal: string, mode: string, initialWorkspace?: string) {
if (sessionId.value && sessionId.value !== sessionIdVal) {
unsubscribe(sessionId.value)
}
sessionId.value = sessionIdVal
executionMode.value = mode
if (initialWorkspace) workspace.value = initialWorkspace
sessionStore.setActiveSession(sessionIdVal)
subscribe(sessionIdVal)
fetchMessages()
fetchTodos()
// 不再需要 connectLocalWebSocket
}
```
`sendMessage()` 中首次创建 LOCAL 会话时,不再调用 `connectLocalWebSocket`,改为在 subscribe 后由服务端在 `handleSendMessage` 中自动注册 userId。
### 7.3 session store — 新增待审批状态追踪
在 session store 中新增:
```typescript
// sessionId → 是否有待审批的工具调用
const pendingToolApprovals = ref<Map<string, boolean>>(new Map())
```
### 7.4 task list — 黄色圆点指示器
`TaskIndexPanel.vue` 中,任务标题后增加指示器:
```vue
<span
v-if="sessionStore.pendingToolApprovals.get(session.id)"
class="pending-indicator"
/>
```
```css
.pending-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #e6a23c;
margin-left: 6px;
vertical-align: middle;
}
```
## 8. 审批消息的路由与回显
### 8.1 非活跃会话的工具执行
服务端发送 `tool_execute` 消息时带 `sessionId`。渲染进程的 `handleLocalToolExecute` 无论当前是否在该会话页面,都会执行并返回结果(无需审批的工具直接执行,需审批的走 Bash 审批流程)。
### 8.2 Bash 审批的跨会话处理
Bash 审批流程:
1. 服务端发送 `tool_execute`(toolName=bash),带 sessionId
2. 渲染进程收到后,调用 `electronAPI.toolExecute('bash', args, requestId)`
3. Electron main process 的 `handleBashFromWebSocket` 调用 `requestBashApproval(command)`
4. `requestBashApproval` 通过 `bash-approval-request` IPC 通知渲染进程
5. 渲染进程收到审批请求时:
- 如果当前在该 sessionId 的会话页面:直接弹出审批弹窗(现有逻辑)
- 如果不在:在 session store 中标记 `pendingToolApprovals.set(sessionId, true)`,在任务列表显示黄点
6. 用户切回该会话时,`restoreSession` 中检查 pending approvals 并回显审批弹窗
7. 用户审批/拒绝后,结果通过原有 `bash-approval-response` IPC → main process → 返回给 `handleToolExecute` → 通过 WS 发回服务端
### 8.3 审批回显流程
用户切换到有 pending approval 的会话时:
```typescript
// useChat.ts — restoreSession 中
fetchMessages()
fetchTodos()
// 回显待审批内容(审批弹窗已通过全局 listener 接收,只需确保 UI 展示)
// pendingBashApprovals 是全局的,切换到该会话后弹窗自然可见
```
Bash 审批监听需改为全局单例(在 App 初始化时注册一次),不再绑定特定 session。审批数据按 sessionId 分组存储:
```typescript
// 全局审批状态
const pendingBashApprovalsBySession = ref<Map<string, BashApprovalItem[]>>(new Map())
```
当收到 `bash-approval-request` 时,根据 requestId 关联的 sessionId 存入对应分组。当前会话的审批直接弹窗,非当前会话的标记黄点。
## 9. 删除清单
| 文件 | 操作 |
|------|------|
| `backend/.../local/LocalToolWebSocketHandler.java` | 删除 |
| `desktop/electron/main.cjs` 中 WS 连接管理代码 | 删除(wsClient, connectWebSocket, ws-connect/disconnect IPC) |
| `desktop/electron/preload.cjs` 中 WS 连接 API | 删除(connectLocalSession, disconnectLocalSession, onWsConnectionChange) |
| `desktop/src/composables/useChat.ts` 中 WS 连接逻辑 | 删除(connectLocalWebSocket, explicitDisconnect, wsConnected) |
| 后端 WebSocket 配置中 `/ws/local-tool` 端点 | 删除 |
## 10. 改动清单
| 文件 | 改动 |
|------|------|
| `LocalToolSessionRegistry.java` | 重写:去掉 WebSocketSession 依赖,改为 sessionId→userId 映射,通过 StreamingWsRegistry 发消息 |
| `StreamingWsHandler.java` | 新增 tool_result/tool_error 消息处理;handleSendMessage 中注册 LOCAL 会话的 userId;afterConnectionClosed 中 failAllPending |
| `main.cjs` | 新增 tool-execute IPC handler;保留工具执行函数和 Bash 审批逻辑 |
| `preload.cjs` | 新增 toolExecute API;删除 WS 连接 API |
| `useStreamWS.ts` | routeEvent 新增 tool_execute 处理;新增 handleLocalToolExecute;全局注册 Bash 审批监听 |
| `useChat.ts` | 删除所有 WS 连接逻辑;restoreSession 简化 |
| `session store` | 新增 pendingToolApprovals 状态 |
| `TaskIndexPanel.vue` | 任务标题后增加黄色圆点指示器 |
| `StreamingWsRegistry.java` | 无改动 |
## 11. 迁移步骤
1. **后端**:改造 LocalToolSessionRegistry,去掉 WebSocketSession 依赖
2. **后端**:StreamingWsHandler 新增 tool_result/tool_error 处理 + LOCAL 会话 userId 注册
3. **后端**:删除 LocalToolWebSocketHandler,移除 /ws/local-tool 端点
4. **Electron**:main.cjs 新增 tool-execute IPC,删除 WS 连接管理代码
5. **Electron**:preload.cjs 更新 API 桥接
6. **渲染进程**:useStreamWS 新增 tool_execute 路由和执行逻辑
7. **渲染进程**:useChat 删除 WS 连接逻辑,简化 restoreSession
8. **渲染进程**:session store + TaskIndexPanel 增加待审批指示器
9. **测试**:验证 LOCAL 模式会话的工具执行、Bash 审批、切换会话无感、非活跃会话工具调用正常
## 12. 风险与注意事项
- **Bash 审批无超时**:后端 CompletableFuture 不设超时(用户要求),但需确保客户端断连时 failAllPending 正确触发,避免 future 永久悬挂
- **消息路由正确性**:tool_execute 消息中的 sessionId 必须准确,渲染进程必须按 sessionId 路由而非当前活跃会话
- **并发工具调用**:多个 session 可能同时发起工具调用,审批队列需按 sessionId 隔离