# 岗位会商 · 沙箱打开 / 关闭与步骤条(详细对接与排障指南) > **文档位置**:`frontend-web/docs/position-roundtable-sandbox/` > **读者**:下次重做或对接岗位会商页面的同事;线上「沙箱不实时 / 卡死 / 第二次不刷新」时的排障同学。 > **最后依据**:当前仓库 `PositionRoundtablePage` + `PositionNodeChat` 实现(含临时名打开、外部 store、前沿节流、按 tool-call 重挂)。 --- ## 目录 1. [文档用途与读法](#1-文档用途与读法) 2. [背景:为什么岗位会商要单独做沙箱](#2-背景为什么岗位会商要单独做沙箱) 3. [用户可见的正确行为(验收标准)](#3-用户可见的正确行为验收标准) 4. [整体架构与数据流](#4-整体架构与数据流) 5. [职责划分:谁拥有什么状态](#5-职责划分谁拥有什么状态) 6. [对接接口详解](#6-对接接口详解) 7. [打开沙箱的完整步骤](#7-打开沙箱的完整步骤) 8. [关闭沙箱的完整步骤](#8-关闭沙箱的完整步骤) 9. [步骤条与沙箱的关系](#9-步骤条与沙箱的关系) 10. [流式内容如何进入沙箱(性能关键)](#10-流式内容如何进入沙箱性能关键) 11. [与普通对话页的对照](#11-与普通对话页的对照) 12. [重做页面时的必守规则](#12-重做页面时的必守规则) 13. [历史问题与根因(务必读)](#13-历史问题与根因务必读) 14. [排障手册(按症状)](#14-排障手册按症状) 15. [对接自测清单](#15-对接自测清单) 16. [最小接线示例](#16-最小接线示例) 17. [关键代码索引](#17-关键代码索引) 18. [术语表](#18-术语表) --- ## 1. 文档用途与读法 ### 1.1 这份文档解决什么问题 岗位会商右侧「文件预览沙箱」看起来像普通对话页的产物预览,但实现路径完全不同。历史上这块反复出过: - 沙箱不实时打开,要等全文写完才弹; - 打开了但内容冻住,生成完才一口气灌进去; - 整页卡顿,连消息列表文字都不实时; - 同轮「重新生成」第二次写入时,步骤条变了,沙箱却不动; - 第一次正常,关掉后再问就不实时。 这些 bug **特别容易在「重做页面 / 换布局 / 自己重写开沙箱逻辑」时复现**。本文把正确对接方式、时序、接口、禁区和排障步骤写全,方便下一位同事直接按文档接线,而不是再靠猜。 ### 1.2 建议读法 | 你的目标 | 先读哪些章节 | | --- | --- | | 从零接一个新页面壳 | 第 3、5、6、7、8、12、15、16 节 | | 只改对话子组件探测逻辑 | 第 7、9、12、13 节 | | 线上排障 | 第 13、14 节,必要时回看第 10 节 | | 理解为什么不能用页面 state | 第 2、10、13 节 | ### 1.3 对照代码(以仓库为准) | 角色 | 路径 | | --- | --- | | 页面壳(唯一沙箱宿主) | `frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx` | | 节点对话(探测 write_file、上报预览) | `frontend-web/src/position-roundtable/components/PositionNodeChat.tsx` | | 收口对话 | `frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx` | | 预览 UI(共享组件,勿分叉逻辑) | `frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx` | | 流式正文读取 | `frontend-web/src/core/artifacts/loader.ts` → `loadArtifactContentFromToolCall` | | 普通对话页对照(步骤条自动开) | `frontend-web/src/components/workspace/messages/message-group.tsx` | | 模块总览 | `frontend-web/src/position-roundtable/README.md` | --- ## 2. 背景:为什么岗位会商要单独做沙箱 ### 2.1 普通对话页怎么开沙箱 普通对话页(`ChatPage`)大致是: ```text ArtifactsProvider(页面级) └─ ChatBox / MessageList └─ message-group 渲染 write_file 步骤 └─ 发现 path 且 autoOpen → artifacts.select + setOpen(true) └─ 右侧 ArtifactFileDetail 读同一套 ThreadContext ``` 要点: - 对话和预览共享同一套 `ThreadContext`(同一个 `useThreadStream`)。 - 步骤条组件自己就能触发打开。 - 流式 `content` 本来就在当前 React 树的 thread 里,不需要「把 thread 镜像到别的地方」。 ### 2.2 岗位会商为什么不能照搬 岗位会商页面同时有: - 左侧任务 / 意图 / 流程图; - 中间某个节点的对话(`PositionNodeChat`); - 右侧配置抽屉; - **以及一个页面级单例沙箱**,它是对话区的**兄弟节点**,不在 `PositionNodeChat` 的 ThreadContext 里面。 因此: 1. 沙箱拿不到节点对话内部的 `thread`,除非节点主动上报; 2. 若把每次流式 thread 写进**页面级 `useState`**,整页(任务卡、意图、流程图、对话)都会按流式频率重渲染,主线程会被打爆; 3. 页面必须自己决定「开 / 关 / 抑制自动再开 / 切节点时清理」,不能让多个子组件各自 `setOpen`。 所以岗位会商采用: ```text 对话子组件探测 + 上报 → 页面壳统一打开唯一 ArtifactFileDetail → 流式 thread 走外部 store,只让沙箱子树订阅 ``` ### 2.3 和「多智能体会商 Step2 沙箱」的区别 仓库里还有 `frontend-web/docs/roundtable-step2-sandbox-progress.md`,那是另一套圆桌 Step2 沙箱进度文档,**不是**岗位会商这条链路。对接岗位会商时以本文为准。 --- ## 3. 用户可见的正确行为(验收标准) 重做或对接完成后,下列行为必须全部成立(用户感知对齐普通对话页): | # | 行为 | 说明 | | --- | --- | --- | | A | 步骤条刚出现「写入文件」时,沙箱应已打开 | **不必等**步骤条上出现文件名 | | B | 沙箱源码边写边滚;消息列表也实时出字 | 不能整页卡死、不能「生成完一口气刷」 | | C | content 先于 path 时:先临时名打开,path 到了再改名 | 不闪屏、不重挂 | | D | 同轮第二次同路径 `write_file`(重新生成) | 沙箱清空并重新流式 | | E | 整轮结束后自动切 Markdown 预览 | 「正在生成,完成后自动预览」消失 | | F | 用户手动关闭后,同一次写入不再自动弹 | 下一轮新写入仍可自动开 | | G | 切岗位 / 切节点 / 新会话 / 切历史 | 沙箱关掉且不串内容 | **不要误判的正常现象:** - 流式期间沙箱停在「源码 / 编辑」视图,结束后才切「预览」——这是 `ArtifactFileDetail` 对 markdown/html 的既定行为,与普通对话页相同,**不是**打开时机 bug。 - 步骤条长时间显示「写入文件」、没有路径,但沙箱已经打开并在滚动——content-first 模型下这是**预期**。 --- ## 4. 整体架构与数据流 ```text ┌──────────────────────────────────────────────────────────────────────────┐ │ PositionRoundtablePage(页面壳) │ │ │ │ state: sandboxArtifact | null │ │ ref: sandboxArtifactRef / closedSandboxKeyRef / latestSnapshotRef │ │ store: createSandboxThreadStore() ←── 禁止改成 useState │ │ │ │ handlePreviewArtifact(artifact) ←──────────────────────────────────┐ │ │ handleNodeThreadSnapshot(...) ←───────────────────────────────┐ │ │ │ closeSandbox() │ │ │ │ │ │ │ │ {sandboxOpen && ( │ │ │ │ SandboxThreadProvider(store) │ │ │ │ └─ ArtifactFileDetail key=sandboxCloseKey(...) │ │ │ │ )} │ │ │ └───────────────────────────────────────────────────────────────────│──│───┘ │ │ ┌───────────────────────────────────────────────────────────────────│──│───┐ │ PositionNodeChat / PositionBuiltinAgentChat │ │ │ │ │ │ │ │ useThreadStream → thread.messages / thread.isLoading │ │ │ │ │ │ │ │ │ ├─ MessageList → 步骤条(只展示 tool call) │ │ │ │ │ │ │ │ │ ├─ latestStreamingWriteFile(messages) │ │ │ │ │ → onPreviewArtifact({..., auto:true}) ───────────────────┘ │ │ │ │ │ └─ useEffect → onThreadSnapshot({threadId, thread}) ───────────────┘ └──────────────────────────────────────────────────────────────────────────┘ ``` 一句话: - **谁打开**:对话子组件决定「该不该开、开哪次交付」; - **谁真正开面板**:页面壳; - **内容怎么更新**:子组件持续上报 thread 快照 → 页面壳 store → 只有沙箱子树重渲染。 --- ## 5. 职责划分:谁拥有什么状态 ### 5.1 页面壳必须拥有 | 状态 / 能力 | 为什么必须在页面壳 | | --- | --- | | `sandboxArtifact` | 唯一预览目标;切节点时要清 | | `closedSandboxKeyRef` | 用户关过后的抑制;跨子组件生命周期 | | `sandboxThreadStore` | 流式内容通道;不能进大页面 state | | `closeSandbox()` | 切岗位 / 切节点 / 新会话统一入口 | | 唯一 `ArtifactFileDetail` | 避免双实例、关闭失效、深度异常 | ### 5.2 对话子组件必须拥有 | 状态 / 能力 | 作用 | | --- | --- | | `autoPreviewRunArmedRef`(armed) | 只有本轮用户发送后才允许自动开 | | `autoPreviewBaselineCallKeysRef`(baseline) | 忽略发送前已有的历史 write_file | | `activeWriteFilePreviewRef` | 记录当前打开的 previewKey / path / toolCallId | | `autoPreviewedArtifactKeyRef` | 同一次 identity 去重,避免每 chunk 重复上报 | | `latestStreamingWriteFile` | 从 streaming messages 探测可预览的 write | | `onThreadSnapshot` 上报 | 把 thread 喂给页面壳 | ### 5.3 明确禁止 1. 在 `PositionNodeChat` 内再挂一套 `ArtifactFileDetail`。 2. 让步骤条组件直接 `setOpen(true)` 作为岗位会商主路径(对照普通页可以,岗位会商不行)。 3. 把 `onThreadSnapshot` 落到 `PositionRoundtablePage` 的 `useState`。 4. 用「整个 threadId」做关闭抑制。 5. 用「path 相同」复用 `previewKey`。 --- ## 6. 对接接口详解 ### 6.1 `PositionArtifactPreview` ```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 = 用户点击打开 }; ``` 字段说明: | 字段 | 详细含义 | | --- | --- | | `threadId` | 沙箱用来匹配 `onThreadSnapshot` 的线程;也对关闭抑制键有贡献 | | `path` | 传给 `ArtifactFileDetail.filepath`。流式阶段通常是 `write-file:...` 虚拟 URL;落盘后的手动打开可以是真实 artifacts 路径 | | `name` | UI 展示用;临时名阶段可能是 `未命名文件.md` | | `mimeType` | 当 path 还不稳定时,帮助把语言判成 markdown/html,避免落成 TEXT | | `previewKey` | **一次交付的身份**。同一次 tool call 的 path 升级要保持不变;新的 tool call 必须变 | | `auto` | 自动打开才走关闭抑制;手动打开应清抑制 | ### 6.2 `onPreviewArtifact(artifact)` —— 对话 → 页面 页面壳 `handlePreviewArtifact` 当前逻辑顺序: 1. 校验 `threadId`、`path`,缺则 toast 并 return。 2. 计算 `artifactKey = sandboxCloseKey(artifact)`。 3. 若 `artifact.auto === true` 且 `closedSandboxKeyRef.current === artifactKey` → **抑制,直接 return**。 4. 清 `closedSandboxKeyRef`(手动打开或新的自动打开)。 5. `ArtifactsContext.select(path, true)` + `setOpen(true)`。 6. 判断是否同一预览 / 是否仅 path upgrade: - `isSamePreview`:threadId + path + previewKey 全同 → 不重复 setState。 - `isPathUpgrade`:previewKey 相同、path 不同 → 更新 artifact,**不要**再折叠右侧抽屉。 7. 若不是 same preview: - `setSandboxArtifact(artifact)`; - **同步** `sandboxArtifactRef.current = artifact`(关键!flush 会立刻读 ref); - `sandboxSnapshotAppliedAtRef.current = 0`(允许立刻推首帧内容)。 8. 若 store 里已有别的 thread 快照,先 `publish(null)`。 9. 若 `latestNodeThreadSnapshotRef` 的 threadId 匹配,立刻 `queueSandboxThreadSnapshot`。 10. 非 path upgrade 时 `setRightPanelOpen(false)`。 `sandboxCloseKey`: ```ts if (artifact.previewKey) { return `${artifact.threadId}:write:${artifact.previewKey}`; } return `${artifact.threadId}:${normalizePreviewArtifactPath(artifact.path)}`; ``` `ArtifactFileDetail` 的 React `key` 必须用这个 key: **新 previewKey → 组件重挂 → 视觉上「清空再生成」。** ### 6.3 `onThreadSnapshot({ threadId, thread })` —— 对话 → 页面 每次 `thread` 引用变化(消息 chunk 到达)子组件都会调: ```ts onThreadSnapshot?.({ threadId: activeThreadId, thread }); ``` 页面壳应: 1. 计算 `signature = sandboxThreadSignature(thread)`(用 isLoading、messages 长度、最近 write_file 的 path/content 长度/尾部等拼签名,用于去重)。 2. 写入 `latestNodeThreadSnapshotRef`(即使沙箱还没开,打开瞬间也要用)。 3. 若当前没有打开的沙箱,或打开的沙箱 threadId 不一致 → **只更新 ref,不 publish**。 4. 若一致 → `queueSandboxThreadSnapshot`(前沿节流后 `store.publish`)。 ### 6.4 虚拟 URL 与正文读取 流式阶段 path 形如: ```text write-file:${sourcePath}?message_id=${messageId}&tool_call_id=${toolCallId}&language=markdown ``` `loadArtifactContentFromToolCall`: 1. 解析 URL 的 `message_id`、`tool_call_id`; 2. 在 `thread.messages` 里找对应 AI message 的 tool call; 3. 返回 `toolCall.args.content`。 结论: - 沙箱跟的是 **tool call 身份**,不是磁盘文件是否已经写完; - 没有真实 path 也能显示正文; - 临时文件名只影响展示名和语言猜测。 ### 6.5 对话子组件 → 页面壳的其它相关回调 | 回调 | 用途 | | --- | --- | | `onPreviewArtifact` | 打开 / 更新沙箱 | | `onThreadSnapshot` | 推送流式 thread | | 页面壳 `closeSandbox` 传给 `ArtifactFileDetail.onClose` | 用户点关闭 | 手动从流程图 / 产物列表打开时,也可以直接调 `handlePreviewArtifact`(通常不带 `auto`,或不带 previewKey)。 --- ## 7. 打开沙箱的完整步骤 ### 7.1 武装(arm):只有「本轮用户提交」才允许自动开 在 `PositionNodeChat.handleSubmit` 真正 `sendMessage` 之前: ```text activeWriteFilePreviewRef = null autoPreviewBaselineCallKeysRef = writeFileCallKeys(thread.messages) // 历史 write 的 messageId:toolCallId autoPreviewRunArmedRef = true → sendMessage(...) ``` 发送失败要把 `armed` 设回 `false`。 为什么要 baseline? 线程里往往已经有上几次问答写过的 `write_file`。如果只看「messages 里有没有 write_file」,一提问就会把旧文件再打开一次。baseline 的含义是:**只有这次提交之后新出现的 tool call 才能自动开沙箱**。 ### 7.2 探测 `write_file`:不要等完整 path `write_file` 参数是一整段 JSON 流,**键顺序由模型决定**。 | 模型吐参顺序 | 步骤条表现 | 错误做法的后果 | | --- | --- | --- | | path / description 先到,再 content | 很快显示描述和文件名 | 普通对话页常见;等 path 也能开 | | **content 先到,最后才 path** | 长时间只有「写入文件」,文件名很晚才出现 | 若硬等完整 path,沙箱会晚几十秒甚至一分钟 | 岗位会商报告类 agent **经常是第二种**。 `latestStreamingWriteFile` 规则: 1. 倒序扫描 AI messages 的 `tool_calls`; 2. 工具名是 `write_file` 或 `str_replace`; 3. **有完整扩展名的 path**(`hasCompleteArtifactExtension`)→ 用真实路径; 4. **尚无可用 path,但 `write_file` 的 content 已有非空字符** → 临时文件名: - 正文头像 HTML → `未命名文件.html` - 否则 → `未命名文件.md` 5. `str_replace` 必须等真实 path(它改已有文件); 6. 组装: - `key = messageId:toolCallId` - `path = write-file:虚拟URL`(write_file)或真实 path(str_replace) - `mimeType`:markdown 则 `text/markdown` 为什么扩展名必须完整? 流式 path 会经历 `/a/b/report.m` → `/a/b/report.md`。若在 `.m` 时就当真实文件打开,预览语言可能被永久判成 TEXT。 ### 7.3 上报 effect 的四个门闩 自动上报 `onPreviewArtifact` 前必须同时满足: 1. `thread.isLoading === true` 2. 探测到 streaming artifact,且其 key **不在** baseline 3. 已有 `previewThreadId` 4. `autoPreviewRunArmedRef === true` 然后处理 previewKey: ```text 若与 activePreview 是同一次 tool call(含 tool-start 前缀兼容) → 复用旧 previewKey // 用于临时名 → 真实 path 否则 → previewKey = 新的 messageId:toolCallId ``` 去重键: ```text `${previewThreadId}:${previewKey}:${sourcePath}` ``` 把 `sourcePath` 算进去,是为了:**同 previewKey 下临时名变成真实 path 时,还要再报一次**(path upgrade),但不会每来一个 content chunk 都报。 ### 7.4 线程绑定竞态(不要误 disarm) 首轮对话常出现: 1. 用户发送(本地 draft threadId / 稍后 SDK 创建真实 id); 2. 流已经开始、armed=true; 3. 会话 API 把 `node.threadId` 从 `null` 写成真实 id。 这是「本节点绑定自己的新线程」,**不是**切节点。若绑定 effect 无脑清空 armed / baseline,首轮会永远开不了沙箱。 正确:识别 `isOwnThreadBinding`(同 nodeKey、旧 threadId 为空、新 threadId 等于当前 boundThreadId)时 **直接 return,不清 armed**。 ### 7.5 path upgrade(改名)≠ 第二次交付 同一次 tool call: ```text T1 content 到达 → path=未命名文件.md, previewKey=msg:tc1 → 打开 T2 真实 path 到达 → path=/mnt/.../报告.md, previewKey=msg:tc1 → 只改名 ``` 页面壳看到相同 previewKey、不同 path → `isPathUpgrade`: - 更新 `sandboxArtifact`; - **不**重挂(React key 仍基于 previewKey); - **不**再折叠右侧抽屉。 ### 7.6 同路径二次写入(重新生成) 同一次问答里模型可能: ```text 1) write_file → 步骤条「写入文件」→ 打开沙箱 2) 再 write_file 同 path,description=「重新生成优化版…」 ``` 这是**两次 tool call**,必须两个 previewKey。 若错误地「path 相同就复用 previewKey」: - 步骤条出现第二条; - 沙箱 React key 不变; - 用户看到「重新生成了,但沙箱没变化」。 正确:身份只看 tool call。 ### 7.7 时序总图(content-first) ```text T0 用户发送 → armed=true,拍 baseline T1 步骤条出现「写入文件」(尚无 path) T1+ args.content 首字符到达 → 临时名打开沙箱,源码开始滚动 … store 约每 160ms 推一次 thread 快照 … T2 args.path 拼完整(含 .md)→ path upgrade,标题改真实文件名(不重挂) T3 write 结束 / isLoading=false → 自动切 Markdown 预览 ``` ### 7.8 时序总图(同轮二次写入) ```text T1 write_file#1 → previewKey=msg:tc1 → 打开沙箱 A T2 #1 结束(可能短暂切预览) T3 write_file#2(同 path,新 description)→ previewKey=msg:tc2 → key 变 → 组件重挂清空 → 跟 #2 重新流式 ``` --- ## 8. 关闭沙箱的完整步骤 ### 8.1 用户手动关闭 `closeSandbox()` 应做: 1. `closedSandboxKeyRef = sandboxCloseKey(current)` —— 抑制**这一次**自动再开; 2. 关闭 `ArtifactsContext.open`(若开着); 3. 清节流定时器、pending 快照; 4. `sandboxSnapshotAppliedAtRef = 0`; 5. `sandboxArtifactRef = null` + `setSandboxArtifact(null)`; 6. `sandboxThreadStore.publish(null)`。 抑制与 path upgrade 的关系: - 关掉临时名面板后,同一次 tool call 升级真实 path **仍被抑制**(同一 previewKey)——符合「用户不要看这次」; - 下一次新的 `write_file`(新 previewKey)**可以**再自动开。 ### 8.2 场景切换时强制关闭 以下操作应先调用 `closeSandbox()`,避免串台: - 切岗位; - 切节点; - 切收口阶段; - 新建会话; - 切换历史会话。 是否写入 `closedSandboxKeyRef` 可按产品决定;至少必须清面板和 store。 ### 8.3 错误的抑制粒度(历史坑) 若做成「关过一次就整个 thread 不再自动开」: - 第一次实时开正常; - 用户关掉; - 再问一次 → 同 thread 被永久抑制 → 看起来像「第二次不实时 / 要等生成完」。 正确粒度:**单次交付的 `sandboxCloseKey`(含 previewKey)**。 --- ## 9. 步骤条与沙箱的关系 ### 9.1 步骤条从哪来 步骤条由 `MessageList` → `message-group.tsx` 渲染 tool call: - 标题:优先 `args.description`;没有则 i18n 兜底「写入文件」; - 子行:有 `args.path` 才显示路径; - 普通对话页还会在「有 path + autoOpen」时自己 `select/setOpen`。 岗位会商里,步骤条**仍然只负责展示**;打开沙箱走 `onPreviewArtifact`,不要让两套逻辑抢。 ### 9.2 对照表 | 你看到的步骤条 | 含义 | 沙箱应处于 | | --- | --- | --- | | 仅「写入文件」,无路径 | tool call 已开始;path/description 可能还没流到 | **应已打开**(临时名),源码在滚 | | 「写入文件」+ 路径 | path 已可用 | 已打开;若刚从临时名升级则只改名 | | 带自定义 description 的标题 + 路径 | 模型给了 description | 同上 | | 第二条「重新生成…」+ 同路径 | 新的 write_file(新 toolCallId) | **应重挂**并重新流式 | | 顶部「正在生成,完成后自动预览」 | 仍在 code 视图 | 正常,等 isLoading 结束切预览 | | 生成结束,出现文件卡片 / present_files | 工具结果已落 | 通常已是预览模式;可手动再点开 | ### 9.3 常见误解 **误解 1:**「步骤条出现文件名 = 打开沙箱的正确时机」 → 错。那是 path 到达的时机。content-first 时正确打开时机更早:content 首字节 / 工具调用已开始。 **误解 2:**「步骤条还在写『写入文件』,沙箱却开了,是 bug」 → 通常不是。两边数据源相同,但沙箱允许无 path 打开,步骤条标题可能还没 description。 **误解 3:**「等生成完从编辑切预览 = 沙箱打开晚了」 → 错。那是 `ArtifactFileDetail` 的 viewMode 策略。 --- ## 10. 流式内容如何进入沙箱(性能关键) ### 10.1 为什么不能用页面 `useState` 存线程快照 错误写法: ```ts setSandboxThreadSnapshot(snapshot) // 页面级 useState ``` 后果: - 消息 chunk 约每 30ms 一次,节流后仍约 6 次/秒; - 每次都重渲染整页:任务卡、意图结果、流程图、节点对话……; - 主线程占满 → 消息列表停绘、沙箱冻住; - 纯尾随 `setTimeout(160)` 会被饿死,十几秒不 flush。 这同时解释了两个历史现象: 1. 「沙箱打开了但内容不更新」; 2. 「修完节流后整页更卡、消息也不实时」——因为节流终于开始真正 setState 了。 ### 10.2 正确做法:外部 store + 子树订阅 ```ts createSandboxThreadStore() subscribe / getSnapshot / publish SandboxThreadProvider useSyncExternalStore(store.subscribe, store.getSnapshot) → ThreadContext.Provider value={{ thread, isMock:false }} → ArtifactFileDetail ``` 页面壳本身**不**因 snapshot 变化而重渲染;只有 Provider 及其子树重渲染。 ### 10.3 节流必须是前沿(leading-edge) `SANDBOX_SNAPSHOT_MIN_INTERVAL_MS = 160`: ```text queue(snapshot): pending = snapshot if 距上次 publish >= 160ms: 立刻 flush(同步 publish) else: 若没有在途 timer,则 setTimeout(剩余时间) 做补尾 flush: 清 timer 校验 pending.threadId === sandboxArtifactRef.threadId publish(pending) ``` **不要**改回: ```ts if (timer != null) return; timer = setTimeout(flush, 160); ``` 那是纯尾随;忙主线程下会假死。 ### 10.4 signature 去重 `sandboxThreadSignature` 用最近 write_file 的 path、content 长度、content 尾 32 字等拼签名。 store.publish 时若 threadId+signature 未变,则不通知订阅者,减少无效渲染。 ### 10.5 打开瞬间的首帧 `handlePreviewArtifact` 里往往会立刻 `queueSandboxThreadSnapshot(latestSnapshot)`。 此时 React 的 `setSandboxArtifact` 可能还没提交,若 flush 只读旧的 `sandboxArtifactRef`,会因 threadId 不匹配把首帧丢掉。 所以打开新预览时必须: ```ts sandboxArtifactRef.current = artifact; // 同步 sandboxSnapshotAppliedAtRef.current = 0; ``` --- ## 11. 与普通对话页的对照 | 点 | 普通对话页 | 岗位会商 | | --- | --- | --- | | 谁触发打开 | `message-group` 内 `artifacts.select` | 子组件 `onPreviewArtifact` → 页面壳 | | 是否要求 path | 是(有 path 才自动开) | 否(write_file + content 可临时名开) | | thread 从哪来 | 同树 ThreadContext | `onThreadSnapshot` + 外部 store | | 关闭抑制 | 主要靠 ArtifactsContext / 用户操作 | `closedSandboxKeyRef` 按 previewKey | | 同 path 重写 | 依赖新的 tool call / 选择逻辑 | **必须**新 previewKey 以重挂 | | 结束后切预览 | ArtifactFileDetail 同一套 | 同一套 | 对齐目标是**用户感知一致**,不是代码路径完全相同。 普通对话页自动开的核心条件(对照用): ```ts artifacts && isLoading && isLast && autoOpen && autoSelect && path && !result ``` 它也要求 path,所以 content-first 时普通页同样会偏晚——只是很多主聊天示例 agent 先吐 path,观感更好。岗位会商报告 agent 更常 content-first,所以必须做临时名提前开。 --- ## 12. 重做页面时的必守规则 按优先级: 1. **流式 thread 快照禁止进页面级 `useState`** → 用 store + `useSyncExternalStore`。 2. **节流用前沿 + 补尾**,不要纯尾随 timer。 3. **不要等完整 path 才开**;`write_file` + content 即可临时名打开。 4. **扩展名完整前不要把 path 当最终 path**。 5. **previewKey 按 tool call,不按 path**。 6. **打开时同步写 `sandboxArtifactRef`,并重置节流时间戳**。 7. **主路径扫描 `thread.messages`,不要只靠 `onToolStart`**。 8. **path upgrade 保持 previewKey,不要当第二次交付**。 9. **关闭抑制按 sandboxCloseKey,不要按整 thread**。 10. **沙箱单例在页面壳;子组件只上报**。 11. **本节点 threadId 回写不要 disarm**。 12. **「结束后才切预览」不要当打开 bug 去「修」掉共享组件**,除非产品明确要求边生成边渲染 Markdown(需单独评估性能)。 --- ## 13. 历史问题与根因(务必读) 下面每个都在真实联调里出现过。重做时若踩中,优先回来对照。 ### 13.1 沙箱要等文件名出来才开(晚几十秒) **现象:** 步骤条早就「写入文件」,沙箱很晚才开;普通对话页对比显得「实时」。 **根因:** 探测逻辑硬等完整 `path`;而该 agent 先流 `content`、最后才流 `path`。 **修复:** content 首批字符即可用临时名打开;path 到了再 upgrade。 ### 13.2 沙箱打开了,内容冻住,生成完才灌满 **现象:** 面板开了,但里面一直是空/旧内容;日志里 queue 很多,flush 很少。 **根因:** 纯尾随 160ms 定时器在忙主线程下被饿死。 **修复:** 前沿节流——到期就在流回调里同步 flush。 ### 13.3 修完「内容更新」后,整页卡顿、消息也不实时 **现象:** 沙箱开始更新了,但页面更卡,消息列表也「攒到最后刷」。 **根因:** flush 走的是页面 `useState`,每 160ms 整页重渲染。以前 timer 饿死时反而「不卡」。 **修复:** 快照改外部 store,只让沙箱子树订阅。 ### 13.4 第二次「重新生成」步骤有了,沙箱不变 **现象:** 同轮第二条二条 write 步骤,沙箱仍显示第一次完整结果。 **根因:** 用「path 相同」复用了第一次 previewKey,React key 不变,组件不重挂。 **修复:** previewKey 只按 tool call;同 path 新 call 必须新 key。 ### 13.5 第一次实时,关掉后再问就不实时 **现象:** 关过沙箱后,下一轮要等生成完才开,或不开。 **根因:** 抑制绑在整个 threadId,或把 path upgrade / 新 call 误判成同一次。 **修复:** 抑制键 = `sandboxCloseKey(previewKey)`;仅抑制那一次交付。 ### 13.6 首轮永远不开 **现象:** 新建节点第一次提问,armed 后很快又不满足条件。 **根因:** `node.threadId` 从 null 回写真实 id 时,绑定 effect 误 disarm。 **修复:** `isOwnThreadBinding` 时不清 armed。 ### 13.7 预览变成纯文本 **现象:** 明明是 md,却按 TEXT 打开。 **根因:** 在 `.m` 阶段就当完整 path;或临时名没有 `.md` / 没传 language、mimeType;或 path 带空格。 **修复:** `hasCompleteArtifactExtension`;临时名带扩展名;URL 加 `language=markdown`;path trim。 ### 13.8 依赖 onToolStart,线上偶发不开 **现象:** 本地偶发正常,联调经常不开。 **根因:** 后端 StreamBridge / worker 可能过滤 tools/events 类 stream mode。 **修复:** 主路径必须是 messages 里渐进出现的 `tool_calls`;onToolStart 只能当补充。 --- ## 14. 排障手册(按症状) 排障时建议先看 Chrome Performance / React 重渲染是否整页抖,再看消息里 tool_calls 的 args 是 path-first 还是 content-first。 ### 14.1 沙箱开得很晚 检查顺序: 1. `latestStreamingWriteFile` 是否仍要求完整 path? 2. armed 是否被 threadId 回写清掉? 3. 是否命中 `closedSandboxKey`? 4. 新 call 是否被 baseline 当成历史? 5. `previewThreadId` 是否为空(线程还没绑上)? ### 14.2 打开了但内容空白 / 冻住 1. 快照是否进了页面 useState? 2. 节流是否纯尾随? 3. `SandboxThreadProvider` 是否拿到带 messages 的 thread? 4. 虚拟 URL 的 message_id / tool_call_id 是否对得上? 5. 打开时 `sandboxArtifactRef` 是否同步?(首帧是否被 drop) 6. signature 是否因实现错误一直不变,导致 publish 被去重? ### 14.3 整页卡、消息列表也不实时 1. 搜页面壳是否还有 `setSandboxThreadSnapshot` / 类似大对象 state。 2. 是否在每 chunk `console.debug` 打大对象(DevTools 打开时更卡)。 3. 是否错误地让页面壳订阅了 store(页面壳不应 `useSyncExternalStore` 这份 store;只有 Provider 订)。 ### 14.4 重新生成不刷新 1. 第二次 write 的 toolCallId 是否变了? 2. previewKey 是否仍用 path 复用? 3. `ArtifactFileDetail` 的 `key=` 是否基于 sandboxCloseKey/previewKey? ### 14.5 关后再问异常 1. 打印 `closedSandboxKeyRef` 与新 artifact 的 sandboxCloseKey。 2. 确认新一轮 send 时 armed/baseline 已重置。 3. 确认抑制不是整 thread。 ### 14.6 语言 / 预览模式不对 1. 看打开时的 path / mimeType / language 查询参数。 2. 确认不是在扩展名不完整时打开。 3. 确认「源码→预览」发生在 isLoading false 之后(属正常)。 --- ## 15. 对接自测清单 重做页面后至少跑完: ### 功能 - [ ] 首轮:步骤条「写入文件」出现时,沙箱是否已开并开始出字? - [ ] content-first agent:无文件名阶段是否也能开? - [ ] path 到达后:是否只改名、不整页闪、不重挂? - [ ] 同轮第二次同路径 write:是否清空并重新流式? - [ ] 结束后是否自动切 Markdown 预览? - [ ] 手动点步骤条路径 / 产物卡片:能否打开正确文件? ### 关闭与抑制 - [ ] 流式中手动关闭:本次是否不再自动弹? - [ ] 关闭后再发一轮新问题:是否仍可自动开? - [ ] path upgrade 是否不会绕过「用户刚关闭」的抑制? ### 场景切换 - [ ] 切节点:沙箱关闭,旧内容不残留。 - [ ] 切岗位:同上。 - [ ] 新建会话 / 切历史:同上。 ### 性能 - [ ] 流式中消息列表是否实时出字? - [ ] 页面是否明显掉帧 / 卡死? - [ ] React DevTools 中,大页面壳是否在每 160ms 整页 update?(不应) --- ## 16. 最小接线示例 ### 16.1 页面壳(示意) ```tsx const [sandboxArtifact, setSandboxArtifact] = useState(null); const sandboxArtifactRef = useRef(null); const closedSandboxKeyRef = useRef(null); const latestNodeThreadSnapshotRef = useRef(null); const store = useMemo(createSandboxThreadStore, []); // pending + timer + appliedAt 用于前沿节流(见 PositionRoundtablePage) function handlePreviewArtifact(artifact: PositionArtifactPreview) { if (!artifact.threadId || !artifact.path) return; const key = sandboxCloseKey(artifact); if (artifact.auto && closedSandboxKeyRef.current === key) return; closedSandboxKeyRef.current = null; artifactsCtx?.select(artifact.path, true); artifactsCtx?.setOpen(true); const prev = sandboxArtifactRef.current; const isSame = prev?.threadId === artifact.threadId && prev.path === artifact.path && prev.previewKey === artifact.previewKey; const isPathUpgrade = !isSame && Boolean(artifact.previewKey) && prev?.threadId === artifact.threadId && prev.previewKey === artifact.previewKey; if (!isSame) { sandboxArtifactRef.current = artifact; // 必须同步 setSandboxArtifact(artifact); sandboxSnapshotAppliedAtRef.current = 0; } if (store.getSnapshot()?.threadId !== artifact.threadId) { store.publish(null); } const latest = latestNodeThreadSnapshotRef.current; if (latest?.threadId === artifact.threadId) { queueSandboxThreadSnapshot(latest); } if (!isPathUpgrade) setRightPanelOpen(false); } function handleNodeThreadSnapshot(snapshot: { threadId: string; thread: BaseStream }) { const next = { ...snapshot, signature: sandboxThreadSignature(snapshot.thread) }; latestNodeThreadSnapshotRef.current = next; if (sandboxArtifactRef.current?.threadId !== snapshot.threadId) return; queueSandboxThreadSnapshot(next); } function closeSandbox() { if (sandboxArtifactRef.current) { closedSandboxKeyRef.current = sandboxCloseKey(sandboxArtifactRef.current); } // clear timer / pending / appliedAt sandboxArtifactRef.current = null; setSandboxArtifact(null); store.publish(null); artifactsCtx?.setOpen(false); } // 渲染 {sandboxArtifact && ( )} ``` ### 16.2 对话子组件(示意) ```tsx // send 前 activeWriteFilePreviewRef.current = null; autoPreviewBaselineCallKeysRef.current = writeFileCallKeys(thread.messages); autoPreviewRunArmedRef.current = true; await sendMessage(...); // 探测 const streaming = thread.isLoading ? latestStreamingWriteFile(thread.messages) : null; const next = streaming && !autoPreviewBaselineCallKeysRef.current.has(streaming.key) ? streaming : null; useEffect(() => { if (!thread.isLoading || !next || !previewThreadId || !autoPreviewRunArmedRef.current) return; // previewKey:同 tool call 复用,否则用 next.key // artifactKey = threadId:previewKey:sourcePath 去重 onPreviewArtifact?.({ threadId: previewThreadId, path: next.path, name: next.name, mimeType: next.mimeType, previewKey, auto: true, }); }, [next, previewThreadId, thread.isLoading, onPreviewArtifact]); useEffect(() => { onThreadSnapshot?.({ threadId: activeThreadId, thread }); }, [activeThreadId, thread, onThreadSnapshot]); ``` **强烈建议:** 探测 / armed / baseline / 临时名逻辑直接复用或抽取自 `PositionNodeChat`,不要从零重写。 --- ## 17. 关键代码索引 | 主题 | 位置 | | --- | --- | | 临时名 / 完整扩展名 / latestStreamingWriteFile | `PositionNodeChat.tsx` 中 `provisionalWriteFilePath`、`hasCompleteArtifactExtension`、`latestStreamingWriteFile` | | armed / baseline / 上报 effect | `PositionNodeChat.tsx` `handleSubmit` + streaming preview `useEffect` | | 同 tool call 判断 / 二次写入重挂 | 同上,`isSameToolCall` / `previewKey` | | 线程绑定保护 | `PositionNodeChat.tsx` node binding `useEffect` 中 `isOwnThreadBinding` | | sandboxCloseKey / store / 前沿节流 | `PositionRoundtablePage.tsx` `sandboxCloseKey`、`createSandboxThreadStore`、`queueSandboxThreadSnapshot` | | 打开 / path upgrade / 抑制 | `PositionRoundtablePage.tsx` `handlePreviewArtifact` | | 关闭 | `PositionRoundtablePage.tsx` `closeSandbox` | | 虚拟 URL 读 content | `core/artifacts/loader.ts` `loadArtifactContentFromToolCall` | | 源码/预览切换 | `artifact-file-detail.tsx` 中 `viewMode` + `thread.isLoading` | | 普通页步骤条自动开 | `message-group.tsx` `write_file` 分支 | 相关提交(背景): - `fix(frontend): 岗位会商沙箱在 write_file 流式阶段实时打开并刷新` — 临时名打开、外部 store、前沿节流 - `fix(frontend): 同路径二次 write_file 时重挂岗位会商沙箱` — previewKey 按 tool call,禁止按 path 复用 --- ## 18. 术语表 | 术语 | 含义 | | --- | --- | | armed | 本轮用户发送后,允许自动打开沙箱的开关 | | baseline | send 前已有的 write tool call 集合,用于忽略历史产物 | | previewKey | 一次交付的稳定身份,推荐 `messageId:toolCallId` | | sandboxCloseKey | 关闭抑制键,同时用作 `ArtifactFileDetail` 的 React key | | provisional path / 临时名 | 尚无真实 path 时的 `未命名文件.md` / `.html` | | path upgrade | 同 previewKey 下临时名升级为真实 path | | sandboxThreadStore | 流式线程外部 store,避免整页重渲染 | | write-file: URL | 用 message_id + tool_call_id 定位流式 content 的虚拟路径 | | leading-edge throttle / 前沿节流 | 到期立即在回调里 publish,而不是只挂尾随 timer | | content-first / path-first | 模型流式 JSON 参数时 content 与 path 哪个先到 | | isOwnThreadBinding | 本节点把自己的新 threadId 写回 props,不是切节点 | --- ## 附录 A:给对接同学的一页纸摘要 1. 页面壳只挂一个沙箱;子组件只调用 `onPreviewArtifact` / `onThreadSnapshot`。 2. 自动打开时机 ≈ 步骤条出现「写入文件」时,而不是出现文件名时。 3. `write_file` 可以没有 path:临时名打开,path 到了再改名。 4. 流式 thread 进外部 store,进页面 state 会卡死。 5. 节流要前沿;previewKey 看 tool call;关闭抑制看 sandboxCloseKey。 6. 同路径重新生成 = 新 previewKey = 沙箱重挂。 7. 出问题先对照第 13、14 节,再看代码索引第 17 节。