deerflow-code/frontend-web/docs/position-roundtable-sandbox/沙箱打开关闭与步骤条.md
2026-09-07 18:24:55 +08:00

41 KiB
Raw Permalink Blame History

岗位会商 · 沙箱打开 / 关闭与步骤条(详细对接与排障指南)

文档位置:frontend-web/docs/position-roundtable-sandbox/
读者:下次重做或对接岗位会商页面的同事;线上「沙箱不实时 / 卡死 / 第二次不刷新」时的排障同学。
最后依据:当前仓库 PositionRoundtablePage + PositionNodeChat 实现(含临时名打开、外部 store、前沿节流、按 tool-call 重挂)。


目录

  1. 文档用途与读法
  2. 背景:为什么岗位会商要单独做沙箱
  3. 用户可见的正确行为(验收标准)
  4. 整体架构与数据流
  5. 职责划分:谁拥有什么状态
  6. 对接接口详解
  7. 打开沙箱的完整步骤
  8. 关闭沙箱的完整步骤
  9. 步骤条与沙箱的关系
  10. 流式内容如何进入沙箱(性能关键)
  11. 与普通对话页的对照
  12. 重做页面时的必守规则
  13. 历史问题与根因(务必读)
  14. 排障手册(按症状)
  15. 对接自测清单
  16. 最小接线示例
  17. 关键代码索引
  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)大致是:

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。

所以岗位会商采用:

对话子组件探测 + 上报
  → 页面壳统一打开唯一 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 明确禁止

  1. 在 PositionNodeChat 内再挂一套 ArtifactFileDetail。
  2. 让步骤条组件直接 setOpen(true) 作为岗位会商主路径(对照普通页可以,岗位会商不行)。
  3. 把 onThreadSnapshot 落到 PositionRoundtablePage 的 useState。
  4. 用「整个 threadId」做关闭抑制。
  5. 用「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 当前逻辑顺序:

  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:

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 });

页面壳应:

  1. 计算 signature = sandboxThreadSignature(thread)(用 isLoading、messages 长度、最近 write_file 的 path/content 长度/尾部等拼签名,用于去重)。
  2. 写入 latestNodeThreadSnapshotRef(即使沙箱还没开,打开瞬间也要用)。
  3. 若当前没有打开的沙箱,或打开的沙箱 threadId 不一致 → 只更新 ref,不 publish。
  4. 若一致 → queueSandboxThreadSnapshot(前沿节流后 store.publish)。

6.4 虚拟 URL 与正文读取

流式阶段 path 形如:

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 之前:

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:

若与 activePreview 是同一次 tool call(含 tool-start 前缀兼容)
  → 复用旧 previewKey   // 用于临时名 → 真实 path
否则
  → previewKey = 新的 messageId:toolCallId

去重键:

`${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:

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() 应做:

  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 存线程快照

错误写法:

setSandboxThreadSnapshot(snapshot) // 页面级 useState

后果:

  • 消息 chunk 约每 30ms 一次,节流后仍约 6 次/秒;
  • 每次都重渲染整页:任务卡、意图结果、流程图、节点对话……;
  • 主线程占满 → 消息列表停绘、沙箱冻住;
  • 纯尾随 setTimeout(160) 会被饿死,十几秒不 flush。

这同时解释了两个历史现象:

  1. 「沙箱打开了但内容不更新」;
  2. 「修完节流后整页更卡、消息也不实时」——因为节流终于开始真正 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. 重做页面时的必守规则

按优先级:

  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 页面壳(示意)

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:给对接同学的一页纸摘要

  1. 页面壳只挂一个沙箱;子组件只调用 onPreviewArtifact / onThreadSnapshot。
  2. 自动打开时机 ≈ 步骤条出现「写入文件」时,而不是出现文件名时。
  3. write_file 可以没有 path:临时名打开,path 到了再改名。
  4. 流式 thread 进外部 store,进页面 state 会卡死。
  5. 节流要前沿;previewKey 看 tool call;关闭抑制看 sandboxCloseKey。
  6. 同路径重新生成 = 新 previewKey = 沙箱重挂。
  7. 出问题先对照第 13、14 节,再看代码索引第 17 节。