989 lines
41 KiB
Markdown
989 lines
41 KiB
Markdown
# 岗位会商 · 沙箱打开 / 关闭与步骤条(详细对接与排障指南)
|
||
|
||
> **文档位置**:`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<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 对话子组件(示意)
|
||
|
||
```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 节。
|