diff --git a/frontend-web/src/position-roundtable/docs/沙箱打开关闭与步骤条.md b/frontend-web/src/position-roundtable/docs/沙箱打开关闭与步骤条.md
index 3a80f5fe..ec5e08fa 100644
--- a/frontend-web/src/position-roundtable/docs/沙箱打开关闭与步骤条.md
+++ b/frontend-web/src/position-roundtable/docs/沙箱打开关闭与步骤条.md
@@ -1,391 +1,1504 @@
-# 岗位会商沙箱对接指南(重做页面 / 排障)
+# DeerFlow 对话流式消息、思考步骤与沙箱生命周期开发文档
-## 文档用途
-
-给**下次重做或对接岗位会商页面**的同事用,也给线上「沙箱不实时 / 卡死 / 第二次不刷新」时排障用。
-
-读完应能回答:
-
-1. 新页面要接哪几个回调、传什么字段?
-2. 打开 / 关闭沙箱各在什么时机?和步骤条是什么关系?
-3. 哪些写法绝对不能用(会复现历史 bug)?
-4. 出问题时按什么顺序查?
-
-对照实现(当前生产路径):
-
-| 角色 | 文件 |
-| --- | --- |
-| 页面壳(沙箱唯一宿主) | `pages/PositionRoundtablePage.tsx` |
-| 节点对话(探测 write_file、上报预览) | `components/PositionNodeChat.tsx` |
-| 收口对话 | `components/PositionBuiltinAgentChat.tsx` |
-| 预览 UI(共享组件,勿分叉逻辑) | `components/workspace/artifacts/artifact-file-detail.tsx` |
-| 流式正文读取 | `core/artifacts/loader.ts` → `loadArtifactContentFromToolCall` |
-| 普通对话页对照(步骤条自动开) | `components/workspace/messages/message-group.tsx` |
-
-模块总览见同目录上级 [`../README.md`](../README.md);本文只覆盖**文件预览沙箱**这一条链路。
+> 适用范围:DeerFlow 标准对话页、单智能体对话页、岗位多智能体会商,以及后续使用 Vue、Angular 或其他前端框架接入 DeerFlow LangGraph 接口的项目。
+>
+> 本文以当前仓库 2026-08-05 的实现为准。文档不仅描述界面效果,还描述事件来源、消息合并规则、渲染状态机、沙箱打开/关闭条件和排障方法。
---
-## 1. 验收标准(对接完成即通过)
+## 1. 先记住这六条结论
-重做页面后,下列行为必须全部成立(与普通对话页用户感知一致):
+1. **页面渲染的事实来源是流式消息和 `values` 状态,不是步骤条 DOM,也不是模型最终回答中的文字。**
+2. **同一条 AI 消息会以相同 `message.id` 分多次到达。**前端必须合并 chunk,不能把每个 chunk 当成新消息追加。
+3. **`...` 不直接交给 Markdown 渲染。**前端先把思考内容从正文中拆出来,再分别渲染“思考”和最终答案。
+4. **普通工具步骤由 AI 消息中的 `tool_calls` 触发。**工具调用刚开始、尚未返回 `ToolMessage` 时,步骤条就应该出现。
+5. **文件沙箱存在两条打开路径。**流式 `write_file` 可打开虚拟文件,`str_replace` 可基于真实文件展示内存替换预览;`values.artifacts` 增加后可打开后端真实文件。
+6. **不要把 `onToolStart` 当作核心协议。**当前前端 stream-mode 兼容层会过滤 `tools` 模式;标准页面主要通过 `messages-tuple` 中的 `tool_calls` 和 `ToolMessage` 工作。
-| # | 行为 |
-| --- | --- |
-| A | 步骤条刚出现「写入文件」时,右侧沙箱应已打开(不必等文件名出现) |
-| B | 沙箱内源码随模型输出边写边滚;消息列表文字也实时出现,不能整页卡死 |
-| C | 模型若先吐正文、后吐 `path`:先用临时名打开,path 到了再改名,不闪屏重挂 |
-| D | 同一次问答里第二次 `write_file` 写同一路径(「重新生成」):沙箱清空并重新流式 |
-| E | 整轮 `isLoading=false` 后,自动从源码切到 Markdown 预览 |
-| F | 用户手动关闭后,**同一次**写入不再自动弹;**下一轮**新写入仍可自动开 |
-| G | 切岗位 / 切节点 / 新建或切换会话时,沙箱关掉且不串内容 |
+如果第三方项目只实现“收到完整 AI 文本后展示”,会同时失去:实时正文、实时思考、实时步骤、工具执行状态和写文件时的实时沙箱。
---
-## 2. 职责划分(谁干什么)
+## 2. 当前实现的源码索引
-```text
-┌─────────────────────────────────────────────────────────────┐
-│ 页面壳(PositionRoundtablePage 或你的新页面) │
-│ · 唯一 sandboxArtifact 状态 │
-│ · handlePreviewArtifact / closeSandbox │
-│ · 流式线程外部 store(禁止整页 useState) │
-│ · 渲染唯一 ArtifactFileDetail │
-└──────────────────────────▲──────────────────────────────────┘
- │ onPreviewArtifact / onThreadSnapshot
-┌──────────────────────────┴──────────────────────────────────┐
-│ 对话子组件(PositionNodeChat 等) │
-│ · useThreadStream 拉消息 │
-│ · 渲染 MessageList / 步骤条(只展示,不直接开沙箱) │
-│ · 探测 write_file → 组装 PositionArtifactPreview 上报 │
-│ · 每次 thread 变化上报 onThreadSnapshot │
-└─────────────────────────────────────────────────────────────┘
+| 职责 | 当前源码 |
+| --- | --- |
+| 对话流创建、发送、停止、断线恢复 | `src/core/threads/hooks.ts` |
+| LangGraph API Client、鉴权 | `src/core/api/api-client.ts` 及同目录 API 配置 |
+| stream mode 兼容过滤 | `src/core/api/stream-mode.ts` |
+| 消息类型识别、思考拆分、消息分组 | `src/core/messages/utils.ts` |
+| 消息列表总入口 | `src/components/workspace/messages/message-list.tsx` |
+| 单条用户/助手消息 | `src/components/workspace/messages/message-list-item.tsx` |
+| 思考与工具步骤编排 | `src/components/workspace/messages/message-group.tsx` |
+| 步骤条基础 UI | `src/components/ai-elements/chain-of-thought.tsx` |
+| 最新步骤切换动画 | `src/components/workspace/flip-display.tsx` |
+| 无结构流式等待动画 | `src/components/workspace/streaming-indicator.tsx` |
+| 独立思考折叠组件 | `src/components/ai-elements/reasoning.tsx` |
+| Markdown 流式渲染 | `src/components/workspace/messages/markdown-content.tsx`、`src/components/ai-elements/message.tsx` |
+| 子智能体步骤卡片 | `src/components/workspace/messages/subtask-card.tsx` |
+| 标准文件状态与沙箱控制 | `src/components/workspace/artifacts/context.tsx`、`src/core/artifacts/hooks.ts` |
+| 虚拟文件内容读取 | `src/core/artifacts/loader.ts` |
+| 沙箱详情组件 | `src/components/workspace/artifacts/artifact-file-detail.tsx` |
+| 标准对话左右分栏 | `src/components/workspace/chats/chat-box.tsx` |
+| 岗位会商节点对话实时预览补强 | `src/position-roundtable/components/PositionNodeChat.tsx` |
+| 岗位会商页面级唯一沙箱 | `src/position-roundtable/pages/PositionRoundtablePage.tsx` |
+| OpenAI 兼容模型 reasoning 转换 | `offline-backend-20260512/backend/packages/harness/deerflow/models/patched_openai.py` |
+| vLLM reasoning/tool chunks 转换 | `offline-backend-20260512/backend/packages/harness/deerflow/models/vllm_provider.py` |
+| 子智能体 task 工具与 custom event | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/task_tool.py` |
+
+这几个文件共同组成完整生命周期。只阅读 `message-list.tsx` 或只阅读沙箱组件都会漏掉关键条件。
+
+当前前端关键依赖版本为 `@langchain/langgraph-sdk ^1.5.3`、`streamdown 1.4.0`。第三方若使用不同大版本,应先核对 SDK 的 stream chunk 类型和 `Client.runs.stream` 参数名称,本文描述的消息语义和状态机仍然适用。
+
+---
+
+## 3. 总体架构
+
+```mermaid
+flowchart LR
+ U["用户输入"] --> C["前端对话控制器"]
+ C -->|"POST run/stream"| G["LangGraph Gateway"]
+ G --> A["Lead Agent / 自定义 Agent"]
+ A --> M["模型流式输出"]
+ A --> T["工具与沙箱"]
+ M -->|"messages-tuple / values / custom / events"| S["SSE 流"]
+ T -->|"AI tool_calls + ToolMessage + artifacts"| S
+ S --> R["chunk 合并器"]
+ R --> V["消息分组与视图模型"]
+ V --> L["消息正文"]
+ V --> P["思考与步骤条"]
+ V --> F["文件卡片"]
+ R --> X["沙箱控制器"]
+ X --> D["文件详情/实时预览"]
```
-硬性规则:
+建议第三方实现也分成四层:
-1. **沙箱只有一个实例**,挂在页面壳上,不要在对话子组件里再挂 `ArtifactFileDetail`。
-2. 对话子组件**不要**直接 `artifacts.setOpen(true)` 当作岗位会商的主路径(页面壳统一开合,才能做关闭抑制、切节点清理)。
-3. 步骤条和沙箱是**两条线**:步骤条只反映 tool call 展示;开沙箱走 `onPreviewArtifact`。
+- **传输层**:鉴权、POST SSE、重连、取消。
+- **状态层**:按 message id 合并 chunk,接收 `values` 快照。
+- **语义层**:拆思考、匹配工具结果、生成消息组和步骤。
+- **视图层**:Vue、Angular、React 只消费稳定的视图模型。
+
+不要把 SSE 解析、tool call JSON 拼接和组件模板写在同一个文件里,否则第二轮写文件、历史切换和断线恢复非常难排查。
---
-## 3. 对接接口(重新接页面时照此接线)
+## 4. 从发送问题到页面完成渲染的完整时序
-### 3.1 对话子组件 → 页面壳
+```mermaid
+sequenceDiagram
+ actor User as 用户
+ participant UI as 输入框
+ participant Store as 消息状态层
+ participant API as LangGraph Stream API
+ participant Agent as Agent/LLM
+ participant Tool as 工具/沙箱
+ participant View as 消息列表与沙箱
-#### `onPreviewArtifact(artifact)`
+ User->>UI: Enter 发送
+ UI->>Store: 写入 optimistic HumanMessage
+ UI->>API: POST /threads/{id}/runs/stream
+ API->>Agent: input.messages + context
+ Agent-->>API: AIMessageChunk(reasoning 或 )
+ API-->>Store: event: messages
+ Store->>Store: 按 message.id 合并
+ Store-->>View: 显示“思考”或流式等待
+ Agent-->>API: AIMessageChunk(tool_calls: write_file)
+ API-->>Store: event: messages
+ Store->>Store: 合并 tool_call_chunks
+ Store-->>View: 出现“写入文件”步骤
+ Store-->>View: 满足条件时打开虚拟沙箱
+ Agent->>Tool: 执行 write_file
+ Tool-->>API: ToolMessage(tool_call_id, result)
+ API-->>Store: event: messages
+ Store->>Store: 将 ToolMessage 匹配到步骤
+ API-->>Store: event: values,artifacts 更新
+ Store-->>View: 虚拟文件切换/对齐为真实文件
+ Agent-->>API: AIMessageChunk(final content)
+ API-->>Store: event: messages
+ Store-->>View: Markdown 正文持续增长
+ API-->>Store: 流结束
+ Store-->>View: isLoading=false,完成动画/预览切换
+```
-请求打开或更新沙箱。载荷:
+按当前前端代码,一次发送的实际步骤为:
+
+1. 防止同一输入被本地重复提交。
+2. 如有附件,先上传附件。
+3. 立即创建一条临时 `HumanMessage`,让页面不用等待网络回包。
+4. 把隐藏上下文、可见用户消息和文件元数据组成 `input.messages`。
+5. 调用 LangGraph run stream。
+6. 流期间持续合并 AI chunk、工具调用 chunk、工具结果和状态快照。
+7. 每次状态变化后重新计算消息组、步骤和沙箱预览候选。
+8. 服务端真实消息数增长后移除 optimistic 消息,防止重复。
+9. 流结束后刷新会话列表,并把 `isLoading` 置为 false。
+
+---
+
+## 5. 接口、鉴权与发送参数
+
+### 5.1 API 基地址
+
+前端通过 `VITE_LANGGRAPH_BASE_URL` 获取 LangGraph API 地址;未配置时通常使用当前域名下的 `/api/langgraph`。第三方项目应从部署配置读取,不能硬编码开发机地址。
+
+标准运行接口的逻辑地址为:
+
+```text
+POST {LANGGRAPH_BASE_URL}/threads/{thread_id}/runs/stream
+```
+
+如果尚未创建 thread,先创建:
+
+```text
+POST {LANGGRAPH_BASE_URL}/threads
+```
+
+上传文件使用应用后端接口,而不是 LangGraph run 接口:
+
+```text
+POST {BACKEND_BASE_URL}/api/threads/{thread_id}/uploads
+Content-Type: multipart/form-data
+```
+
+读取真实产物:
+
+```text
+GET {BACKEND_BASE_URL}/api/threads/{thread_id}/artifacts/{path}
+```
+
+### 5.2 请求头
+
+当前应用 Client 会自动附加:
+
+```http
+Accept: text/event-stream
+Content-Type: application/json
+Authorization: Bearer
+X-CSRF-Token:
+```
+
+浏览器请求还会按当前部署配置携带 cookie。Vue/Angular 项目需要同步处理 CORS、`credentials`、Authorization 和 CSRF,不能只把 URL 配通。
+
+### 5.3 典型发送体
+
+下面是便于理解的协议示例;最终字段以部署的 LangGraph SDK 版本为准:
+
+```json
+{
+ "assistant_id": "lead_agent",
+ "input": {
+ "messages": [
+ {
+ "type": "human",
+ "id": "client-generated-id",
+ "content": "帮我分析这个任务",
+ "additional_kwargs": {
+ "client_timestamp": "2026-08-05T10:00:00.000Z",
+ "files": []
+ }
+ }
+ ]
+ },
+ "config": {
+ "recursion_limit": 1000
+ },
+ "context": {
+ "thread_id": "thread-id",
+ "thinking_enabled": true,
+ "is_plan_mode": false,
+ "subagent_enabled": false,
+ "reasoning_effort": "medium",
+ "memory_injection_enabled": true,
+ "rag_mode_enabled": false,
+ "summarization_enabled": true
+ },
+ "stream_mode": [
+ "messages-tuple",
+ "values",
+ "updates",
+ "custom",
+ "events"
+ ],
+ "stream_subgraphs": true,
+ "stream_resumable": true
+}
+```
+
+当前模式映射大致为:
+
+| 页面模式 | `thinking_enabled` | `is_plan_mode` | `subagent_enabled` |
+| --- | ---: | ---: | ---: |
+| flash | false | false | false |
+| 普通/其他 | true | false | false |
+| pro | true | true | false |
+| ultra | true | true | true |
+
+### 5.4 新 thread、隐藏上下文和元数据
+
+当前 hook 在发送前还会处理三类不应由组件层临时拼接的数据:
+
+- 隐藏上下文转为带 `additional_kwargs.hide_from_ui=true` 的 HumanMessage,供模型读取但不进入消息列表;
+- 上传结果写入可见 HumanMessage 的 `additional_kwargs.files`;
+- 新 thread 创建成功后取得服务端真实 thread id,并补充 `agent_id` 及页面传入的 thread metadata。
+
+第三方页面如果使用“临时会话 id”,必须在 `onCreated`/创建响应后统一替换为服务端 id。上传、运行、历史、artifact API 和取消请求都必须使用同一个真实 thread id。
+
+### 5.5 为什么不要直接使用浏览器 `EventSource`
+
+原生 `EventSource` 只适合 GET,并且不方便携带 POST body 和自定义 Authorization。run stream 是 POST SSE,建议:
+
+1. 优先使用 `@langchain/langgraph-sdk` 的框架无关 Client;或
+2. 使用 `fetch()` + `ReadableStream` 自己解析 SSE。
+
+不要使用“先 POST,随后另开 EventSource 猜测 URL”的方式,会破坏 run id、可恢复流和取消逻辑。
+
+---
+
+## 6. SSE 事件与各自职责
+
+### 6.0 当前 React 页面如何订阅这些事件
+
+当前 `useThreadStream` 封装 `useStream`,主要配置为:
```ts
-type PositionArtifactPreview = {
- threadId: string; // 必填:LangGraph thread id
- path: string; // 必填:真实路径,或 write-file: 虚拟 URL,或临时文件名
- name: string; // 展示名
- mimeType: string | null; // 如 text/markdown;帮助预览语言判断
- previewKey?: string; // 强烈建议:一次交付的身份,推荐 `${messageId}:${toolCallId}`
- auto?: boolean; // true = 流式自动打开;false/缺省 = 用户点击打开
-};
+useStream({
+ client: getAPIClient(isMock),
+ assistantId: assistantId ?? "lead_agent",
+ threadId,
+ reconnectOnMount: sessionStorageAdapter,
+ fetchStateHistory: { limit: 1 },
+ onLangChainEvent,
+ onUpdateEvent,
+ onCustomEvent,
+ onToolEvent,
+ onCreated,
+ onFinish,
+});
```
-页面壳收到后应:
+React 页面没有在每个组件里分别建立 SSE。`useStream` 根据页面实际访问的 `thread.messages`、`thread.values` 以及注册的 callbacks 组合所需 stream modes:
-1. `auto===true` 且 `closedSandboxKey === sandboxCloseKey(artifact)` → **直接 return**(用户刚关过这一次)。
-2. 更新页面级 `sandboxArtifact`(并同步 ref,见下)。
-3. 同步 `ArtifactsContext.select(path) + setOpen(true)`(与共享预览组件生命周期对齐)。
-4. 若已有该 thread 的最新快照,立刻推进沙箱 store。
-5. React `key` 用 `sandboxCloseKey(artifact)`(有 `previewKey` 时优先它),保证新交付会重挂。
+- 访问消息和 state,使 `messages-tuple`、`values` 成为核心模式;
+- update callback 需要 `updates`;
+- custom callback 需要 `custom`;
+- LangChain callback 需要 `events`;
+- tool callback 会尝试请求 `tools`,但当前兼容层会把它过滤。
-`sandboxCloseKey` 约定:
+因此第三方可以显式订阅本文列出的模式,也可以复用 SDK 的自动模式管理;无论哪一种,消息正文和步骤都必须以 `messages-tuple + values` 为主。
-```ts
-// 有 previewKey:threadId:write:${previewKey}
-// 无 previewKey:threadId:${normalizePath(path)}
-```
+### 6.1 页面真正关心的事件
-#### `onThreadSnapshot({ threadId, thread })`
+| stream mode | SSE 中常见事件名 | 数据 | 页面用途 |
+| --- | --- | --- | --- |
+| `messages-tuple` | `messages` | `[messageChunk, metadata]` | 实时正文、思考、tool calls、ToolMessage |
+| `values` | `values` | 完整 Agent state | 服务端权威 messages、artifacts、任务状态 |
+| `updates` | `updates` | 节点局部状态更新 | 调试、局部状态或标题等扩展能力 |
+| `custom` | `custom` | 自定义事件 | 子智能体进度、重试、上下文压缩提示 |
+| `events` | `events` | LangChain 事件 | 监控、日志和模型结束事件 |
+| 元数据 | `metadata` | run/thread 元信息 | 保存 run id、恢复流 |
+| 错误 | `error` | 错误对象 | 结束加载并展示错误 |
-把当前 `useThreadStream` 的 thread 对象持续交给页面壳,供沙箱读流式 `content`。
+启用 `stream_subgraphs` 后,事件名可能带命名空间后缀。接入层应先分离基本事件名和 namespace,再进入 reducer;不要因为事件名不是完全等于 `messages` 就丢弃子智能体消息。
-页面壳应:
+### 6.2 `tools` 事件的特别说明
-1. 始终写入 `latestNodeThreadSnapshotRef`(打开瞬间可能用到)。
-2. **仅当**当前沙箱 `threadId` 与 snapshot 一致时,才 `queue` → store `publish`。
-3. 节流:前沿 160ms(见第 5 节),不要纯尾随 `setTimeout`。
+LangGraph SDK 本身存在 tool event 能力,但当前项目 `src/core/api/stream-mode.ts` 支持集合不包含 `tools`,不支持的模式会被清理。因此:
-### 3.2 页面壳 → 预览组件
+- `onToolStart`/`onToolEnd` 只能作为辅助信号;
+- 步骤条必须由 `messages` 内 AI `tool_calls` 和后续 `ToolMessage` 推导;
+- 沙箱实时打开也不能只监听 `onToolStart`。
-```tsx
-{sandboxOpen && sandboxArtifact && (
-
-
-
-)}
-```
+这是跨框架对接中必须保持的兼容策略。
-流式正文依赖虚拟 URL:
+### 6.3 SSE 原始格式示意
```text
-write-file:${path}?message_id=...&tool_call_id=...&language=markdown
+id: 17
+event: messages
+data: [{"type":"AIMessageChunk","id":"ai-1","content":""},{"langgraph_node":"agent"}]
+
+id: 18
+event: values
+data: {"messages":[...],"artifacts":[]}
+
```
-`loadArtifactContentFromToolCall` **只认** URL 里的 `message_id` + `tool_call_id`,从 `thread.messages` 对应 tool call 的 `args.content` 取值。
-因此:**可以没有真实磁盘 path 就开沙箱**;path 主要用于文件名展示和语言猜测。
+解析器必须支持:
-### 3.3 对话子组件内部必做状态(可复用现有逻辑)
-
-重新实现探测时,至少保留这些语义(不必同名,语义要对):
-
-| 状态 | 作用 |
-| --- | --- |
-| `armed` | 仅本轮用户 `send` 成功武装后才允许自动 `onPreviewArtifact` |
-| `baselineCallKeys` | send 前已有的 `messageId:toolCallId` 集合;忽略历史 write,防止一提问就开旧文件 |
-| `activePreview` | 当前已打开的 previewKey / path / toolCallId,用于 path 升级与同 call 去重 |
-| 线程绑定保护 | `node.threadId` 从 null→本节点刚创建的 id 时,**不要** disarm |
-
-发送时:
-
-```text
-baseline = 当前历史 write keys
-armed = true
-activePreview = null
-→ sendMessage(...)
-```
-
-探测时(`isLoading`):
-
-```text
-找到最新 write_file / str_replace
-若不在 baseline:
- - 有完整扩展名 path → 用真实 path
- - write_file 且仅有 content → 临时名(未命名文件.md / .html)
- - str_replace 无 path → 先不报
- previewKey = 同 tool call 复用,否则新 key
- → onPreviewArtifact({ ..., auto: true })
-```
+- 一个事件多行 `data:`;
+- 空行结束一个事件;
+- `:` 开头的注释/心跳;
+- `Last-Event-ID`;
+- JSON 解析失败时保留原始文本用于诊断;
+- UTF-8 跨字节边界解码,使用流式 `TextDecoder`。
---
-## 4. 打开 / 关闭时序(对接时按此实现)
+## 7. 消息 chunk 必须如何合并
-### 4.1 打开(自动)
+### 7.1 三类核心消息
-```text
-用户发送
- → armed=true + 拍 baseline
- → 模型开始 write_file
- → 步骤条出现「写入文件」(可能还没有 path)
- → content 或完整 path 任一满足探测条件
- → onPreviewArtifact(auto)
- → 页面壳打开 ArtifactFileDetail + 推送 thread 快照
- → 源码视图滚动更新
- →(可选)真实 path 到达:同 previewKey 改名(path upgrade,不重挂)
- → isLoading=false:共享组件自动切预览
-```
-
-### 4.2 同轮第二次写入(重新生成)
-
-```text
-write_file #1 → previewKey = msg:tc1 → 打开
-write_file #2 同 path → previewKey = msg:tc2(必须变)
- → React key 变 → 组件重挂 → 视觉上清空再生成
-```
-
-**禁止**用「path 相同 ⇒ 复用 previewKey」。那会导致步骤条有「重新生成」、沙箱却不动。
-
-### 4.3 关闭
-
-| 触发 | 行为 |
-| --- | --- |
-| 用户点关闭 | `closedSandboxKeyRef = sandboxCloseKey(current)`;清 artifact / store / 定时器 |
-| 切岗位、切节点、新会话、切历史 | 先 `closeSandbox()`(可按产品决定是否写入抑制键;至少清面板) |
-| 自动打开被抑制 | 仅当 `auto && closedKey === 当前 key`;手动打开应清抑制 |
-
-抑制粒度必须是**单次交付(previewKey)**,不能是「整个 thread 永远不再自动开」。
-
----
-
-## 5. 重做页面时的必守规则(违反必出 bug)
-
-1. **流式 thread 快照禁止进页面级 `useState`**
- 大页面每秒重渲染数次会占满主线程:消息列表停更、沙箱冻住、定时器饿死。
- 正确:外部 store + `useSyncExternalStore`,只让沙箱子树订阅。
-
-2. **节流用前沿(leading-edge),不要纯尾随 `setTimeout`**
- 距上次 publish ≥ 160ms → 同步 publish;否则补尾定时器。
- 纯「有 timer 就 return」在忙主线程下会十几秒不 flush。
-
-3. **不要等完整 `path` 才开沙箱**
- 报告类 agent 常先流 `content`、最后才流 `path`,硬等会晚几十秒。
- `write_file` + 已有 content → 临时名打开;`str_replace` 仍等真实 path。
-
-4. **扩展名要完整再当「真实 path」**
- 流式中 `.m` → `.md`,过早打开会把语言锁成 TEXT。
-
-5. **previewKey 按 tool call,不按 path**
- 同 path 多次写入 = 多次交付。
-
-6. **打开瞬间同步推进 `sandboxArtifactRef`**
- 若 flush 快照时只看 ref、而 setState 尚未提交,首帧会被「threadId 不匹配」丢掉。
-
-7. **不要依赖 `onToolStart` 作为唯一打开手段**
- 当前后端对部分 stream mode 可能过滤;主路径必须是 `thread.messages` 里渐进出现的 `tool_calls`。
-
-8. **path upgrade 不要当第二次打开**
- 同 previewKey 改 path:更新 artifact,不要乱关抽屉 / 不要重挂。
-
-9. **「结束后才切预览」不是打开 bug**
- `ArtifactFileDetail` 对 markdown/html 在 `isLoading` 期间强制源码视图,与普通对话页相同。
-
----
-
-## 6. 步骤条怎么对接理解
-
-步骤条来自消息里的 tool call 渲染(`message-group`),标题优先 `args.description`,否则文案「写入文件」;子行显示 `args.path`。
-
-| 你看到的步骤条 | 含义 | 沙箱应处于 |
+| 类型 | 关键字段 | 作用 |
| --- | --- | --- |
-| 仅「写入文件」,无路径 | tool call 已开始,path 可能还没流到 | **应已打开**(临时名) |
-| 「写入文件」+ 路径 | path 已可用 | 已打开;若刚从临时名升级则只改名 |
-| 第二条「重新生成…」+ 同路径 | 新的 write_file(新 toolCallId) | **应重挂**并重新流式 |
-| 生成中顶部「正在生成,完成后自动预览」 | 仍在 code 视图 | 正常,等 isLoading 结束切预览 |
+| HumanMessage | `id`、`content`、`additional_kwargs.files` | 用户问题与上传附件 |
+| AIMessage/AIMessageChunk | `id`、`content`、`additional_kwargs.reasoning_content`、`tool_calls`、`tool_call_chunks` | 思考、答案和工具请求 |
+| ToolMessage | `id`、`tool_call_id`、`name`、`content` | 某个工具调用的执行结果 |
-对接时不要把「步骤条出现文件名」当成「开沙箱的唯一信号」——那会在 content-first 模型上系统性偏晚。
+### 7.2 合并不等于数组追加
----
+同一条 AI 输出可能如下到达:
-## 7. 排障手册(按症状查)
-
-### 7.1 沙箱开得很晚 / 要等文件名才开
-
-检查顺序:
-
-1. 探测是否仍要求「必须有完整 path」?→ 改为 content 可开临时名。
-2. `armed` 是否在 `threadId` 回写时被误清?→ 保护「本节点绑定自己的新 thread」。
-3. 是否被 `closedSandboxKey` 抑制?→ 看是否刚关过同 previewKey。
-4. 新 call 是否落在 baseline 里?→ baseline 只能在 send 时拍一次。
-
-### 7.2 沙箱打开了但内容空白 / 冻住,生成完才一口气出来
-
-1. 快照是否写进了页面 `useState`?→ 改 store。
-2. 节流是否纯尾随定时器?→ 改前沿。
-3. `SandboxThreadProvider` 是否把带 `messages` 的 thread 传给了 `ArtifactFileDetail`?
-4. 虚拟 URL 的 `tool_call_id` 是否与消息里一致?
-5. 打开时 `sandboxArtifactRef` 是否已同步(避免首帧 flush 被丢)?
-
-### 7.3 消息列表也不实时,整页很卡
-
-优先怀疑页面壳在流式频率下整页重渲染。
-确认没有把 `onThreadSnapshot` 落到大页面 state;临时 `console.debug` 每 chunk 打大对象也会加重(排障完删掉)。
-
-### 7.4 第二次「重新生成」步骤有了,沙箱不变
-
-previewKey 是否按 path 复用了?→ 必须按 `messageId:toolCallId`。
-`ArtifactFileDetail` 的 `key` 是否跟 previewKey 走?否则不会重挂。
-
-### 7.5 第一次实时开,关掉后再问就不实时 / 很晚才开
-
-抑制是否绑在整个 `threadId` 上?→ 改为单次 `sandboxCloseKey(previewKey)`。
-
-### 7.6 预览变成纯文本 / 不像 Markdown
-
-1. 是否在 `.m` 阶段就当完整 path 打开了?
-2. 临时名是否带 `.md` / URL 是否带 `language=markdown` / 是否传了 `mimeType`?
-3. path 是否有首尾空格?
-
-### 7.7 只靠 onToolStart,线上偶发永远不开
-
-后端可能不推 tools/events。主路径改为扫描 `thread.messages` 的 streaming `tool_calls`。
-
----
-
-## 8. 对接自测清单(重做页面后必跑)
-
-- [ ] 首轮:步骤条「写入文件」出现时,沙箱是否已开并开始出字?
-- [ ] content-first agent:无文件名阶段是否也能开?
-- [ ] path 到达后:是否只改名、不整页闪?
-- [ ] 同轮第二次同路径 write:是否清空并重新流式?
-- [ ] 流式中消息列表是否仍实时?页面是否明显卡顿?
-- [ ] 手动关闭后本次是否不再弹?下一轮是否仍可自动开?
-- [ ] 结束后是否自动切 Markdown 预览?
-- [ ] 切节点 / 切岗位 / 新会话:沙箱是否关闭且内容不串?
-
----
-
-## 9. 最小接线伪代码(新页面可抄)
-
-```tsx
-// 页面壳
-const [sandboxArtifact, setSandboxArtifact] = useState(null);
-const sandboxArtifactRef = useRef(sandboxArtifact);
-const closedSandboxKeyRef = useRef(null);
-const store = useMemo(createSandboxThreadStore, []);
-
-function handlePreviewArtifact(artifact: PositionArtifactPreview) {
- const key = sandboxCloseKey(artifact);
- if (artifact.auto && closedSandboxKeyRef.current === key) return;
-
- const prev = sandboxArtifactRef.current;
- const sameKey = prev?.previewKey && prev.previewKey === artifact.previewKey
- && prev.threadId === artifact.threadId;
- const pathOnlyUpgrade = sameKey && prev.path !== artifact.path;
-
- sandboxArtifactRef.current = artifact;
- setSandboxArtifact(artifact);
- // publish latest thread snapshot into store (leading-edge throttle)
- // ArtifactsContext.select + setOpen(true)
- if (!pathOnlyUpgrade) closeRightDrawer();
-}
-
-function closeSandbox() {
- if (sandboxArtifactRef.current) {
- closedSandboxKeyRef.current = sandboxCloseKey(sandboxArtifactRef.current);
- }
- sandboxArtifactRef.current = null;
- setSandboxArtifact(null);
- store.publish(null);
-}
-
-// 对话子组件
-
+```text
+ai-1 chunk 1: reasoning_content = "我先分析"
+ai-1 chunk 2: reasoning_content = "任务约束"
+ai-1 chunk 3: tool_call_chunks = [{id:"call-1", name:"write_file", args:"{\"pa"}]
+ai-1 chunk 4: tool_call_chunks = [{id:"call-1", args:"th\":\"/mnt/..."}]
+ai-1 chunk 5: tool_call_chunks = [{id:"call-1", args:"\",\"content\":\"# 报告"}]
```
-对话侧探测逻辑优先**复用** `PositionNodeChat` 内的 `latestStreamingWriteFile` / armed / baseline,而不是从零再写一套——这是最容易写错的部分。
+正确状态只能有一条 `ai-1`。它的正文、reasoning 和 tool arguments 随 chunk 增长。
+
+LangGraph SDK 当前内部的 MessageTupleManager 采用以下原则:
+
+1. message 没有 id 时不能可靠归并,通常忽略或等待权威 `values`。
+2. 第一次看到某个 id 时记录它在消息数组中的位置。
+3. 后续相同 id 使用 MessageChunk 的 concat 语义合并。
+4. 合并后替换原位置,而不是追加新位置。
+5. tool call chunk 逐段拼接,并规范化为可供 UI 使用的 `tool_calls`。
+6. `values` 到来后以服务端完整状态校正本地结果。
+
+第三方实现至少需要下列状态:
+
+```ts
+interface StreamMessageStore {
+ order: string[];
+ byId: Map;
+ canonicalValues?: AgentThreadState;
+}
+```
+
+伪代码:
+
+```ts
+function acceptMessageChunk(chunk: MessageChunk) {
+ if (!chunk.id) return;
+
+ const previous = store.byId.get(chunk.id);
+ const merged = previous ? concatMessageChunk(previous, chunk) : normalize(chunk);
+
+ if (!previous) store.order.push(chunk.id);
+ store.byId.set(chunk.id, merged);
+ recomputeViewModel();
+}
+
+function acceptValues(values: AgentThreadState) {
+ store.canonicalValues = values;
+ reconcileMessagesById(values.messages);
+ syncArtifacts(values.artifacts);
+ recomputeViewModel();
+}
+```
+
+### 7.3 ToolMessage 如何匹配步骤
+
+首选条件:
+
+```text
+ToolMessage.tool_call_id === AIMessage.tool_calls[i].id
+```
+
+搜索结果等少数历史数据没有 call id 时,当前实现允许按同名工具进行有限兜底,但不能把“按 name 匹配”作为通用规则。一次回答可以连续调用两个同名 `write_file`,只按 name 会把第二个结果配给第一个步骤。
+
+### 7.4 content 不一定是字符串
+
+消息 content 可能是:
+
+- 普通 string;
+- content block 数组中的 `text`;
+- `thinking` block;
+- 图片/多模态 block;
+- 某些 provider 放在数组中的 `tool_call`/`tool_use` block。
+
+SDK 在必要时会从数组块中提取 tool calls。自己实现时不要直接执行 `String(message.content)`,否则会得到 `[object Object]`,也会丢失 thinking/tool_use。建议先规范化为:
+
+```ts
+interface NormalizedMessage {
+ id: string;
+ type: "human" | "ai" | "tool";
+ text: string;
+ reasoning: string;
+ toolCalls: NormalizedToolCall[];
+ raw: unknown;
+}
+```
+
+多模态 block 应保留在 raw/attachments 中供专门组件使用,不能混入 Markdown 字符串。
+
+### 7.5 ToolMessage content 也需要规范化
+
+工具结果可能是纯字符串、JSON 字符串、对象,或由 `text`、`json`、搜索来源等组成的内容块数组。当前 `extractToolResultPayload` 会按形态提取,并对可解析的 JSON 字符串做解析。
+
+建议保留两份值:
+
+```ts
+interface NormalizedToolResult {
+ raw: unknown; // 调试、下载或自定义技能展示使用
+ payload: unknown; // 解析后的对象/文本,步骤 renderer 使用
+}
+```
+
+不要把所有工具结果都强制 JSON.parse;普通成功提示不是 JSON。也不要只保留格式化后的展示字符串,否则搜索来源、文件信息和技能 display config 会丢失。
---
-## 10. 术语
+## 8. 消息列表不是简单 map:它先执行语义分组
-| 词 | 含义 |
+当前入口 `groupMessages(messages, mapper)` 把原始消息转换为以下视图组:
+
+```ts
+type MessageGroup =
+ | { type: "human"; message: HumanMessage }
+ | { type: "assistant:processing"; messages: BaseMessage[] }
+ | { type: "assistant"; message: AIMessage }
+ | { type: "assistant:present-files"; message: AIMessage }
+ | { type: "assistant:clarification"; call: ToolCall; result?: ToolMessage }
+ | { type: "assistant:setup-agent-approval"; call: ToolCall; result?: ToolMessage }
+ | { type: "assistant:setup-writing-approval"; call: ToolCall; result?: ToolMessage }
+ | { type: "assistant:subagent"; messages: BaseMessage[] };
+```
+
+### 8.1 分组状态机
+
+| 原始消息 | 分组行为 |
| --- | --- |
-| armed | 本轮用户发送后,允许自动打开沙箱 |
-| baseline | send 前已有 write tool call 集合,用于忽略历史产物 |
-| previewKey | 一次交付身份,推荐 `messageId:toolCallId` |
-| sandboxCloseKey | 关闭抑制键 + React key |
-| provisional path | 无真实 path 时的临时文件名 |
-| path upgrade | 同 previewKey 下临时名 → 真实 path |
-| sandboxThreadStore | 流式线程外部 store,避免整页重渲染 |
-| write-file: URL | 用 message/tool call id 定位流式 content 的虚拟路径 |
+| HumanMessage | 总是结束前一个助手阶段,成为独立 `human` 组 |
+| 有 reasoning 的 AI | 进入或延续 `assistant:processing` |
+| 有普通 tool calls 的 AI | 进入或延续 `assistant:processing` |
+| 有最终正文且没有 tool calls 的 AI | 创建 `assistant` 正文组 |
+| AI 调用 `present_files` | 创建 `assistant:present-files` 文件组 |
+| AI 调用 `task` | 创建 `assistant:subagent` 子智能体组 |
+| clarification 工具调用/结果 | 既关联处理组,又创建结构化 clarification 组 |
+| setup approval 工具调用/结果 | 创建相应审批卡片组 |
+| 普通 ToolMessage | 追加到前一个仍可接收结果的处理组 |
+
+注意 AI 判断不是简单的 `else if`:一条 AI 消息可以先贡献 reasoning/tool step,又在没有工具调用时贡献最终正文。接入方如果只取一个分支,会出现“有思考没答案”或“有答案没步骤”。
+
+### 8.2 被隐藏的消息
+
+下列消息不会进入普通 UI:
+
+- `additional_kwargs.hide_from_ui === true`;
+- name 为 `summary` 的内部摘要;
+- name 为 `todo_reminder` 的内部提醒。
+
+隐藏只是展示规则,不等于从 Agent 上下文中删除。
+
+### 8.3 工具结果成为“孤儿”的原因
+
+普通 `ToolMessage` 必须能接到前一个开放的 processing/subagent 组。如果第三方先按类型排序、把 ToolMessage 移到列表末尾,或者丢失 AI tool call,结果就无法匹配。消息必须保留服务端顺序。
+
+### 8.4 每一种组具体由哪个组件渲染
+
+| 组类型 | React 当前组件 | 页面表现 |
+| --- | --- | --- |
+| `human` | `MessageListItem` | 用户气泡、附件、时间 |
+| `assistant` | `MessageListItem` | 助手 Markdown 正文、引用、时间、token usage |
+| `assistant:processing` | `MessageGroup` | 思考、工具步骤、历史步骤折叠 |
+| `assistant:present-files` | 正文 + `ArtifactFileList` | 助手说明和正式交付文件卡 |
+| `assistant:clarification` | `ClarificationCard` | 单问题/多问题交互表单 |
+| setup approval | 对应 Approval Card | Agent/写作设置确认 |
+| `assistant:subagent` | `MessageGroup` + `SubtaskCard` | 子智能体思考、当前工具、结果和状态 |
+
+### 8.5 消息列表外围行为
+
+`MessageList` 在分组前还会做以下工作:
+
+- 过滤后端隐藏消息、本地删除 id 和外部指定删除 id;
+- thread 首次加载且一条消息都没有时展示 skeleton;
+- 用户消息数量增加时滚动到底部;
+- 渲染每组的时间、引用批次和 token usage;
+- 当前流仍在运行且暂无结构化内容时,在列表末尾展示流式圆点。
+
+第三方的虚拟滚动或 `trackBy` 不能使用数组下标,应优先使用 message id、tool call id、group 起始 message id,避免 chunk 更新时整列重建。
---
-## 11. 相关提交(背景)
+## 9. `` 和原生 reasoning 如何渲染
-若需对照修复动机,可查:
+### 9.1 DeerFlow 支持的三种思考来源
-- `fix(frontend): 岗位会商沙箱在 write_file 流式阶段实时打开并刷新` — 临时名打开、store、前沿节流
-- `fix(frontend): 同路径二次 write_file 时重挂岗位会商沙箱` — previewKey 按 tool call
+当前 `extractReasoningContentFromMessage` 的优先级为:
-排障时优先对照本文第 5、7 节,再对照上述提交的 diff。
+1. `AIMessage.additional_kwargs.reasoning_content`;
+2. 数组型 content 中第一个 `thinking` 内容块;
+3. 字符串 content 中的 `...`。
+
+后端不同模型适配器会把供应商原生 reasoning 写入 `reasoning_content`。例如 OpenAI 兼容 provider 的 delta reasoning、vLLM provider 的 reasoning 都会转成该字段。只有模型直接把标签吐进正文时,才走第三种前端解析。
+
+### 9.2 标签解析的精确规则
+
+当前闭合标签规则等价于:
+
+```ts
+const THINK_TAG_RE = /\s*([\s\S]*?)\s*<\/think>/g;
+```
+
+处理结果:
+
+- 所有已闭合 `...` 的内部文字拼入 reasoning;
+- 对应标签和内容从最终正文中移除;
+- 同一正文可有多个 think 段;
+- 保留标签外的正常答案。
+
+### 9.3 未闭合标签为什么也不能显示在正文
+
+流式阶段经常先收到:
+
+```text
+我需要先读取任务,再决定调用哪个工具
+```
+
+此时还没有 ``。当前实现把最后一个未闭合 `` 后面的内容视为“正在增长的思考”,立即从正文中拿走。等闭合标签到达后再正常收口。
+
+如果只用闭合正则,会在流式阶段把 `` 和内部推理短暂显示在答案里,闭合后又突然消失,造成严重闪烁。
+
+### 9.4 正文提取和思考提取必须共享同一解析结果
+
+推荐接口:
+
+```ts
+interface SplitReasoningResult {
+ reasoning: string;
+ content: string;
+ hasOpenThink: boolean;
+}
+
+function splitInlineReasoning(raw: string): SplitReasoningResult;
+```
+
+正文 renderer 只接收 `content`,思考 renderer 只接收 `reasoning`。不要让两个组件各自用不同正则,否则未闭合标签、多个 think 段和空白处理会不一致。
+
+### 9.5 当前页面上“思考”的两种显示路径
+
+1. **标准消息分组路径**:reasoning 进入 `assistant:processing`,由 `MessageGroup` 与工具步骤一起编排。最后一段思考显示为可折叠“思考”,普通页面默认收起。
+2. **单条 reasoning-only 兜底路径**:`MessageListItem` 使用 `Reasoning` 组件;流式时显示 `Thinking...`,结束后显示耗时,并在结束约 1 秒后自动收起。
+
+标准 DeerFlow 对话通常走第一条。第三方可以统一采用第一条,关键是不能把思考并入最终答案。
+
+---
+
+## 10. 工具步骤条如何触发、更新和完成
+
+### 10.1 步骤数据从哪里来
+
+`MessageGroup` 对一个 `assistant:processing` 消息组执行 `convertToSteps`:
+
+1. 遍历组内消息,保持原顺序。
+2. 每遇到 AI reasoning,生成一个 reasoning step。
+3. 每遇到 AI `tool_calls`,为除 `task` 之外的每个调用生成 toolCall step。
+4. 用 `tool_call_id` 查找组内对应 ToolMessage。
+5. 找到结果后把 result 附到原步骤,而不是生成另一条“工具完成”步骤。
+
+因此步骤的生命周期是:
+
+```text
+AI tool_call 首个可识别 chunk
+ → 步骤出现,参数持续增长
+ → ToolMessage 到达,步骤拿到 result
+ → 后续 AI reasoning / tool_call 成为新的最新步骤
+```
+
+### 10.2 步骤何时“刚出现”
+
+只要合并后的 AI 消息已经能解析出:
+
+```json
+{
+ "id": "call-1",
+ "name": "write_file",
+ "args": {}
+}
+```
+
+步骤即可出现。它不需要等待完整 `path`,更不需要等待工具执行结束。
+
+这解释了为什么页面可能先显示“写入文件”,稍后才出现路径。实时沙箱若要求 path 完整后才打开,就一定比步骤条晚。
+
+### 10.3 哪些步骤默认可见
+
+当前 UI 的编排规则:
+
+- 最新一个 tool call 始终可见;
+- 最新 tool call 之前的步骤默认折叠为“查看其他 N 个步骤”;
+- 最新工具之后的最后一段 reasoning 单独显示为“思考”折叠区;
+- 静态网站模式可以默认展开,正常工作台默认收起;
+- 最新步骤切换时通过 `FlipDisplay` 做约 250ms 的纵向位移和透明度过渡;
+- 底层 `ChainOfThoughtStep` 有淡入和左侧连接线。
+
+当最新步骤从“读取文件”变为“写入文件”时,旧步骤不是被删除,而是进入“其他步骤”。
+
+### 10.4 状态图标的限制
+
+`ChainOfThoughtStep` 支持 active、pending、complete 等样式,但当前普通 `MessageGroup` 没有给通用工具步骤显式传状态,因此默认使用 complete 样式。真正“是否已有工具结果”应通过 `result` 字段判断,不能仅看步骤图标。
+
+子智能体卡片另有明确状态:
+
+- `in_progress`:旋转 loading 和发光动画;
+- `completed`:成功图标;
+- `failed`:红色错误图标。
+
+### 10.5 当前所有工具的步骤渲染规则
+
+| 工具/场景 | 标题/图标 | 内容与交互 |
+| --- | --- | --- |
+| 有技能自定义 display config | 技能配置决定 | 规范化结果后展示搜索来源/引用 |
+| RAG 默认引用 | 搜索类步骤 | 展示来源和引用 |
+| `web_search` 或通用搜索结果 | 搜索图标 | 可展开搜索结果卡片 |
+| `image_search` | 图片搜索 | 展示缩略图和结果链接 |
+| `web_fetch` | 网页 | “查看网页”,展示 URL/标题 |
+| `ls` | 文件夹 | 描述或“列出文件夹”,展示路径 badge |
+| `read_file` | 书本 | 描述或“读取文件”,展示路径 badge |
+| `write_file` | 编辑笔 | 描述或“写入文件”,展示路径;可点击打开沙箱 |
+| `str_replace` | 编辑笔 | 描述或“写入文件”,展示路径;可点击打开沙箱 |
+| `bash` | 终端 | 描述或“执行命令”,可展示命令代码块 |
+| `ask_clarification` | 协助提示 | 步骤中显示“需要你的协助”,另有结构化问题卡 |
+| `write_todos` | To-do | “更新 To-do 列表” |
+| `update_artifact` | 文档/代码 | “生成代码”或“生成文档” |
+| 未识别工具 | 通用工具 | 使用 description,缺失时显示 `使用“工具名”工具` |
+
+标题尽量读取工具参数中的 `description`,所以工具 schema 应要求模型给短、可展示的描述。不要用模型最终回答文本反向猜工具步骤。
+
+### 10.6 `MessageGroup` 的实际渲染结构
+
+下面的伪 JSX 对应当前页面结构:
+
+```tsx
+
+ {previousSteps.length > 0 && (
+
+
+ 查看其他 {previousSteps.length} 个步骤
+
+
+ {previousSteps.map(renderReasoningOrToolStep)}
+
+
+ )}
+
+
+ {latestToolCall && renderToolStep(latestToolCall)}
+
+
+ {lastReasoning && (
+
+ 思考
+
+
+ {lastReasoning}
+
+
+
+ )}
+
+```
+
+消息列表只会把当前最后一个 processing 组与全局 `thread.isLoading` 组合成真正的流式组。历史 processing 组即使没有 ToolMessage,也不应继续显示流式动画或触发自动沙箱。
+
+`ChainOfThoughtStep` 的组成包括:左侧状态节点、纵向连接线、图标、label 和可折叠 children。工具的 path 通常放入 `ChainOfThoughtSearchResult` 风格的 badge;点击 write/replace 步骤会选择对应 artifact 并打开右侧面板。
+
+---
+
+## 11. 特殊消息不是普通工具步骤
+
+### 11.1 `present_files`
+
+`present_files` 用于向用户交付最终文件。它不进入通用步骤条,而是生成 `assistant:present-files`:
+
+- 先显示可选 AI 说明文字;
+- 再渲染文件卡片列表;
+- 文件路径由 `present_files` 参数解析;
+- 可显示 token usage。
+
+“写文件步骤”和“文件卡片”是两个不同阶段:前者说明正在/已经执行工具,后者是面向用户的正式交付。新文件没有卡片时,应查 `present_files`,不只查 `write_file`。
+
+### 11.2 `task` 子智能体
+
+`task` 被通用步骤转换显式跳过,进入 `assistant:subagent`。状态由对应 ToolMessage 文本前缀判断:
+
+| ToolMessage 内容 | 状态 |
+| --- | --- |
+| `Task Succeeded. Result:` | completed |
+| `Task failed.` | failed |
+| `Task timed out` | failed |
+| 尚无终态结果 | in_progress |
+
+后端还可能发 `task_running` custom event,用来更新子智能体最新消息。它是增强信号,最终状态仍应能从消息恢复。
+
+### 11.3 澄清和审批
+
+`ask_clarification`、setup agent approval、setup writing approval 会生成专用交互卡片。澄清卡是否仍待回答,会参考它是否出现在最近一条 HumanMessage 之后。多问题澄清支持一次提交批量答案。
+
+### 11.4 流式等待点
+
+当 `thread.isLoading=true` 且暂时没有可渲染的新 reasoning/tool/content 时,消息列表尾部显示三个依次跳动的圆点。该动画只说明流仍在运行,不代表模型处于哪一个具体步骤。
+
+---
+
+## 12. 最终消息正文如何渲染
+
+### 12.1 HumanMessage
+
+用户消息渲染前会:
+
+- 解析并移除内部 `` 展示标签;
+- 解析附件元数据并展示文件卡;
+- 移除不应直接显示的 artifact 包装;
+- 保留客户端时间戳。
+
+### 12.2 AI 最终正文
+
+标准链路为:
+
+```text
+extractContentFromMessage
+ → 移除 reasoning /
+ → 处理不完整引用标记
+ → 插入/解析 citation
+ → MarkdownContent
+ → MessageResponse
+ → Streamdown
+```
+
+渲染支持 GFM、数学公式、链接、代码块和项目内引用。流式阶段使用 split-word rehype 插件给非 CJK 单词加淡入效果;中文不会被逐字拆成大量 span。`MessageResponse` 只在 children 改变时重渲染,减少长回答卡顿。
+
+### 12.3 正文、思考、步骤为什么必须分开
+
+一个 AI 回合在 UI 中可能同时有:
+
+```text
+[思考折叠区]
+[读取任务步骤]
+[写入文件步骤]
+[最终 Markdown 回答]
+[交付文件卡片]
+```
+
+它们可能来自同一条 AI message,也可能跨 AIMessage + ToolMessage + 下一条 AIMessage。不要用“一条后端消息对应一个气泡”的固定假设。
+
+---
+
+## 13. 标准 DeerFlow 沙箱的打开、更新和关闭
+
+### 13.1 ArtifactsProvider 的核心状态
+
+```ts
+interface ArtifactsUIState {
+ artifacts: string[];
+ selectedArtifact: string | null;
+ open: boolean;
+ autoOpen: boolean;
+ autoSelect: boolean;
+}
+```
+
+语义:
+
+- `selectedArtifact`:当前预览目标;
+- `open`:右侧沙箱是否显示;
+- `autoOpen`:用户是否仍允许系统自动打开;
+- `autoSelect`:系统是否可以自动替换当前选择。
+
+手动选择文件会关闭 autoSelect;用户关闭沙箱会把 autoOpen 和 autoSelect 关闭,避免同一内容立刻弹回。
+
+### 13.2 路径 A:流式写文件时打开虚拟文件
+
+`message-group.tsx` 对最后一个 `write_file` 步骤使用以下核心条件:
+
+```ts
+artifacts &&
+isLoading &&
+isLast &&
+artifacts.autoOpen &&
+artifacts.autoSelect &&
+path &&
+!result
+```
+
+满足后稍作延迟,选择虚拟 artifact 并打开:
+
+```text
+write-file:{path}?message_id={messageId}&tool_call_id={toolCallId}
+```
+
+虚拟 URL 不是后端文件地址。`core/artifacts/loader.ts` 使用两个 id 回到消息列表,找到对应 tool call,再直接读取正在增长的 `args.content`。因此工具尚未真正落盘时也能显示源码。
+
+这条路径有三个重要前提:
+
+1. AI tool call 中已经有完整 `path`;
+2. 当前步骤必须是最新步骤;
+3. 工具结果尚未返回。
+
+### 13.3 路径 B:`values.artifacts` 增加后打开真实文件
+
+对话工作区还会监听权威状态中的 artifacts:
+
+1. `values.artifacts` 数量增加;
+2. 当前没有手动选中的文件;
+3. 选择最后一个新 artifact;
+4. 打开右侧沙箱;
+5. 内容通过 artifacts API 读取。
+
+这条路径稳定但通常更晚,因为它依赖后端状态更新。只实现这条路径时,用户会感觉“写完后才打开”。
+
+### 13.4 虚拟内容和真实内容的读取
+
+| 文件类型 | 内容来源 |
+| --- | --- |
+| `write-file:` 虚拟路径 | tool call 的实时 `args.content` |
+| `str_replace` 进行中 | 真实内容 + 前端临时应用替换参数 |
+| 普通真实路径 | `/api/threads/{threadId}/artifacts/{path}` |
+
+真实文件查询在流式期间可按间隔轮询刷新;虚拟文件则随消息 chunk 重算,不必轮询后端文件系统。
+
+### 13.5 文件类型识别顺序
+
+推荐严格按以下优先级:
+
+1. 后端/调用方明确提供的 mime type;
+2. 虚拟 URL 中明确的 language;
+3. **完整路径**的扩展名;
+4. 最后才回退为 text。
+
+不能在流式参数还只有 `.m`、`.md` 未完整时就永久缓存为 TEXT。路径改变后必须重新识别语言。
+
+### 13.6 Markdown/HTML 的流式显示
+
+当前详情组件在 Markdown/HTML 流式期间偏向源码视图,结束后自动切到预览并加完成效果。这样可以避免未闭合 Markdown/HTML 结构导致预览大幅跳动。
+
+---
+
+## 14. 岗位会商为什么需要额外的实时沙箱补强
+
+标准路径 A 必须等完整 path,而部分模型会按以下顺序输出工具参数:
+
+```text
+name → description → content(已经大量增长)→ path(最后才完整)
+```
+
+这时步骤条已经显示“写入文件”,但标准虚拟预览仍没有 path,导致沙箱很晚打开。岗位会商的 `PositionNodeChat.tsx` 增加了 message-driven 检测,使 `write_file` 在正文开始增长时可以先用临时名打开,path 完整后再无闪烁改名。
+
+### 14.1 页面壳和节点对话的职责边界
+
+| 层 | 责任 |
+| --- | --- |
+| `PositionNodeChat` | 观察当前节点消息、识别新写入、生成 preview payload、上报 thread snapshot |
+| `PositionRoundtablePage` | 持有整个页面唯一沙箱、决定开关、保存关闭抑制、协调左右布局 |
+| `ArtifactFileDetail` | 展示内容、识别文件类型、源码/预览切换、下载与关闭 UI |
+
+节点组件不能各自挂一个沙箱,否则切岗位、切历史或多个节点流式时会产生多个宿主争夺状态。
+
+### 14.2 回调建议
+
+```ts
+interface PreviewArtifactPayload {
+ previewKey: string;
+ threadId: string;
+ messageId: string;
+ toolCallId: string;
+ toolName: "write_file" | "str_replace";
+ path?: string;
+ provisionalName?: string;
+ content?: string;
+ args: Record;
+ isLoading: boolean;
+}
+
+interface NodeChatBridgeProps {
+ onPreviewArtifact?: (payload: PreviewArtifactPayload) => void;
+ onThreadSnapshot?: (snapshot: ThreadSnapshot) => void;
+}
+```
+
+### 14.3 必须有 armed 和 baseline
+
+加载历史会话时,历史 AI 消息里也有 write_file。若组件一挂载就扫描所有消息并自动打开,会把旧文件当成新文件。
+
+正确流程:
+
+1. 首次加载记录当前已有 tool call key,形成 baseline;
+2. 用户本轮点击发送后设置 `armed=true`;
+3. 只处理 baseline 之后的新 call;
+4. 本轮结束后解除 armed。
+
+唯一键不能只用 path,应使用:
+
+```text
+previewKey = messageId + ":" + toolCallId
+```
+
+同一路径第二次重写必须是新预览。用 path 去重会让第二轮无法重新打开。
+
+### 14.4 `write_file` 与 `str_replace` 的打开条件不同
+
+- `write_file`:content 已开始增长但 path 未到时,可用 provisionalName 打开;path 到达后更新名称和语言。
+- `str_replace`:通常需要原文件和明确 path 才能计算替换结果,不宜只凭 description 提前打开空白沙箱。
+
+### 14.5 用户关闭后的抑制范围
+
+关闭状态应记录到当前 `previewKey`:
+
+```ts
+closedPreviewKeys.add(previewKey);
+```
+
+行为应为:
+
+- 同一个 tool call 后续 chunk 不再强制重开;
+- 下一次新 tool call 即使写同一路径,仍可以自动打开;
+- 切换会话后清理不相关的关闭键。
+
+如果只保存 `userClosedSandbox=true`,用户关闭一次后整个会话后续所有文件都不会再自动打开。
+
+### 14.6 不要让每个 token 重渲染整个会商页面
+
+文件正文可能每几十毫秒增长。页面级状态若每个 chunk 都 `setState`,会让流程图、左侧卡片和消息列表一起重渲染。
+
+当前会商设计应保持:
+
+- 外部轻量 store;
+- `useSyncExternalStore` 或其他框架等价订阅;
+- leading-edge 更新保证首次写入立即打开;
+- 后续内容约 100–200ms 合并刷新;
+- previewKey、path、language 改变立即提交,不等待尾节流。
+
+Vue 可使用 shallowRef + 自己的节流提交;Angular 可用 signal/RxJS store 并在必要时 `auditTime`,但首次事件不能被 trailing debounce 吞掉。
+
+---
+
+## 15. 一轮真实问答的逐事件渲染示例
+
+假设用户说“生成一份 Markdown 报告”。
+
+### 阶段 1:用户消息
+
+```json
+{
+ "type": "human",
+ "id": "h-1",
+ "content": "生成一份 Markdown 报告"
+}
+```
+
+页面立即产生 human 组并显示,发送按钮进入运行态。
+
+### 阶段 2:思考开始
+
+```json
+{
+ "type": "AIMessageChunk",
+ "id": "ai-1",
+ "content": "",
+ "additional_kwargs": {
+ "reasoning_content": "我先整理任务信息"
+ }
+}
+```
+
+页面产生 `assistant:processing`,显示“思考”或流式状态。
+
+如果模型输出的是:
+
+```json
+{
+ "id": "ai-1",
+ "content": "我先整理任务信息"
+}
+```
+
+展示结果相同,正文区仍为空。
+
+### 阶段 3:工具调用出现
+
+```json
+{
+ "type": "AIMessageChunk",
+ "id": "ai-1",
+ "tool_calls": [
+ {
+ "id": "call-1",
+ "name": "write_file",
+ "args": {
+ "description": "撰写任务分析报告",
+ "content": "# 任务分析报告\n"
+ }
+ }
+ ]
+}
+```
+
+页面立即:
+
+1. 在 processing 组生成 write_file 步骤;
+2. 标题显示“撰写任务分析报告”或“写入文件”;
+3. 会商增强模式可用临时文件名打开沙箱;
+4. 后续相同 ai-1 chunk 持续增加内容。
+
+### 阶段 4:path 到达
+
+```json
+{
+ "type": "AIMessageChunk",
+ "id": "ai-1",
+ "tool_calls": [
+ {
+ "id": "call-1",
+ "name": "write_file",
+ "args": {
+ "path": "/mnt/user-data/outputs/任务分析报告.md"
+ }
+ }
+ ]
+}
+```
+
+合并器不能用这个 args 覆盖之前的 content;应按 tool call chunk 语义得到完整 args。文件名和 language 此时校正为 Markdown。
+
+### 阶段 5:工具结果
+
+```json
+{
+ "type": "tool",
+ "id": "tool-1",
+ "name": "write_file",
+ "tool_call_id": "call-1",
+ "content": "File written successfully"
+}
+```
+
+步骤 `call-1` 得到 result。若 `values.artifacts` 同时增加,标准沙箱会获得真实 artifact。
+
+### 阶段 6:最终回答
+
+```json
+{
+ "type": "AIMessageChunk",
+ "id": "ai-2",
+ "content": "报告已经生成,重点结论如下……"
+}
+```
+
+页面新增 `assistant` 正文组,Markdown 随 chunk 增长。
+
+### 阶段 7:正式交付
+
+```json
+{
+ "type": "AIMessage",
+ "id": "ai-3",
+ "tool_calls": [
+ {
+ "id": "call-2",
+ "name": "present_files",
+ "args": {
+ "files": ["/mnt/user-data/outputs/任务分析报告.md"]
+ }
+ }
+ ]
+}
+```
+
+页面生成文件卡片。没有 `present_files` 时,即使文件已真实写入,标准消息列表也不一定出现正式交付卡片。
+
+---
+
+## 16. Vue / Angular 推荐接入结构
+
+### 16.1 首选:使用框架无关 SDK Client
+
+`@langchain/langgraph-sdk` 的 React `useStream` 不能直接用于 Vue/Angular,但底层 `Client` 是框架无关的。推荐封装成业务 service:
+
+```ts
+import { Client } from "@langchain/langgraph-sdk/client";
+
+const client = new Client({
+ apiUrl: LANGGRAPH_BASE_URL,
+ onRequest: (_url, init) => {
+ const headers = new Headers(init.headers);
+ headers.set("Authorization", `Bearer ${token}`);
+ if (csrfToken) {
+ headers.set("X-CSRF-Token", csrfToken);
+ }
+ return {
+ ...init,
+ credentials: "include",
+ headers,
+ };
+ },
+});
+
+const stream = client.runs.stream(threadId, "lead_agent", {
+ input: { messages: submittedMessages },
+ config: { recursion_limit: 1000 },
+ context,
+ streamMode: ["messages-tuple", "values", "updates", "custom", "events"],
+ streamSubgraphs: true,
+ streamResumable: true,
+});
+
+for await (const chunk of stream) {
+ conversationReducer.accept(chunk);
+}
+```
+
+SDK 小版本字段可能使用 snake_case 或 camelCase 包装,接入时以安装版本类型定义为准;协议概念不变。
+
+### 16.2 框架无关状态模型
+
+```ts
+interface ConversationState {
+ threadId: string;
+ runId?: string;
+ loading: boolean;
+ error?: unknown;
+
+ messageOrder: string[];
+ messagesById: Map;
+ values?: AgentThreadState;
+
+ groups: MessageGroup[];
+ artifactUI: {
+ open: boolean;
+ selected?: PreviewArtifact;
+ autoOpen: boolean;
+ autoSelect: boolean;
+ closedPreviewKeys: Set;
+ };
+}
+```
+
+所有框架模板只读取 `groups` 和 `artifactUI`。事件处理顺序固定为:
+
+```text
+decode SSE
+ → normalize event
+ → merge message/values
+ → derive groups
+ → derive steps
+ → detect artifact preview
+ → publish one state update
+```
+
+### 16.3 Vue 适配建议
+
+```ts
+const state = shallowRef(createInitialState());
+
+function publish(next: ConversationState) {
+ state.value = next;
+}
+
+const visibleGroups = computed(() => state.value.groups);
+```
+
+注意:
+
+- `Map`/`Set` 用 shallowRef 时更新后要替换外层 state;
+- 不要在模板 computed 中解析 SSE 或拼 tool args;
+- 长文件内容单独放 shallowRef/store,避免整个页面跟随每个 token 深响应;
+- `onBeforeUnmount` 中取消当前 run 或解绑 reader。
+
+### 16.4 Angular 适配建议
+
+可以使用 Signals 或 RxJS:
+
+```ts
+readonly state = signal(createInitialState());
+
+accept(event: StreamEvent) {
+ const next = reducer(this.state(), event);
+ this.state.set(next);
+}
+```
+
+注意:
+
+- fetch ReadableStream 的回调如果在 Angular zone 外运行,要在最终发布 UI 状态时进入正确的 change detection 上下文;
+- 使用 `OnPush`,消息组和步骤用稳定 id 作为 `trackBy`;
+- 大文本沙箱单独 signal;
+- 流式正文可按 animation frame 批量发布,但 tool name、previewKey、完整 path 首次出现时要立即发布。
+
+### 16.5 推荐组件树
+
+```text
+ConversationPage
+├─ MessageList
+│ ├─ HumanBubble
+│ ├─ ProcessingGroup
+│ │ ├─ HiddenPreviousSteps
+│ │ ├─ LatestToolStep
+│ │ └─ ThinkingPanel
+│ ├─ AssistantMarkdownBubble
+│ ├─ ClarificationCard
+│ ├─ SubagentCard
+│ └─ PresentFilesCard
+├─ Composer
+└─ ArtifactPanel
+ ├─ ArtifactHeader
+ ├─ StreamingSourceView
+ └─ CompletedPreview
+```
+
+---
+
+## 17. 历史记录、重连、停止与切换
+
+### 17.1 历史记录
+
+历史页面应读取服务端 thread state/history,然后重新执行同一套 group 算法。不要把已经渲染好的 HTML 存成历史,否则无法恢复步骤、文件卡片和思考折叠状态。
+
+常见接口:
+
+```text
+GET /threads/{thread_id}/state
+POST /threads/{thread_id}/history
+```
+
+切换历史时建议:
+
+1. 停止/解绑旧流;
+2. 清理 optimistic messages;
+3. 关闭或重置右侧沙箱;
+4. 加载新 thread 的权威 state;
+5. 建立新 baseline,避免历史 write_file 自动弹出;
+6. 如该 thread 有可恢复 run,再决定是否重连。
+
+### 17.2 可恢复流
+
+当前 React hook 使用 sessionStorage 保存可恢复 run 信息,键形如:
+
+```text
+lg:stream:{threadId}
+```
+
+SDK 可通过 run stream rejoin 和 Last-Event-ID 继续接收事件。第三方也应把 runId 与 threadId 绑定,而不是只保存一个全局 runId。
+
+### 17.3 停止生成
+
+停止不是在前端简单设置 `loading=false`,而是取消后端 run。特别要注意,SDK 的 `thread.stop()` 只会停止本地 SSE 监听,服务端 run 仍可能继续执行;要真正停止任务,应调用后端取消。逻辑接口为:
+
+```text
+POST /threads/{thread_id}/runs/{run_id}/cancel?wait=0&action=interrupt
+```
+
+停止后仍应接收/刷新一次最终 state,因为已完成的 ToolMessage、artifact 或部分 AI 内容可能已经写入服务端。
+
+### 17.4 新建会话
+
+新建会话需要:
+
+- 新 thread 或待创建状态;
+- 清空消息与 optimistic 状态;
+- 关闭右侧配置/分析/沙箱面板的页面级临时状态;
+- 重置 artifact 自动选择策略;
+- 清空历史 baseline 和 preview close keys。
+
+---
+
+## 18. 典型故障与根因矩阵
+
+| 现象 | 优先检查 | 常见根因 |
+| --- | --- | --- |
+| 回答结束后一次性出现 | 是否逐条消费 `messages` | 只读取最终 `values`;ReadableStream 被错误缓冲 |
+| 每个字变成一条新消息 | message id 是否合并 | 把 chunk 直接 push 到数组 |
+| `` 出现在正文 | 是否处理未闭合标签 | 只在流结束后做闭合正则 |
+| 有思考但没最终答案 | 分组是否允许 AI 同时贡献 processing 和 assistant | 错误使用互斥 `else if` |
+| 步骤条不出现 | 合并后的 AI 是否有 tool_calls | 只监听 `onToolStart`;tool args JSON 未合并成功 |
+| 步骤一直没有结果 | `tool_call_id` 是否一致 | 按 name 错配;ToolMessage 被排序或过滤 |
+| 页面显示“写入文件”,沙箱不打开 | 当前是否等待 path;autoOpen/autoSelect 是否仍为 true | 标准路径要求完整 path;用户关闭抑制过宽 |
+| 写完才打开沙箱 | 是否实现虚拟文件路径 | 只监听 `values.artifacts` |
+| 第二次写同文件不再打开 | preview 去重键是什么 | 用 path 去重,而不是 messageId + toolCallId |
+| 用户关闭后马上又弹回 | 关闭抑制是否按 previewKey | 每个新 chunk 都无条件 setOpen(true) |
+| `.md` 显示成 TEXT | 路径是否完整后重新识别 | 在 `.m` 阶段缓存语言;临时名没有扩展名 |
+| 文件写完但没有文件卡片 | 是否有 `present_files` 调用 | 把 write_file 误当成正式交付 |
+| 历史一打开沙箱就弹出旧文件 | 是否做 baseline/armed | 扫描了所有历史工具调用 |
+| 切历史后仍显示旧步骤 | 是否清理 message id 索引 | 两个 thread 共用了消息 store |
+| 中文流式卡顿 | 是否逐字创建 span/触发全页更新 | 粒度过细;大 store 深响应 |
+| 子智能体永远 loading | 是否处理 ToolMessage 前缀和 custom event | 只监听 task_running,没有处理最终工具结果 |
+
+---
+
+## 19. 建议加入的诊断日志
+
+开发和联调环境至少记录以下字段,生产环境可以采样并脱敏:
+
+```ts
+interface StreamDiagnosticLog {
+ timestamp: string;
+ threadId: string;
+ runId?: string;
+ eventId?: string;
+ eventName: string;
+ namespace?: string;
+ messageId?: string;
+ messageType?: string;
+ toolCallId?: string;
+ toolName?: string;
+ argsLength?: number;
+ contentLength?: number;
+ path?: string;
+ previewKey?: string;
+ artifactCount?: number;
+ loading: boolean;
+}
+```
+
+排查沙箱时按同一 `toolCallId` 输出时间线:
+
+```text
+22:56:01.120 AI chunk: tool=write_file, argsLength=42, path=undefined
+22:56:01.123 Step derived: 写入文件
+22:56:01.126 Preview opened: previewKey=ai-1:call-1, provisional=true
+22:56:01.540 AI chunk: path=/mnt/user-data/outputs/report.md
+22:56:01.542 Preview renamed: language=markdown
+22:56:03.204 ToolMessage matched: call-1
+22:56:03.260 values.artifacts: 0 -> 1
+22:56:03.400 stream finished
+```
+
+如果只有第一、第二行,没有第三行,问题在前端 preview controller;如果连第二行都没有,问题在消息合并或分组;如果事件日志里没有 tool call,则再查后端流。
+
+---
+
+## 20. 跨框架验收清单
+
+### 20.1 消息与思考
+
+- [ ] 用户消息先乐观显示,服务端消息到达后不重复。
+- [ ] 同一 AI message id 的 chunk 合并到同一个气泡。
+- [ ] 原生 `reasoning_content` 能显示为思考。
+- [ ] 已闭合和未闭合 `` 都不会泄漏到正文。
+- [ ] 最终正文支持 Markdown,并能流式增长。
+- [ ] 切换历史后按服务端顺序恢复消息。
+
+### 20.2 步骤与特殊卡片
+
+- [ ] tool call 首次可识别时步骤立即出现,不等待 ToolMessage。
+- [ ] tool arguments 后续 chunk 能更新步骤 description/path/content。
+- [ ] ToolMessage 按 tool_call_id 匹配原步骤。
+- [ ] 最新步骤可见,旧步骤进入“其他步骤”。
+- [ ] `task` 使用子智能体卡,不重复显示普通工具步骤。
+- [ ] clarification 和审批使用交互卡。
+- [ ] `present_files` 生成文件卡片。
+
+### 20.3 沙箱
+
+- [ ] `write_file` 内容开始增长时能尽早打开虚拟沙箱。
+- [ ] path 完整后文件名和格式正确更新。
+- [ ] 内容随 chunk 实时增长,不等流结束。
+- [ ] 用户关闭后,同一 tool call 不再弹回。
+- [ ] 下一次新 tool call 写同一路径仍可打开。
+- [ ] 历史旧 write_file 不会自动打开。
+- [ ] 流结束后 Markdown/HTML 可切换到预览。
+- [ ] `present_files` 与沙箱预览互不替代。
+
+### 20.4 稳定性
+
+- [ ] POST SSE 带 Authorization、cookie/CSRF。
+- [ ] 支持取消生成。
+- [ ] 支持断线恢复或至少能重新拉取 state。
+- [ ] 一个 thread 的事件不会写入另一个 thread store。
+- [ ] 大文本流式时不会重渲染整个页面。
+- [ ] error 事件能结束 loading 并保留已生成内容。
+
+---
+
+## 21. 最小实现伪代码
+
+下面不是可直接复制的 React 代码,而是跨 Vue/Angular/React 都适用的控制流程:
+
+```ts
+async function sendQuestion(text: string, files: File[]) {
+ assertNotRunning();
+ sandbox.armForNextRun();
+
+ const uploaded = await uploadFilesIfNeeded(files);
+ const human = createOptimisticHumanMessage(text, uploaded);
+ messages.addOptimistic(human);
+ publish();
+
+ state.loading = true;
+
+ try {
+ const stream = openRunStream({
+ threadId: state.threadId,
+ messages: [toServerHumanMessage(human)],
+ modes: ["messages-tuple", "values", "updates", "custom", "events"],
+ resumable: true,
+ subgraphs: true,
+ });
+
+ for await (const event of stream) {
+ switch (baseEventName(event.event)) {
+ case "messages":
+ messages.mergeChunk(event.data[0], event.data[1]);
+ break;
+ case "values":
+ messages.reconcile(event.data.messages);
+ artifacts.reconcile(event.data.artifacts);
+ break;
+ case "custom":
+ customEvents.accept(event.data);
+ break;
+ case "error":
+ throw normalizeStreamError(event.data);
+ }
+
+ const groups = groupMessages(messages.all());
+ const preview = detectLatestWritableArtifact(groups, {
+ armed: sandbox.armed,
+ baseline: sandbox.baseline,
+ });
+
+ if (preview && !sandbox.wasClosed(preview.previewKey)) {
+ sandbox.openOrUpdate(preview);
+ }
+
+ publishBatchedButLeadingEdge(groups, sandbox.state);
+ }
+ } finally {
+ state.loading = false;
+ sandbox.disarm();
+ await refreshCanonicalThreadState();
+ publish();
+ }
+}
+```
+
+这里最关键的顺序是:**先合并消息,再生成步骤,再检测沙箱**。不能从尚未合并的单个 chunk 直接判断完整 tool args。
+
+---
+
+## 22. 最终设计原则
+
+DeerFlow 对话页面的核心不是“聊天气泡”,而是一套事件投影:
+
+```text
+后端运行事件
+ → 可恢复的消息状态
+ → 语义消息组
+ → 思考 / 工具步骤 / 答案 / 文件交付
+ → 页面级沙箱
+```
+
+后续新项目接入时,应复用这套协议与 reducer 规则,再用 Vue、Angular 或其他组件系统实现视觉层。只复制现有 React DOM 样式,不能获得正确的流式生命周期;只调用接口等最终答案,也不能获得 DeerFlow 的步骤、思考和实时文件体验。
+
+出现问题时始终按以下顺序排查:
+
+1. 网络层是否持续收到 SSE;
+2. message id 是否正确合并;
+3. reasoning/tool_calls 是否规范化;
+4. 分组和 ToolMessage 匹配是否正确;
+5. 沙箱控制条件和关闭抑制是否正确;
+6. 最后才检查 Vue/Angular/React 的模板和 CSS。
+
+按这个顺序,能够快速区分“后端没有发”“前端丢事件”“消息没有合并”“步骤推导错误”和“沙箱 UI 状态错误”,避免在表现层反复试错。