41 KiB
岗位会商 · 沙箱打开 / 关闭与步骤条(详细对接与排障指南)
文档位置:
frontend-web/docs/position-roundtable-sandbox/
读者:下次重做或对接岗位会商页面的同事;线上「沙箱不实时 / 卡死 / 第二次不刷新」时的排障同学。
最后依据:当前仓库PositionRoundtablePage+PositionNodeChat实现(含临时名打开、外部 store、前沿节流、按 tool-call 重挂)。
目录
- 文档用途与读法
- 背景:为什么岗位会商要单独做沙箱
- 用户可见的正确行为(验收标准)
- 整体架构与数据流
- 职责划分:谁拥有什么状态
- 对接接口详解
- 打开沙箱的完整步骤
- 关闭沙箱的完整步骤
- 步骤条与沙箱的关系
- 流式内容如何进入沙箱(性能关键)
- 与普通对话页的对照
- 重做页面时的必守规则
- 历史问题与根因(务必读)
- 排障手册(按症状)
- 对接自测清单
- 最小接线示例
- 关键代码索引
- 术语表
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)大致是:
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 里面。
因此:
- 沙箱拿不到节点对话内部的
thread,除非节点主动上报; - 若把每次流式 thread 写进页面级
useState,整页(任务卡、意图、流程图、对话)都会按流式频率重渲染,主线程会被打爆; - 页面必须自己决定「开 / 关 / 抑制自动再开 / 切节点时清理」,不能让多个子组件各自
setOpen。
所以岗位会商采用:
对话子组件探测 + 上报
→ 页面壳统一打开唯一 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. 整体架构与数据流
┌──────────────────────────────────────────────────────────────────────────┐
│ 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 明确禁止
- 在
PositionNodeChat内再挂一套ArtifactFileDetail。 - 让步骤条组件直接
setOpen(true)作为岗位会商主路径(对照普通页可以,岗位会商不行)。 - 把
onThreadSnapshot落到PositionRoundtablePage的useState。 - 用「整个 threadId」做关闭抑制。
- 用「path 相同」复用
previewKey。
6. 对接接口详解
6.1 PositionArtifactPreview
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 当前逻辑顺序:
- 校验
threadId、path,缺则 toast 并 return。 - 计算
artifactKey = sandboxCloseKey(artifact)。 - 若
artifact.auto === true且closedSandboxKeyRef.current === artifactKey→ 抑制,直接 return。 - 清
closedSandboxKeyRef(手动打开或新的自动打开)。 ArtifactsContext.select(path, true)+setOpen(true)。- 判断是否同一预览 / 是否仅 path upgrade:
isSamePreview:threadId + path + previewKey 全同 → 不重复 setState。isPathUpgrade:previewKey 相同、path 不同 → 更新 artifact,不要再折叠右侧抽屉。
- 若不是 same preview:
setSandboxArtifact(artifact);- 同步
sandboxArtifactRef.current = artifact(关键!flush 会立刻读 ref); sandboxSnapshotAppliedAtRef.current = 0(允许立刻推首帧内容)。
- 若 store 里已有别的 thread 快照,先
publish(null)。 - 若
latestNodeThreadSnapshotRef的 threadId 匹配,立刻queueSandboxThreadSnapshot。 - 非 path upgrade 时
setRightPanelOpen(false)。
sandboxCloseKey:
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 到达)子组件都会调:
onThreadSnapshot?.({ threadId: activeThreadId, thread });
页面壳应:
- 计算
signature = sandboxThreadSignature(thread)(用 isLoading、messages 长度、最近 write_file 的 path/content 长度/尾部等拼签名,用于去重)。 - 写入
latestNodeThreadSnapshotRef(即使沙箱还没开,打开瞬间也要用)。 - 若当前没有打开的沙箱,或打开的沙箱 threadId 不一致 → 只更新 ref,不 publish。
- 若一致 →
queueSandboxThreadSnapshot(前沿节流后store.publish)。
6.4 虚拟 URL 与正文读取
流式阶段 path 形如:
write-file:${sourcePath}?message_id=${messageId}&tool_call_id=${toolCallId}&language=markdown
loadArtifactContentFromToolCall:
- 解析 URL 的
message_id、tool_call_id; - 在
thread.messages里找对应 AI message 的 tool call; - 返回
toolCall.args.content。
结论:
- 沙箱跟的是 tool call 身份,不是磁盘文件是否已经写完;
- 没有真实 path 也能显示正文;
- 临时文件名只影响展示名和语言猜测。
6.5 对话子组件 → 页面壳的其它相关回调
| 回调 | 用途 |
|---|---|
onPreviewArtifact |
打开 / 更新沙箱 |
onThreadSnapshot |
推送流式 thread |
页面壳 closeSandbox 传给 ArtifactFileDetail.onClose |
用户点关闭 |
手动从流程图 / 产物列表打开时,也可以直接调 handlePreviewArtifact(通常不带 auto,或不带 previewKey)。
7. 打开沙箱的完整步骤
7.1 武装(arm):只有「本轮用户提交」才允许自动开
在 PositionNodeChat.handleSubmit 真正 sendMessage 之前:
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 规则:
- 倒序扫描 AI messages 的
tool_calls; - 工具名是
write_file或str_replace; - 有完整扩展名的 path(
hasCompleteArtifactExtension)→ 用真实路径; - 尚无可用 path,但
write_file的 content 已有非空字符 → 临时文件名:- 正文头像 HTML →
未命名文件.html - 否则 →
未命名文件.md
- 正文头像 HTML →
str_replace必须等真实 path(它改已有文件);- 组装:
key = messageId:toolCallIdpath = 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 前必须同时满足:
thread.isLoading === true- 探测到 streaming artifact,且其 key 不在 baseline
- 已有
previewThreadId autoPreviewRunArmedRef === true
然后处理 previewKey:
若与 activePreview 是同一次 tool call(含 tool-start 前缀兼容)
→ 复用旧 previewKey // 用于临时名 → 真实 path
否则
→ previewKey = 新的 messageId:toolCallId
去重键:
`${previewThreadId}:${previewKey}:${sourcePath}`
把 sourcePath 算进去,是为了:同 previewKey 下临时名变成真实 path 时,还要再报一次(path upgrade),但不会每来一个 content chunk 都报。
7.4 线程绑定竞态(不要误 disarm)
首轮对话常出现:
- 用户发送(本地 draft threadId / 稍后 SDK 创建真实 id);
- 流已经开始、armed=true;
- 会话 API 把
node.threadId从null写成真实 id。
这是「本节点绑定自己的新线程」,不是切节点。若绑定 effect 无脑清空 armed / baseline,首轮会永远开不了沙箱。
正确:识别 isOwnThreadBinding(同 nodeKey、旧 threadId 为空、新 threadId 等于当前 boundThreadId)时 直接 return,不清 armed。
7.5 path upgrade(改名)≠ 第二次交付
同一次 tool call:
T1 content 到达 → path=未命名文件.md, previewKey=msg:tc1 → 打开
T2 真实 path 到达 → path=/mnt/.../报告.md, previewKey=msg:tc1 → 只改名
页面壳看到相同 previewKey、不同 path → isPathUpgrade:
- 更新
sandboxArtifact; - 不重挂(React key 仍基于 previewKey);
- 不再折叠右侧抽屉。
7.6 同路径二次写入(重新生成)
同一次问答里模型可能:
1) write_file → 步骤条「写入文件」→ 打开沙箱
2) 再 write_file 同 path,description=「重新生成优化版…」
这是两次 tool call,必须两个 previewKey。
若错误地「path 相同就复用 previewKey」:
- 步骤条出现第二条;
- 沙箱 React key 不变;
- 用户看到「重新生成了,但沙箱没变化」。
正确:身份只看 tool call。
7.7 时序总图(content-first)
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 时序总图(同轮二次写入)
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() 应做:
closedSandboxKeyRef = sandboxCloseKey(current)—— 抑制这一次自动再开;- 关闭
ArtifactsContext.open(若开着); - 清节流定时器、pending 快照;
sandboxSnapshotAppliedAtRef = 0;sandboxArtifactRef = null+setSandboxArtifact(null);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 存线程快照
错误写法:
setSandboxThreadSnapshot(snapshot) // 页面级 useState
后果:
- 消息 chunk 约每 30ms 一次,节流后仍约 6 次/秒;
- 每次都重渲染整页:任务卡、意图结果、流程图、节点对话……;
- 主线程占满 → 消息列表停绘、沙箱冻住;
- 纯尾随
setTimeout(160)会被饿死,十几秒不 flush。
这同时解释了两个历史现象:
- 「沙箱打开了但内容不更新」;
- 「修完节流后整页更卡、消息也不实时」——因为节流终于开始真正 setState 了。
10.2 正确做法:外部 store + 子树订阅
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:
queue(snapshot):
pending = snapshot
if 距上次 publish >= 160ms:
立刻 flush(同步 publish)
else:
若没有在途 timer,则 setTimeout(剩余时间) 做补尾
flush:
清 timer
校验 pending.threadId === sandboxArtifactRef.threadId
publish(pending)
不要改回:
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 不匹配把首帧丢掉。
所以打开新预览时必须:
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 同一套 | 同一套 |
对齐目标是用户感知一致,不是代码路径完全相同。
普通对话页自动开的核心条件(对照用):
artifacts && isLoading && isLast && autoOpen && autoSelect && path && !result
它也要求 path,所以 content-first 时普通页同样会偏晚——只是很多主聊天示例 agent 先吐 path,观感更好。岗位会商报告 agent 更常 content-first,所以必须做临时名提前开。
12. 重做页面时的必守规则
按优先级:
- 流式 thread 快照禁止进页面级
useState→ 用 store +useSyncExternalStore。 - 节流用前沿 + 补尾,不要纯尾随 timer。
- 不要等完整 path 才开;
write_file+ content 即可临时名打开。 - 扩展名完整前不要把 path 当最终 path。
- previewKey 按 tool call,不按 path。
- 打开时同步写
sandboxArtifactRef,并重置节流时间戳。 - 主路径扫描
thread.messages,不要只靠onToolStart。 - path upgrade 保持 previewKey,不要当第二次交付。
- 关闭抑制按 sandboxCloseKey,不要按整 thread。
- 沙箱单例在页面壳;子组件只上报。
- 本节点 threadId 回写不要 disarm。
- 「结束后才切预览」不要当打开 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 沙箱开得很晚
检查顺序:
latestStreamingWriteFile是否仍要求完整 path?- armed 是否被 threadId 回写清掉?
- 是否命中
closedSandboxKey? - 新 call 是否被 baseline 当成历史?
previewThreadId是否为空(线程还没绑上)?
14.2 打开了但内容空白 / 冻住
- 快照是否进了页面 useState?
- 节流是否纯尾随?
SandboxThreadProvider是否拿到带 messages 的 thread?- 虚拟 URL 的 message_id / tool_call_id 是否对得上?
- 打开时
sandboxArtifactRef是否同步?(首帧是否被 drop) - signature 是否因实现错误一直不变,导致 publish 被去重?
14.3 整页卡、消息列表也不实时
- 搜页面壳是否还有
setSandboxThreadSnapshot/ 类似大对象 state。 - 是否在每 chunk
console.debug打大对象(DevTools 打开时更卡)。 - 是否错误地让页面壳订阅了 store(页面壳不应
useSyncExternalStore这份 store;只有 Provider 订)。
14.4 重新生成不刷新
- 第二次 write 的 toolCallId 是否变了?
- previewKey 是否仍用 path 复用?
ArtifactFileDetail的key=是否基于 sandboxCloseKey/previewKey?
14.5 关后再问异常
- 打印
closedSandboxKeyRef与新 artifact 的 sandboxCloseKey。 - 确认新一轮 send 时 armed/baseline 已重置。
- 确认抑制不是整 thread。
14.6 语言 / 预览模式不对
- 看打开时的 path / mimeType / language 查询参数。
- 确认不是在扩展名不完整时打开。
- 确认「源码→预览」发生在 isLoading false 之后(属正常)。
15. 对接自测清单
重做页面后至少跑完:
功能
- 首轮:步骤条「写入文件」出现时,沙箱是否已开并开始出字?
- content-first agent:无文件名阶段是否也能开?
- path 到达后:是否只改名、不整页闪、不重挂?
- 同轮第二次同路径 write:是否清空并重新流式?
- 结束后是否自动切 Markdown 预览?
- 手动点步骤条路径 / 产物卡片:能否打开正确文件?
关闭与抑制
- 流式中手动关闭:本次是否不再自动弹?
- 关闭后再发一轮新问题:是否仍可自动开?
- path upgrade 是否不会绕过「用户刚关闭」的抑制?
场景切换
- 切节点:沙箱关闭,旧内容不残留。
- 切岗位:同上。
- 新建会话 / 切历史:同上。
性能
- 流式中消息列表是否实时出字?
- 页面是否明显掉帧 / 卡死?
- React DevTools 中,大页面壳是否在每 160ms 整页 update?(不应)
16. 最小接线示例
16.1 页面壳(示意)
const [sandboxArtifact, setSandboxArtifact] = useState<PositionArtifactPreview | null>(null);
const sandboxArtifactRef = useRef<PositionArtifactPreview | null>(null);
const closedSandboxKeyRef = useRef<string | null>(null);
const latestNodeThreadSnapshotRef = useRef<SandboxThreadSnapshot | null>(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<AgentThreadState> }) {
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 && (
<SandboxThreadProvider
store={store}
threadId={sandboxArtifact.threadId}
fallbackThread={stub}
>
<ArtifactFileDetail
key={sandboxCloseKey(sandboxArtifact)}
filepath={sandboxArtifact.path}
threadId={sandboxArtifact.threadId}
mimeType={sandboxArtifact.mimeType}
onClose={closeSandbox}
allowVirtualMarkdownEdit
/>
</SandboxThreadProvider>
)}
16.2 对话子组件(示意)
// 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:给对接同学的一页纸摘要
- 页面壳只挂一个沙箱;子组件只调用
onPreviewArtifact/onThreadSnapshot。 - 自动打开时机 ≈ 步骤条出现「写入文件」时,而不是出现文件名时。
write_file可以没有 path:临时名打开,path 到了再改名。- 流式 thread 进外部 store,进页面 state 会卡死。
- 节流要前沿;previewKey 看 tool call;关闭抑制看 sandboxCloseKey。
- 同路径重新生成 = 新 previewKey = 沙箱重挂。
- 出问题先对照第 13、14 节,再看代码索引第 17 节。