From aa3742b1035b57c01a56a29b4d4fe15ed77eb9be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 7 Aug 2026 00:05:42 +0800 Subject: [PATCH] =?UTF-8?q?docs(position-roundtable):=20update=20=E6=B2=99?= =?UTF-8?q?=E7=AE=B1=E6=89=93=E5=BC=80=E5=85=B3=E9=97=AD=E4=B8=8E=E6=AD=A5?= =?UTF-8?q?=E9=AA=A4=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/沙箱打开关闭与步骤条.md | 1775 ++++++++++++++--- 1 file changed, 1444 insertions(+), 331 deletions(-) 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 状态错误”,避免在表现层反复试错。