Skip to content

Chat 组件、SSE 与会话恢复 ​

本文描述 muotai-frontend-neo 中用户端 Chat 组件与 muotai-backend/muotai-ai 的实际连接契约。内容以当前源码为基线,重点覆盖异步发送、单会话回答 SSE、用户级运行状态 SSE、结构化消息渲染、断线恢复、暂停/继续/取消以及会话保存。

1. 适用范围与源码基线 ​

主要前端源码:

责任文件
Chat 页面状态机、历史恢复、控制按钮/Users/xing/workspace/muotai/muotai-frontend-neo/src/components/npm/chat/chat.vue
消息列表、Markdown 投影、产物和引用/Users/xing/workspace/muotai/muotai-frontend-neo/src/components/npm/chat/messageList.vue
结构化块组件注册/Users/xing/workspace/muotai/muotai-frontend-neo/src/components/npm/chat/chatSection/index.ts
单会话 SSE 单例/Users/xing/workspace/muotai/muotai-frontend-neo/src/lib/sse/sseManager.ts
用户级运行状态 SSE/Users/xing/workspace/muotai/muotai-frontend-neo/src/lib/sse/runStatusSse.ts
SSE 原始事件解析/Users/xing/workspace/muotai/muotai-frontend-neo/src/lib/sse/sseEvent.ts
结构化块幂等合并/Users/xing/workspace/muotai/muotai-frontend-neo/src/lib/sse/structuredBlocks.ts
思考块扫描、分组、计时/Users/xing/workspace/muotai/muotai-frontend-neo/src/lib/thinkingGroup.ts
API 与 npm 组件可复用 SSE 适配/Users/xing/workspace/muotai/muotai-frontend-neo/src/api/aiChatbot.ts、src/api/npm/aiChatbotNpm.ts
Chat 组件依赖注入/Users/xing/workspace/muotai/muotai-frontend-neo/src/depts/chat.ts

主要后端源码:

责任文件
通用对话、SSE、控制和历史接口muotai-ai-biz/src/main/java/com/pig4cloud/pigx/ai/controller/AiChatbotAgentController.java
会话级 emitter 管理、心跳和替换.../agent/chatbot/stream/SseConnectionManager.java
Agent 响应通道注册.../channel/AgentResponseChannelRegistry.java
SSE 响应通道.../channel/sse/SseResponseChannel.java
token、事件、快照和终态推送.../agent/chatbot/stream/SseStreamHandler.java
会话串行调度、暂停、恢复、取消.../agent/chatbot/mq/CentralizedAgentMessageDispatcher.java
SSE 尚未连接时的短期终态错误缓存.../agent/chatbot/mq/PendingSseEventStore.java
用户级运行状态 SSE.../sse/UserRunStatusSseManager.java

当前实现与设计目标的差异:后端已经能够发送 response_snapshot、structured_block、thinking_*、node、rag 等事件;但 sseManager.ts 当前实际启用的 onEvent 分支只处理 artifact、reference、agent_state、file_saved、doc_progress 和 error。response_snapshot 与 structured_block 的前端处理函数仍保留为注释代码。因此,本文将“当前页面已消费的事件”和“后端已提供、前端尚未接入的事件”分开说明。

2. Chat 组件分层 ​

text
Assistant.vue / Workbench
└── chat.vue
    ├── historySessions                 会话列表、搜索、分页、删除
    ├── messageList.vue                 消息投影、操作、产物和引用
    │   └── markstreamVue               Markdown、代码、表格、KaTeX 和自定义 fence
    ├── chatInput.vue                   输入、上传、引用、暂停/继续/取消入口
    └── chatSection                     Agent 步骤和结构化块懒加载组件

chat.vue
├── ChatDeps                            API、用户、房间、文件能力的依赖注入
├── sseManager                          当前会话回答流,模块级单例
└── runStatusSse                        当前用户所有会话的运行状态流,模块级单例

ChatDeps 使 npm Chat 组件不直接依赖宿主应用的 Pinia 和 API 模块。默认实现由 createDefaultChatDeps() 桥接 AIChatbotApiService、AiAgentApiService、DocSpaceApiService 和上传服务;组件库使用方也可以传入同名函数替换具体实现。SSE 认证通过 getSseAuth() 显式提供 token、tenantId 和可选 baseUrl,因此 SSE 不依赖 axios 拦截器,也可以在独立组件包中运行。

3. 一次对话的完整时序 ​

用户端采用“异步投递 + 单独建立 SSE”的模式。发送接口只负责创建或复用会话、记录用户消息和入队,回答内容通过之后的连接返回。

3.1 发送阶段 ​

chat.vue 的 sendMessage() 调用 deps.startChatBot(),实际请求为:

http
POST /ai/v1/chatbot/{agentId}/general-chat?sse=async&stream=true
Content-Type: application/json
Authorization: Bearer <access-token>
tenant-id: <tenant-id>

请求体至少包含:

json
{
  "sessionId": "new 或已有 sessionId",
  "roomId": "当前房间",
  "content": "用户输入",
  "clientRequestId": "前端请求幂等键",
  "webSearchMode": "AUTO|FORCE|OFF"
}

后端 generalChat() 会校验用户、Agent、房间和 Sandbox 工作区;新会话时创建会话骨架并绑定空间项目,异步模式把消息提交到 Agent dispatcher,然后返回 MessageSendResponse。前端拿到 res.data.sessionId 后更新 currentSession,必要时广播 session:created,随后建立回答 SSE。

当历史会话处于 PAUSED 时,异步发送只保存补充信息,响应的 runStatus 仍为 PAUSED,不会创建新运行,也不会自动恢复。前端会把该消息显示为“补充信息已保存”,只有用户显式点击继续才调用 resume。

3.2 建立回答连接 ​

http
GET /ai/v1/chatbot/{agentId}/sessions/{sessionId}/sse/connect
Accept: text/event-stream
Authorization: Bearer <access-token>
tenant-id: <tenant-id>

AIChatbotNpmService.sseConnect() 使用 fetchEventSource,并返回 { promise, abort }。onopen 必须同时通过 HTTP 状态和 content-type 检查;否则不会进入 connected/padding 状态。AbortController 的主动中断被归类为 abort,不会误显示成服务错误。

后端每次 connectSession() 都强制新建 emitter,并通过 AgentResponseChannelRegistry.replace() 替换旧 channel。这样断线重连时旧 channel 不会继续占用 dispatcher 的消息投递路径。连接建立后还会启动 30 秒心跳,并尝试推送回答快照、执行进度快照和短期缓存的终态错误。

3.3 Agent 执行和事件投递 ​

CentralizedAgentMessageDispatcher 从消息队列取出请求,按 sessionId 路由到独占的单线程虚拟线程执行器。同一会话 FIFO,不同会话并行。SseResponseChannel 每次取当前 emitter 创建新的 SseStreamHandler,因此重连后后续事件会落到新的连接。

后端 token 不是每个字符立即发送,SseStreamHandler 会把 token 放入缓冲区并定时批量刷新;前端收到无名 message 事件时追加到 answerMsg。完成时后端发送 complete 事件,前端把消息标记为 computed,随后异步查询一次权威 run-status,避免回答流结束与运行终态之间的短暂竞态。

4. 两条 SSE 的职责边界 ​

4.1 单会话回答 SSE ​

路径:GET /ai/v1/chatbot/{agentId}/sessions/{sessionId}/sse/connect。

特点:

  • 只服务一个 sessionId;
  • 返回当前回答的 token 增量、引用、产物和 Agent 控制事件;
  • 前端由 sseManager.ts 模块级单例维护;
  • 同一页面同一 session 的活跃连接会复用;切换到另一 session 会 abort 旧连接;
  • 历史正文不由 manager 长期缓存,刷新时重新调用 history API;
  • 连接结束后保留 computed 或 error 的当前消息,直到页面主动 sseDisconnect() 清理。

SseStatus 为:

状态含义
connecting正在打开 HTTP SSE 连接
connected连接已打开,尚未明确处于等待回答阶段
padding消息已投递,等待或接收本轮回答
computed收到完成事件
error收到业务 error 或连接异常
disconnected当前 session 没有前端连接

4.2 用户级运行状态 SSE ​

路径:GET /ai/v1/chatbot/sse/run-status/connect。

这条连接不传 token 正文,而是推送当前用户所有会话的运行状态。runStatusSse.ts 用订阅者引用计数在多个 Chat、侧边栏和页面之间共享一条物理连接,状态存储在 runStatusBySession[sessionId]。

状态词表:

text
IDLE
QUEUED
RUNNING
PAUSED
COMPLETED_UNREAD / COMPLETED_READ
FAILED_UNREAD    / FAILED_READ
CANCELLED_UNREAD / CANCELLED_READ

服务端首先发送 run_status_snapshot,前端用它重建整个 Map;随后 run_status 只更新单个 session。增量事件按 updatedAt 比较,旧事件不会覆盖新状态。聊天页把 QUEUED/RUNNING 映射为 AgentRunState.running,把 PAUSED 映射为 paused,把三种终态映射为 completed/failed/cancelled。

用户正在查看某会话且收到 *_UNREAD 终态时,chat.vue 调用:

http
PUT /ai/v1/chatbot/{agentId}/sessions/{sessionId}/run-status/read

服务端随后广播 *_READ,侧边栏的运行状态灯熄灭。标记已读只改变通知语义,不删除聊天记录,也不改变回答正文。

5. SSE 原始事件和当前前端处理 ​

sseEvent() 将每一帧规范化为 { event, data, id },JSON 数据自动解析,解析失败保留字符串。complete、done、[DONE] 进入结束分支;error 进入错误结束分支;非主动网络错误会抛出以阻止 fetch-event-source 的隐式重试,重试策略由上层明确控制。

5.1 当前页面实际消费的事件 ​

事件后端数据用途sseManager 行为页面结果
无名 messagetoken 或批量文本追加 answerMsg流式更新 AI 消息
complete/done本轮完成,可带兜底 message状态置 computed停止 loading,消息 isEnd=true
artifact最终文件产物按 artifactId 更新/追加GeneratedFileCard 展示文件
reference网络或文件引用按 referenceId/URL 合并去重引用列表和正文 [[ref:id]] 链接
agent_stateRUNNING、PAUSED、CANCELLED 等控制态更新 AgentRunState暂停、继续、取消按钮和提示
file_saved文件保存路径mitt 广播 chat:file-saved文档空间等宿主场景消费,不写入正文
doc_progress文档生成阶段、文件、标题、进度mitt 广播 chat:doc-progress宿主场景显示进度,不写入正文
error错误消息和类型状态置 error,提取可读文案显示失败提示并结束当前消息

5.2 后端已发送但当前 sseManager 未启用的事件 ​

事件后端来源/设计用途当前状态
response_snapshot新连接建立时推送内存中的回答全文,覆盖恢复前端增量applyResponseSnapshot() 存在,但 onEvent 分支被注释
structured_block推送完整的 loadConfig、planning、taskEnd 等 fenceapplyStructuredBlock() 和 upsertStructuredBlock() 存在,但未接入当前事件回调
thinking_start、thinking_content、thinking_end后端思考阶段和动态文本当前 onEvent 不处理;历史/正文中的 fence 仍可由渲染层处理
node、rag、其他中间事件Agent 节点和 RAG 过程当前页面不直接消费

因此,不能把 response_snapshot 说成当前刷新后必然恢复回答的唯一机制。当前可靠恢复来源是 history API 和 run-status;要启用快照恢复,必须重新打开事件分支,并验证轮次隔离、重复快照和旧快照覆盖问题。

6. sseManager 单例和轮次隔离 ​

模块内部只有一份:

text
currentSessionId       当前 session
currentAbort           当前 abort 函数
currentMsg             当前会话的流式累积对象
messageListeners       chat.vue 等订阅者

SseMsg 主要字段为 sendMsg、answerMsg、status、agentState、agentStateReason、artifacts、references、runId、turnId 和 snapshotBlocked。

同 session 且连接处于 connecting/connected/padding 时,sseConnect() 复用连接;如果传入新 sendMsg,它会清空上一轮 answerMsg/artifacts/references,设置 snapshotBlocked=true。首个 token 或结构化块到达时解除阻塞,防止后端尚未清理的上一轮回答快照混入新轮次。

runId 标识 Agent 运行,turnId 标识持久化对话轮次。它们应被用于诊断“旧事件写入新消息”的问题;不能仅凭前端数组下标判断轮次。

7. 断线、重连与恢复 ​

7.1 当前回答 SSE 的恢复策略 ​

回答 SSE 没有像用户级 run-status SSE 那样的自动指数退避循环。恢复由页面生命周期和会话切换显式触发:

  1. 切换会话时调用后端 disconnect、前端 sseDisconnect,然后加载新 history。
  2. history 最后一条 AI 消息若为 QUEUED/RUNNING/PAUSED,标记 isEnd=false。
  3. restoreAgentRunState() 查询 GET .../run-status,把权威状态合并到全局 Map。
  4. 若仍在运行或暂停,ensureSessionSse(false) 建立当前 session SSE 并重新订阅。
  5. subscribeSseMsg() 注册后若 manager 已有同 session 数据,会立即回调一次;handleSseUpdate() 会避免用空的恢复消息覆盖 history 已有正文。

后端侧的补偿包括:

  • SseConnectionManager.create() 先写入新 emitter,再关闭旧 emitter,避免重连时出现空窗;
  • AgentResponseChannelRegistry.replace() 强制让新 channel 接管;
  • SseStreamHandler 将回答快照写入 AssistantResponseSnapshotService;
  • PendingSseEventStore 仅短期缓存无法投递的 error 终态事件,按 TTL 和每 session 最大数量裁剪;
  • dispatcher 的 session executor 在 SSE 断开后不立即销毁,给重连窗口留下继续投递的机会;容器超时才触发更重的清理。

7.2 用户级运行状态 SSE 的自动重连 ​

runStatusSse.ts 有明确的重连循环:

  • 退避为 1s、2s、4s、8s,最大 30s;
  • 每次延迟乘以 0.8~1.2 的随机抖动;
  • 连接稳定 10 秒后重置重连次数;
  • 401/403 停止重连并进入 stopped;
  • 无 token 时暂停,token 恢复或变化时立即使用新凭证重连;
  • pagehide 暂停,pageshow 恢复;
  • 所有订阅者释放后取消“保持连接”的意图。

该连接的快照事件是断线补偿的关键,所以侧边栏不依赖单个 Chat 组件是否仍持有回答 SSE。

7.3 暂停、继续和取消的竞态 ​

控制接口的 HTTP 响应、会话级 agent_state SSE 和用户级 run_status SSE 可能乱序到达。前端因此区分两类状态:

  • AgentRunState:按钮和当前页面交互态,包括 PAUSE_REQUESTED、RESUME_REQUESTED、CANCEL_REQUESTED;
  • RunStatus:后端持久化/通知态,包括 QUEUED、RUNNING、PAUSED 和三类已读/未读终态。

setAgentRunState() 会忽略已经被更晚状态超越的中间态。例如页面已收到 PAUSED 后,迟到的 PAUSE_REQUESTED 不会把按钮退回请求中;已进入 COMPLETED/FAILED/CANCELLED 后,迟到的 RESUME_REQUESTED 也不会覆盖终态。

pause/resume/cancel 都生成 clientMessageId。同一动作的网络重试复用相同 ID,后端 RunControlStore 可以返回 deduplicated=true 而不重复执行。

继续任务前,resumeTask() 会先 ensureSessionSse(true):调用后端 disconnect,清理前端连接,再新建回答 SSE,最后提交 resume。这样恢复执行产生的 token 不会落到已经失效的 emitter 上。取消不会删除 session,只会中断当前 worker 并最终产生 CANCELLED_* 状态。

8. 会话保存与恢复的三层模型 ​

8.1 导航上下文:浏览器 sessionStorage ​

src/store/modules/navigation.ts 保存当前导航上下文,例如 projectId、roomId、agentId、sessionId、sessionTitle 和引用文件。它还兼容旧 query 和 ?id= URL 迁移。

这层只回答“用户当前打开哪个会话”,不保存完整消息正文,也不代替后端权限校验。关闭浏览器标签页后通常会消失。

8.2 服务端历史:可靠正文来源 ​

刷新、重进页面、切换历史会话时调用:

http
GET /ai/v1/chatbot/sessions/{sessionId}/messages/history

后端 toHistoryMessageDtos() 将持久化消息、执行进度、运行时间、产物和引用投影为 ChatHistoryMessageDTO。前端把 type=user 映射为用户消息,其余映射为 AI 消息,并保留:

text
text
runStatus
runId / turnId
clientMessageId
executionStartedAt / executionFinishedAt
artifacts / references

运行中的最后一条 AI 消息保持 isEnd=false,这样重连后可以继续更新同一个消息节点。已结束消息的深度思考时长使用后端起止时间计算;运行中的消息以 executionStartedAt 作为计时起点。

8.3 当前增量:前端内存 ​

sseManager.currentMsg 只保存当前回答连接期间的增量。它不是持久化缓存,页面主动切换或卸载时会被 sseDisconnect()/sseDisconnectAll() 清空。连接自然结束后暂时保留 computed/error,用于页面展示和完成通知。

因此,正确的恢复顺序是“history 先恢复基线,run-status 确认状态,回答 SSE 接收后续增量”,而不是尝试从浏览器内存重建整个会话。

9. 消息渲染类型 ​

messageList.vue 把消息内容交给 markstreamVue。渲染前会将正文中的 [[ref:id]] 替换为当前消息引用锚点,并调用 wrapThinkingBlocks() 把连续的 Agent 执行 fence 包装成外层 deepThink 容器。消息结束后还会为 Markdown 表格注入下载和打印操作。

9.1 自定义 fence 和组件映射 ​

注册表位于 src/components/npm/chat/chatSection/index.ts:

fence/类型组件用途
vuevue3Msg.vue普通 Markdown/文本、Vue 流式消息容器
codeMsgcodeMsg.vue 或 markstream 代码块代码高亮、复制和下载
jsonMsgjsonMsg.vueJSON 展示
mermaidMsgmermaidMsg.vueMermaid 图表
referencereferenceFiles.vue文件或网络引用
quoteTextquoteText.vue引用文本
uploadFilesuploadFiles.vue上传文件结果
templateDoctemplateDoc.vue文档模板信息和提示词
deepThinkthinkingGroupPanel.vue可折叠的整体思考容器
thinkingthinking.vue动态思考文本
loadConfigloadConfigPanel.vueAgent 配置加载
intentRecognitionintentRecognitionPanel.vue意图识别
planningplanningPanel.vue规划摘要/步骤
skillCallskillCallPanel.vueSkill 调用
toolCalltoolCallPanel.vueTool 调用
agenticLoopagenticLoopPanel.vueAgentic Loop
taskEndtaskEndPanel.vue任务完成
texttextPanel.vue思考分组中的普通文本
taskDetailtaskDetail.vue任务详情
todoListtodoList.vue待办清单
resourceAllocationresourceAllocation.vue资源分配
chooseResultchooseResult.vue选择结果

Agent 步骤的规范类型来自 chatSectionTypes.ts:loadConfig、intentRecognition、planning、taskEnd、skillCall、toolCall、agenticLoop、text。agentStep.vue 会把中英文旧名称归一化,未知步骤保留原始文本,避免新后端步骤导致整条消息无法显示。

9.2 结构化块的幂等规则 ​

structuredBlocks.ts 能识别完整或半截 code fence,并按 block key 替换已有块:

  • 相同 key 已存在时原地替换,不追加第二个面板;
  • 没有同 key 时插入到已有结构化块之后;
  • history 已有旧块、SSE 只到达后续块时,不清空旧块;
  • 半截围栏补全时替换原半截内容;
  • 围栏前后自动补空行,防止正文被吞进代码块。

但由于 sseManager 当前未启用 structured_block 分支,这套幂等合并目前主要服务于渲染/预留恢复逻辑,以及后续接入事件时的实现基础。

9.3 思考分组和计时 ​

thinkingGroup.ts 扫描连续的 Agent fence,把它们包装为:

text
````deepThink
{"start": 运行开始时间, "end": 运行结束时间}
```loadConfig
...

...

```

外层使用比内层多一个反引号,避免内层 fence 提前关闭外层。流式过程中支持未闭合 fence 和半截 tag;历史消息优先使用 `executionStartedAt/executionFinishedAt` 恢复时长,缺少时间字段时不伪造耗时。

## 10. 产物、引用和文件事件

`artifact` 事件按 `artifactId` 更新,页面通过 `GeneratedFileCard` 展示文件名、扩展名、大小、房间路径和状态,并可调用 `getChatArtifact`、下载原始文件、保存到房间或定位到文档空间。

`reference` 事件按 `referenceId` 或 URL 去重并按 ID 排序。正文中的 `[[ref:12]]` 只有在引用数组包含 12 时才会变成内部链接,否则保留原 marker,避免错误跳转。

`file_saved` 和 `doc_progress` 不进入 AI 正文,而通过 mitt 广播给文档空间/文档生成宿主。调试时应同时查看 SSE 帧和 mitt 事件,否则容易误以为“后端未返回文件进度”。

## 11. API 清单

| 用途 | 方法和路径 | 前端调用 |
| --- | --- | --- |
| 异步发送 | `POST /ai/v1/chatbot/{agentId}/general-chat?sse=async&stream=true` | `startChatBot` |
| 当前会话回答流 | `GET /ai/v1/chatbot/{agentId}/sessions/{sessionId}/sse/connect` | `sseConnect` |
| 会话历史 | `GET /ai/v1/chatbot/sessions/{sessionId}/messages/history` | `getMessagesHistoryBySessionId` |
| Agent 运行状态 | `GET /ai/v1/chatbot/{agentId}/sessions/{sessionId}/run-status` | `getAgentRunStatus` |
| 用户级运行状态流 | `GET /ai/v1/chatbot/sse/run-status/connect` | `connectRunStatusSse` |
| 标记终态已读 | `PUT /ai/v1/chatbot/{agentId}/sessions/{sessionId}/run-status/read` | `markRunStatusRead` |
| 请求暂停 | `POST /ai/v1/chatbot/{agentId}/sessions/{sessionId}/pause` | `pauseChatBot` |
| 从 checkpoint 继续 | `POST /ai/v1/chatbot/{agentId}/sessions/{sessionId}/resume` | `resumeChatBot` |
| 立即取消 | `POST /ai/v1/chatbot/{agentId}/sessions/{sessionId}/cancel` | `cancelChatBot` |
| 主动断开回答流 | `GET /ai/v1/chatbot/{agentId}/sessions/{sessionId}/disconnect` | `disconnectChatBot` |
| 会话列表 | `GET /ai/v1/chatbot/history/sessions/agent/{agentId}` | `getSessionsHistoryByAgentId` |
| 删除会话 | `DELETE /ai/v1/chatbot/sessions/{sessionId}` | `delSessions` |

暂停/继续/取消接口的返回 `status` 是控制受理态(例如 `PAUSE_REQUESTED`、`RESUME_REQUESTED`、`CANCEL_REQUESTED`),不是用户级 `RunStatus`。最终结果必须以 run-status SSE 或 REST 查询为准。

## 12. 常见故障与定位方法

### 12.1 页面显示“已发送”但没有回答

依次检查:

1. `general-chat` 是否返回 `sessionId` 和 `messageId`;
2. `sse/connect` 是否返回 200 且 `content-type` 包含 `text/event-stream`;
3. 请求头中的 Bearer token 和 `tenant-id` 是否有效;
4. 浏览器 Network 是否收到 heartbeat 或 SSE 帧;
5. 后端是否执行了 `connectSession()`、`channelRegistry.replace()`;
6. `run_status` 是否为 `QUEUED/RUNNING`;
7. 是否把 `PAUSED` 会话误当成新运行,或在页面卸载时调用了 disconnect。

### 12.2 重连后回答重复或被旧内容覆盖

检查 `sessionId`、`runId`、`turnId` 和 `clientRequestId`。前端新一轮发送应设置 `snapshotBlocked` 并清空本轮增量;后端重连应创建新 emitter 和新 channel。若重新启用 `response_snapshot`,必须验证快照只覆盖同一运行,且首 token 到达前不会覆盖新轮次。

### 12.3 侧边栏状态灯不熄灭

检查是否收到 `COMPLETED_UNREAD/FAILED_UNREAD/CANCELLED_UNREAD`,聊天页是否调用 `run-status/read`,以及后端是否广播对应 `*_READ`。不要只观察回答 SSE 的 `complete`,它不等价于已读状态。

### 12.4 SSE 连接频繁重试

区分两条连接:回答 SSE 的重建由页面显式触发;用户级 run-status SSE 才有指数退避。401/403 不会自动重连,应先检查登录状态、租户和网关权限。页面隐藏时 `runStatusSse` 会暂停,回到页面会重新连接。

## 13. 联调与测试清单

至少应覆盖:

- 新会话首次发送:返回新 `sessionId`,收到 token、complete,历史可再次加载;
- 已有会话续聊:同 session 连接复用,上一轮正文不混入新轮;
- 刷新页面:history 恢复正文,QUEUED/RUNNING/PAUSED 状态正确,必要时自动重建回答 SSE;
- SSE 连接建立前后顺序变化:异步发送后延迟 connect,确认短期错误和历史状态可补偿;
- 网络断开后重连:后端 emitter/channel 替换,不能出现旧 channel 投递或重复面板;
- `artifact`、`reference`、`file_saved`、`doc_progress` 的事件和宿主广播;
- `response_snapshot`、`structured_block` 接入前后的轮次隔离测试;
- pause 在 checkpoint 生效、resume 携带补充信息、cancel 立即中断;
- 控制请求重复提交时复用 `clientMessageId`,后端返回幂等结果;
- run-status 快照、过期增量、未读终态和标记已读;
- Markdown、代码、JSON、Mermaid、表格打印/下载、引用锚点和未知 fence fallback;
- token 过期、租户变更、pagehide/pageshow、多 Tab 订阅者释放。

## 14. 维护注意事项

1. 修改事件名称或 payload 时,同时更新后端 `SseStreamHandler`、前端 `sseEvent`/`sseManager`、历史 DTO 和本文事件表。
2. 修改运行状态词表时,同时更新 `runStatusSse.ts`、`chat.vue` 的映射和按钮状态,不能把控制受理态与最终 RunStatus 混用。
3. 修改结构化 fence 时,保持后端持久化文本、`structuredBlocks.ts`、`thinkingGroup.ts` 和 `chatSection/index.ts` 的 tag 同源。
4. SSE 连接属于资源,新增页面订阅必须在卸载时释放;用户级连接应使用引用计数,不能每个组件单独创建物理连接。
5. README 或设计文档描述的能力不代表当前页面已启用,发布前应以 `onEvent` 分支和实际网络帧为准。