Ctrl K
ComunidadGuías…

docs/plan/2026-05-30-sse-to-websocket-migration.md

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

Archivo original ↗Preguntar a KiBot
# SSE → WebSocket 多任务流式输出改造方案

> Note: The `bash` tool has been removed. `shell` is now the only command execution tool. See `2026-06-03-shell-unification-design.md`.

> 解决桌面客户端切换任务时流式输出断连、无法并行监听多任务的问题。

## 1. 问题分析

### 当前架构

```
桌面客户端                           后端
┌─────────────┐                ┌──────────────────┐
│  TaskView    │  EventSource   │  SseEmitter      │
│  (单实例复用) │◄──────────────│  Registry        │
│             │  1:1 绑定 session │  (1 emitter/session) │
│  useChat    │                │                  │
│  (局部状态)  │                │  AgentLoop       │
│  messages[] │                │  (线程池执行)      │
└─────────────┘                └──────────────────┘
```

**三个核心问题:**

| # | 问题 | 根因 | 影响 |
|---|------|------|------|
| 1 | 切走即断流 | `loadSession()` 调用 `cleanup()` 关闭 EventSource | 无法后台监听任务进度 |
| 2 | 切回不重连 | `restoreSession()` 只拉历史消息,不重建 SSE | 回来后看不到实时输出 |
| 3 | 消息随组件销毁 | `messages` 是 composable 局部 ref,不在 Pinia store | 切任务 = 清空消息数组 |

### 不受影响的部分

- 后端 agent 执行不依赖客户端连接,断开后继续运行
- 消息最终持久化到 `message` 表,不会丢失最终结果
- 侧边栏 phase 指示器有 30s 轮询兜底(但不够实时)

---

## 2. 目标架构

```
桌面客户端                           后端
┌─────────────┐                ┌──────────────────┐
│  TaskView    │                │  StreamingWs     │
│  (渲染层)    │                │  Handler         │
│      ▲       │   WebSocket    │      ▲           │
│      │       │◄══════════════│      │           │
│  Pinia Store │   1:N 多路复用  │  AgentLoop      │
│  Map<sid,    │                │  (per-session)   │
│    msgs[]>   │                │                  │
└─────────────┘                └──────────────────┘

切任务 = 切换 activeSessionId → store 渲染对应 key 的消息
连接始终在线,不因切任务断开
```

**核心变化:**

1. **一条 WebSocket 连接**:用户登录后建立,生命周期跟随会话而非单个 task
2. **消息路由**:服务端按 sessionId 标记每条事件,客户端按 sessionId 分发到 Pinia store
3. **切任务零开销**:`loadSession()` 不再需要 `cleanup()`,只切换 `activeSessionId`

---

## 3. 协议设计

### 3.1 WebSocket 连接

```
URL:  ws(s)://{host}/ws/stream?token={jwt}

生命周期: 登录 → 建立连接 → 持续在线 → 登出/关闭标签页 → 断开
心跳: 客户端每 30s 发 {"type":"ping"},服务端回 {"type":"pong"}
断线重连: 客户端指数退避重连(1s → 2s → 4s → ... → 30s cap)
```

### 3.2 消息格式(服务端 → 客户端)

所有事件统一封装为:

```json
{
  "type": "content_delta | tool_call_start | tool_call_result | activity | todo_updated | session_status | session_list_update | context_window | compaction_start | compaction_end | message_end | error | pong",
  "sessionId": 123,
  "data": { ... }
}
```

`data` 字段复用现有 SSE 事件的 payload,不改格式。示例:

```json
// content_delta
{ "type": "content_delta", "sessionId": 42, "data": { "delta": "Hello" } }

// tool_call_start
{ "type": "tool_call_start", "sessionId": 42, "data": { "tool_call_id": "tc_1", "tool_name": "bash", "arguments": "{\"command\":\"ls\"}" } }

// session_status
{ "type": "session_status", "sessionId": 42, "data": { "phase": "RUNNING" } }

// session_list_update(其他 session 的状态变化)
{ "type": "session_list_update", "sessionId": 99, "data": { "phase": "COMPLETED" } }

// message_end
{ "type": "message_end", "sessionId": 42, "data": { "prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150 } }
```

### 3.3 客户端 → 服务端消息

```json
// 订阅:告诉服务端"我要收这个 session 的实时事件"
// 进入任务页面时发送,不触发任何执行
{ "type": "subscribe", "sessionId": 42 }

// 取消订阅:离开任务页面时发送(可选,服务端在连接断开时自动清理)
{ "type": "unsubscribe", "sessionId": 42 }

// 发送消息并触发 agent 执行(替代 POST /messages + GET /stream 两步流程)
// 服务端收到后:保存消息 → 缓存内容 → 提交 AgentLoop → 通过 WS 流式返回事件
{ "type": "send_message", "sessionId": 42, "data": { "content": "请帮我...", "eventId": "uuid" } }

// 心跳
{ "type": "ping" }

// 取消正在执行的 agent
{ "type": "cancel", "sessionId": 42 }
```

**subscribe 与 send_message 的关系:**

- `subscribe` 是**被动监听**:只声明"我要收事件",不触发执行。用于:打开已在运行的 session 时接收其实时输出。
- `send_message` 是**主动触发**:保存消息 + 启动 AgentLoop。隐含 subscribe 语义——不需要先 subscribe 再 send_message。
- 时序保障:`send_message` 的事件回调绑定在创建它的那次 AgentLoop 上,不会因为 subscribe 时序问题丢失事件。

**重连后的追赶机制:**

客户端重连后对 RUNNING 状态的 session 重新 subscribe,服务端发送 `session_snapshot` 事件补发当前状态:

```json
// 服务端在 subscribe 时检查 session 是否正在运行,如果是则发送一次快照
{
  "type": "session_snapshot",
  "sessionId": 42,
  "data": {
    "phase": "RUNNING",
    "currentToolCall": { "tool_call_id": "tc_3", "tool_name": "bash", "status": "running" },
    "partialContent": "已完成的部分文本输出...",
    "todos": [{ "id": "t1", "content": "分析代码", "status": "completed" }]
  }
}
```

断线期间的 `content_delta`、`tool_call_start` 等实时事件**不缓冲不补发**——客户端通过 `fetchMessages()` REST 接口拉取已持久化的完整消息历史,`session_snapshot` 只补充当前正在执行的那一轮的实时状态。

---

## 4. 后端改造

### 4.1 新增文件

| 文件 | 说明 |
|------|------|
| `session/ws/WsEvent.java` | WS 事件 DTO,包含 type、sessionId、data 字段 |
| `session/ws/StreamingWsHandler.java` | WebSocket 主处理器,替代 SSE 流式推送 |
| `session/ws/StreamingWsRegistry.java` | 连接注册表,管理 userId → WebSocket 映射 |
| `session/ws/WsStreamingEventListener.java` | 事件监听器,实现 AgentEventListener 接口,将回调转为 WS 消息 |

### 4.2 改造文件

#### `WebSocketConfig.java`

新增 `/ws/stream` 端点:

```java
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
    registry.addHandler(localToolHandler, "/ws/local-tool")
            .setAllowedOrigins("*");
    registry.addHandler(streamingWsHandler, "/ws/stream")   // 新增
            .setAllowedOrigins("*");
}
```

#### `SecurityConfig.java`

`/ws/stream` 已被现有 `/ws/**` 规则覆盖,无需改动。

#### `SessionController.java`

**删除旧端点**:`POST /v1/sessions/{id}/messages`(line 168)和 `GET /v1/sessions/{id}/stream`(line 192)整体删除,逻辑迁入 `StreamingWsHandler`。

`SessionController` 保留其余端点(创建 session、获取消息列表、删除 session 等 REST API)。

当前流程:
```
POST /messages → 保存消息 → 缓存内容 → 返回 {eventId}
GET  /stream   → 创建 SseEmitter → 提交 AgentLoop 到线程池
```

新流程:**agent 执行触发统一走 WS**,`send_message` 替代 POST /messages + GET /stream 两步:

```
客户端 WS send_message
  → StreamingWsHandler.onTextMessage()
  → 校验 session 存在且有权限
  → 如果 executionMode=LOCAL,校验 LocalToolSessionRegistry 有对应连接
  → 保存用户消息到 DB
  → 缓存内容到 Caffeine
  → 创建 WsStreamingEventListener(绑定 userId + sessionId + registry)
  → 提交 AgentLoop 到线程池
  → AgentLoop 事件回调 → WsStreamingEventListener → WS 推送给客户端
```

旧的 `POST /v1/sessions/{id}/messages` 和 `GET /v1/sessions/{id}/stream` 端点**删除**,不再保留。

**AgentLoop 生命周期(与当前 SSE 路径完全一致):**

```
send_message 到达
  → agentExecutor.submit(() -> {
      sessionService.updatePhase(sessionId, "RUNNING")
      wsRegistry.send(userId, session_status(RUNNING))
      AgentLoop loop = new AgentLoop(sessionId, content, listener, ...)
      loop.execute()  // 阻塞直到 LLM 不再调用工具
      sessionService.updatePhase(sessionId, "COMPLETED")
      wsRegistry.send(userId, session_status(COMPLETED))
      wsRegistry.send(userId, message_end(...))
    })
```

线程池配置沿用当前 `agentExecutor`(core=8, max=16, queue=200)。同一 session 不会并发执行——`send_message` 处理时检查 session phase,RUNNING 状态拒绝新请求(同当前逻辑)。

#### `SseEmitterRegistry.java` — 删除

SSE 路径整体移除,`StreamingWsRegistry` 完全替代。

#### `AgentEventListener` 实现

新增 `WsStreamingEventListener`,将每个事件回调转为 WS 消息发送:

```java
public class WsStreamingEventListener implements AgentEventListener {
    private final StreamingWsRegistry registry;
    private final Long sessionId;
    private final Long userId;

    @Override
    public void onContentDelta(String delta) {
        registry.send(userId, new WsEvent("content_delta", sessionId, Map.of("delta", delta)));
    }

    @Override
    public void onToolCallStart(String toolCallId, String toolName, String arguments) {
        registry.send(userId, new WsEvent("tool_call_start", sessionId,
            Map.of("tool_call_id", toolCallId, "tool_name", toolName, "arguments", arguments)));
    }

    // ... 其他事件同理
}
```

#### `SessionController` 广播改造

当前 `broadcastPhaseChange()` 只推给单个 session 的 SSE emitter。

**广播范围定义:** `session_list_update` 应推送给**该用户的所有已连接客户端**(多设备场景),而非所有在线用户。理由:
- session 列表是按用户隔离的,A 用户看不到 B 用户的 session
- 侧边栏只显示当前用户的 session,所以只有 session 创建者关心状态变化
- 实现:`StreamingWsRegistry.send(userId, event)` 只发给指定用户的连接集合

```java
// 在 AgentLoop 生命周期的关键节点广播:
// session phase 变更时(RUNNING/COMPLETED/FAILED)
registry.send(userId, new WsEvent("session_list_update", sessionId, Map.of("phase", "RUNNING")));
```

如果未来支持**共享 session**(多人协作),再改为按 session 成员列表广播。当前无此需求。

### 4.3 连接管理

```
StreamingWsRegistry
├── ConcurrentHashMap<Long, Set<WebSocketSession>>  // userId → 连接集合
├── ConcurrentHashMap<WebSocketSession, Long>       // 连接 → userId(反查)
├── ConcurrentHashMap<Long, Set<Long>>              // userId → 订阅的 sessionId 集合
│
├── register(session, userId)        // 连接建立时
├── unregister(session)              // 连接断开时
├── subscribe(userId, sessionId)     // 客户端发送 subscribe 时
├── unsubscribe(userId, sessionId)   // 客户端发送 unsubscribe 时
├── send(userId, event)              // 发送给指定用户的所有连接
└── broadcast(sessionId, event)      // 广播给订阅了该 session 的所有用户
```

### 4.4 并发安全

- AgentLoop 线程池与 WS 发送线程分离:AgentLoop 回调 → 事件入队 → WS 异步发送
- WS 发送使用 `WebSocketSession.sendMessage()`(异步,非阻塞 AgentLoop)
- 用户多设备登录:同一 userId 可能有多个 WS 连接,`send()` 遍历发送

---

## 5. 前端改造

### 5.1 新增文件

| 文件 | 说明 |
|------|------|
| `composables/useStreamWS.ts` | WebSocket 连接管理,替代 useSSE.ts |

### 5.2 改造文件

#### `stores/session.ts` — 新增消息缓存

```typescript
// 新增状态
const sessionMessages = ref<Map<string, ChatMessage[]>>(new Map())
const sessionTodos = ref<Map<string, TodoItem[]>>(new Map())

// 新增 actions
function appendDelta(sessionId: string, delta: string) { ... }
function addToolCallStart(sessionId: string, event: ToolCallStartEvent) { ... }
function addToolCallResult(sessionId: string, event: ToolCallResultEvent) { ... }
function setMessages(sessionId: string, messages: ChatMessage[]) { ... }
function updateTodos(sessionId: string, todos: TodoItem[]) { ... }

// 新增 getter
const activeMessages = computed(() =>
  sessionMessages.value.get(activeSessionId.value ?? '') ?? []
)
```

#### `composables/useStreamWS.ts` — 全局 WebSocket 管理

```typescript
// 在 TaskView 或 App 级别初始化一次,全局共享
export function useStreamWS() {
  const ws = ref<WebSocket | null>(null)
  const connected = ref(false)
  const reconnectDelay = ref(1000)

  function connect() {
    const token = authStore.token
    const wsUrl = apiBase.replace(/^http/, 'ws').replace(/\/api$/, '') + `/ws/stream?token=${token}`
    ws.value = new WebSocket(wsUrl)

    ws.value.onopen = () => {
      connected.value = true
      reconnectDelay.value = 1000
      // 重新订阅当前活跃 session
      subscribe(sessionStore.activeSessionId)
    }

    ws.value.onmessage = (event) => {
      const msg = JSON.parse(event.data)
      routeEvent(msg)  // 按 type + sessionId 分发到 store
    }

    ws.value.onclose = () => {
      connected.value = false
      setTimeout(connect, reconnectDelay.value)
      reconnectDelay.value = Math.min(reconnectDelay.value * 2, 30000)
    }
  }

  function routeEvent(msg: WsEvent) {
    const { type, sessionId, data } = msg

    switch (type) {
      case 'content_delta':
        sessionStore.appendDelta(sessionId, data.delta)
        break
      case 'tool_call_start':
        sessionStore.addToolCallStart(sessionId, data)
        break
      case 'tool_call_result':
        sessionStore.addToolCallResult(sessionId, data)
        break
      case 'session_status':
        sessionStore.updateSessionPhase(sessionId, data.phase)
        break
      case 'session_list_update':
        sessionStore.updateSessionPhase(sessionId, data.phase)
        break
      case 'message_end':
        sessionStore.markMessageComplete(sessionId, data)
        break
      // ... 其他事件
    }
  }

  function subscribe(sessionId: string | null) {
    if (sessionId && ws.value?.readyState === WebSocket.OPEN) {
      ws.value.send(JSON.stringify({ type: 'subscribe', sessionId }))
    }
  }

  function sendMessage(sessionId: string, content: string, eventId: string) {
    ws.value?.send(JSON.stringify({
      type: 'send_message', sessionId, data: { content, eventId }
    }))
  }

  return { connected, connect, subscribe, sendMessage, cancel }
}
```

#### `composables/useChat.ts` — 简化

改造后 `useChat` 不再持有 `messages`、`sse` 等局部状态,变为薄封装层:

```typescript
export function useChat() {
  const { sendMessage: wsSend, subscribe, cancel } = useStreamWS()
  const sessionStore = useSessionStore()

  // 消息直接从 store 读
  const messages = computed(() => sessionStore.activeMessages)
  const todos = computed(() => sessionStore.activeTodos)

  async function sendMessage(content: string) {
    // 1. 乐观更新:立即在 store 中添加用户消息
    sessionStore.addUserMessage(sessionStore.activeSessionId, content)

    // 2. 如果是新 session,先创建
    if (!sessionStore.activeSessionId) {
      await createSession()
    }

    // 3. 通过 WS 发送
    const eventId = crypto.randomUUID()
    wsSend(sessionStore.activeSessionId, content, eventId)
  }

  // loadSession 只需加载历史 + 订阅,不再 cleanup
  async function loadSession(sid: string) {
    sessionStore.setActiveSession(sid)
    subscribe(sid)  // 告诉服务端我要收这个 session 的事件
    await fetchMessages(sid)  // 拉历史消息到 store
  }

  // 不再需要 cleanup(),WS 连接是全局的

  return { messages, todos, sendMessage, loadSession, cancel }
}
```

#### `views/task/TaskView.vue` — 简化

```typescript
// 删除:
// - useSSE.ts 导入和所有相关调用
// - cleanup() 中的 sse.stop()、SSE 相关逻辑
// - messages.value = [] 等手动状态重置(消息从 store 读)
// - 30s 轮询 fetchSessions(session_list_update 已通过 WS 实时推送)

// 改造 watch:
watch(sessionIdParam, (newSid, oldSid) => {
  if (newSid && newSid !== oldSid) {
    loadSession(newSid)  // 只切换订阅 + 拉历史,不断开连接
  }
})

// 模板不变,messages 从 store 的 computed 读取
```

#### `electron/main.cjs` — 本地工具 WS 保留不变

`/ws/local-tool` 连接保持现状,它负责工具执行桥接,与流式输出无关。

---

## 6. 改造步骤

一次性完成,不分阶段。

```
后端:
1. 新建 StreamingWsHandler + StreamingWsRegistry + WsStreamingEventListener
2. WebSocketConfig 注册 /ws/stream 端点
3. StreamingWsHandler 实现消息路由:
   - subscribe → 注册到 registry 的订阅集合
   - send_message → 校验权限 + LOCAL 连接 → 保存消息 → 提交 AgentLoop
   - cancel → 取消对应 session 的 AgentLoop
   - ping → 回 pong
4. WsStreamingEventListener 接入 AgentLoop,事件通过 registry 推送给 userId
5. session_list_update 广播:phase 变更时推送给 session 创建者的所有连接
6. 删除 SessionController 中的 POST /messages + GET /stream 端点
7. 删除 SseEmitterRegistry.java

前端:
8. 新建 useStreamWS.ts,实现全局 WS 连接管理
9. stores/session.ts 新增 sessionMessages Map 和相关 actions
10. 改造 useChat.ts:messages 从 store computed 读取,sendMessage 走 WS
11. 改造 TaskView.vue:loadSession 只切订阅,删除 SSE/cleanup 逻辑和 30s 轮询
12. 删除 useSSE.ts

验证:
13. 多任务并行执行 + 切换 + 返回,确认流式输出不中断
14. 断线重连:断网 → 恢复 → session_snapshot 补发 → 消息不丢失
15. 50+ 并发 session 的 WS 广播性能
```

---

## 7. 边界场景处理

### 7.1 断线重连

```
客户端断线
  → 自动重连(指数退避 1s → 30s)
  → 重连成功后重新 subscribe 所有正在查看的 session
  → 服务端检查 session 状态:
      IDLE/COMPLETED/FAILED → 无需补偿,历史消息通过 REST 拉取
      RUNNING → 发送 session_snapshot 补发当前执行状态,之后继续实时推送
```

**断线期间丢失的事件类型:**

| 事件类型 | 是否持久化 | 断线后能否恢复 | 说明 |
|---------|-----------|--------------|------|
| `content_delta` | 否 | 不可恢复 | 实时打字效果,最终完整文本通过 ASSISTANT 消息持久化 |
| `tool_call_start` | 否 | 不可恢复 | 工具开始执行的通知,最终 TOOL 消息持久化了结果 |
| `tool_call_result` | 部分 | 通过 REST 恢复 | result 内容持久化为 TOOL 消息,但 summary/status 字段丢失 |
| `activity` | 否 | 不可恢复 | 中间活动通知,无持久化 |
| `todo_updated` | 否 | 通过 session_snapshot 恢复 | 快照中包含最新 todos 状态 |
| `context_window` | 否 | 不可恢复 | 信息性事件,不影响功能 |
| `compaction_start/end` | 否 | 不可恢复 | 信息性事件,不影响功能 |
| `session_status` | 是 | 通过 REST 恢复 | phase 持久化在 session 表 |
| `message_end` | 是 | 通过 REST 恢复 | 完整消息持久化在 message 表 |

**结论:服务端不需要缓冲事件**,因为:
1. 最终结果(USER/ASSISTANT/TOOL 消息)已持久化,`fetchMessages()` 可完整恢复
2. 实时事件(delta、activity)是"打字效果",丢失不影响最终内容
3. 中间状态(todos、当前工具调用)通过 `session_snapshot` 在重连时补发

### 7.2 多设备登录

同一用户可能在多台设备登录。`StreamingWsRegistry` 按 userId 管理连接集合,事件广播到该用户的所有连接。

### 7.3 浏览器标签页(非 Electron)

桌面端用 Electron 走原生 WS。如果未来有 Web 版,浏览器端也直接用 `new WebSocket()`,逻辑完全一致。不需要 SockJS/STOMP。

---

## 8. 风险评估

| 风险 | 概率 | 影响 | 缓解措施 |
|------|------|------|---------|
| WS 连接被 Nginx/网关超时断开 | 中 | 流式中断 | 心跳 30s + 客户端自动重连 |
| 大量并发 session 导致 WS 消息风暴 | 低 | 前端渲染卡顿 | 只推送 subscribe 的 session 事件,非全量广播 |
| AgentLoop 事件回调阻塞在 WS 发送 | 低 | agent 执行变慢 | WS 发送异步化,不阻塞回调线程 |
| 浏览器 WS 连接数限制(同域) | 极低 | 连接失败 | 一条连接多路复用,不受 6 连接限制影响 |

---

## 9. 文件变更清单

### 新增

```
backend/src/main/java/cn/etarch/mao/session/ws/
  ├── StreamingWsHandler.java
  ├── StreamingWsRegistry.java
  └── WsStreamingEventListener.java

desktop/src/composables/
  └── useStreamWS.ts
```

### 改造

```
backend/
  ├── config/WebSocketConfig.java          — 注册 /ws/stream 端点
  └── session/service/SessionService.java  — 广播 phase 变更到 WS

desktop/
  ├── stores/session.ts                    — 新增 sessionMessages Map
  ├── composables/useChat.ts              — 简化,消息从 store 读,sendMessage 走 WS
  └── views/task/TaskView.vue             — 删除 SSE 逻辑,loadSession 只切订阅
```

### 删除

```
backend/
  ├── session/SseEmitterRegistry.java          — SSE 注册表,整体移除
  └── session/controller/SessionController.java — 删除 POST /messages + GET /stream 端点

desktop/
  └── composables/useSSE.ts                    — SSE 客户端,整体移除
```

---

## 10. 附:SSE vs WebSocket 对比

| 维度 | 当前 SSE | 改造后 WebSocket |
|------|---------|-----------------|
| 连接数 | 每 session 1 条 | 全局 1 条 |
| 切任务 | 断开旧连接 + 建新连接 | 只切换渲染目标 |
| 后台监听 | 不支持 | 支持(subscribe 机制) |
| 双向通信 | 不支持 | 支持(可发送消息、取消) |
| 浏览器兼容性 | 好 | 好(IE 不支持但已不需考虑) |
| 代理/网关穿透 | 好 | 需确认 Nginx proxy_read_timeout(默认 60s,心跳 30s 可覆盖) |
| 实现复杂度 | 低 | 中 |
| 多任务体验 | 差(断流) | 好(无缝切换) |