Appearance
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 行为 | 页面结果 |
|---|---|---|---|
无名 message | token 或批量文本 | 追加 answerMsg | 流式更新 AI 消息 |
complete/done | 本轮完成,可带兜底 message | 状态置 computed | 停止 loading,消息 isEnd=true |
artifact | 最终文件产物 | 按 artifactId 更新/追加 | GeneratedFileCard 展示文件 |
reference | 网络或文件引用 | 按 referenceId/URL 合并去重 | 引用列表和正文 [[ref:id]] 链接 |
agent_state | RUNNING、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 等 fence | applyStructuredBlock() 和 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 那样的自动指数退避循环。恢复由页面生命周期和会话切换显式触发:
- 切换会话时调用后端
disconnect、前端sseDisconnect,然后加载新 history。 - history 最后一条 AI 消息若为
QUEUED/RUNNING/PAUSED,标记isEnd=false。 restoreAgentRunState()查询GET .../run-status,把权威状态合并到全局 Map。- 若仍在运行或暂停,
ensureSessionSse(false)建立当前 session SSE 并重新订阅。 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/类型 | 组件 | 用途 |
|---|---|---|
vue | vue3Msg.vue | 普通 Markdown/文本、Vue 流式消息容器 |
codeMsg | codeMsg.vue 或 markstream 代码块 | 代码高亮、复制和下载 |
jsonMsg | jsonMsg.vue | JSON 展示 |
mermaidMsg | mermaidMsg.vue | Mermaid 图表 |
reference | referenceFiles.vue | 文件或网络引用 |
quoteText | quoteText.vue | 引用文本 |
uploadFiles | uploadFiles.vue | 上传文件结果 |
templateDoc | templateDoc.vue | 文档模板信息和提示词 |
deepThink | thinkingGroupPanel.vue | 可折叠的整体思考容器 |
thinking | thinking.vue | 动态思考文本 |
loadConfig | loadConfigPanel.vue | Agent 配置加载 |
intentRecognition | intentRecognitionPanel.vue | 意图识别 |
planning | planningPanel.vue | 规划摘要/步骤 |
skillCall | skillCallPanel.vue | Skill 调用 |
toolCall | toolCallPanel.vue | Tool 调用 |
agenticLoop | agenticLoopPanel.vue | Agentic Loop |
taskEnd | taskEndPanel.vue | 任务完成 |
text | textPanel.vue | 思考分组中的普通文本 |
taskDetail | taskDetail.vue | 任务详情 |
todoList | todoList.vue | 待办清单 |
resourceAllocation | resourceAllocation.vue | 资源分配 |
chooseResult | chooseResult.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` 分支和实际网络帧为准。