代码修改

This commit is contained in:
董宏凯 committed 2026-08-05 21:15:36 +08:00
1 parent 8b732b43ee
commit 49af497085
2 files changed
+394

No files matched your search

@@ -132,6 +132,9 @@ PositionRoundtablePage
页面只维护一个 `sandboxArtifact`。`PositionNodeChat` 和 `PositionBuiltinAgentChat` 通过 `onPreviewArtifact` 上报预览目标;父页面统一渲染 `ArtifactFileDetail`。
**对接 / 重做页面 / 排障指南(接口约定、必守规则、自测与症状表):**
[`docs/沙箱打开关闭与步骤条.md`](./docs/沙箱打开关闭与步骤条.md)
设计要点:
- 文件正在由 `write_file` 流式生成时,优先显示源码;完成后交由预览组件切换 Markdown 预览;
@@ -0,0 +1,391 @@
# 岗位会商沙箱对接指南(重做页面 / 排障)
## 文档用途
给**下次重做或对接岗位会商页面**的同事用,也给线上「沙箱不实时 / 卡死 / 第二次不刷新」时排障用。
读完应能回答:
1. 新页面要接哪几个回调、传什么字段?
2. 打开 / 关闭沙箱各在什么时机?和步骤条是什么关系?
3. 哪些写法绝对不能用(会复现历史 bug)?
4. 出问题时按什么顺序查?
对照实现(当前生产路径):
| 角色 | 文件 |
| --- | --- |
| 页面壳(沙箱唯一宿主) | `pages/PositionRoundtablePage.tsx` |
| 节点对话(探测 write_file、上报预览) | `components/PositionNodeChat.tsx` |
| 收口对话 | `components/PositionBuiltinAgentChat.tsx` |
| 预览 UI(共享组件,勿分叉逻辑) | `components/workspace/artifacts/artifact-file-detail.tsx` |
| 流式正文读取 | `core/artifacts/loader.ts` → `loadArtifactContentFromToolCall` |
| 普通对话页对照(步骤条自动开) | `components/workspace/messages/message-group.tsx` |
模块总览见同目录上级 [`../README.md`](../README.md);本文只覆盖**文件预览沙箱**这一条链路。
---
## 1. 验收标准(对接完成即通过)
重做页面后,下列行为必须全部成立(与普通对话页用户感知一致):
| # | 行为 |
| --- | --- |
| A | 步骤条刚出现「写入文件」时,右侧沙箱应已打开(不必等文件名出现) |
| B | 沙箱内源码随模型输出边写边滚;消息列表文字也实时出现,不能整页卡死 |
| C | 模型若先吐正文、后吐 `path`:先用临时名打开,path 到了再改名,不闪屏重挂 |
| D | 同一次问答里第二次 `write_file` 写同一路径(「重新生成」):沙箱清空并重新流式 |
| E | 整轮 `isLoading=false` 后,自动从源码切到 Markdown 预览 |
| F | 用户手动关闭后,**同一次**写入不再自动弹;**下一轮**新写入仍可自动开 |
| G | 切岗位 / 切节点 / 新建或切换会话时,沙箱关掉且不串内容 |
---
## 2. 职责划分(谁干什么)
```text
┌─────────────────────────────────────────────────────────────┐
│ 页面壳(PositionRoundtablePage 或你的新页面) │
│ · 唯一 sandboxArtifact 状态 │
│ · handlePreviewArtifact / closeSandbox │
│ · 流式线程外部 store(禁止整页 useState) │
│ · 渲染唯一 ArtifactFileDetail │
└──────────────────────────▲──────────────────────────────────┘
│ onPreviewArtifact / onThreadSnapshot
┌──────────────────────────┴──────────────────────────────────┐
│ 对话子组件(PositionNodeChat 等) │
│ · useThreadStream 拉消息 │
│ · 渲染 MessageList / 步骤条(只展示,不直接开沙箱) │
│ · 探测 write_file → 组装 PositionArtifactPreview 上报 │
│ · 每次 thread 变化上报 onThreadSnapshot │
└─────────────────────────────────────────────────────────────┘
```
硬性规则:
1. **沙箱只有一个实例**,挂在页面壳上,不要在对话子组件里再挂 `ArtifactFileDetail`。
2. 对话子组件**不要**直接 `artifacts.setOpen(true)` 当作岗位会商的主路径(页面壳统一开合,才能做关闭抑制、切节点清理)。
3. 步骤条和沙箱是**两条线**:步骤条只反映 tool call 展示;开沙箱走 `onPreviewArtifact`。
---
## 3. 对接接口(重新接页面时照此接线)
### 3.1 对话子组件 → 页面壳
#### `onPreviewArtifact(artifact)`
请求打开或更新沙箱。载荷:
```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/缺省 = 用户点击打开
};
```
页面壳收到后应:
1. `auto===true` 且 `closedSandboxKey === sandboxCloseKey(artifact)` → **直接 return**(用户刚关过这一次)。
2. 更新页面级 `sandboxArtifact`(并同步 ref,见下)。
3. 同步 `ArtifactsContext.select(path) + setOpen(true)`(与共享预览组件生命周期对齐)。
4. 若已有该 thread 的最新快照,立刻推进沙箱 store。
5. React `key` 用 `sandboxCloseKey(artifact)`(有 `previewKey` 时优先它),保证新交付会重挂。
`sandboxCloseKey` 约定:
```ts
// 有 previewKey:threadId:write:${previewKey}
// 无 previewKey:threadId:${normalizePath(path)}
```
#### `onThreadSnapshot({ threadId, thread })`
把当前 `useThreadStream` 的 thread 对象持续交给页面壳,供沙箱读流式 `content`。
页面壳应:
1. 始终写入 `latestNodeThreadSnapshotRef`(打开瞬间可能用到)。
2. **仅当**当前沙箱 `threadId` 与 snapshot 一致时,才 `queue` → store `publish`。
3. 节流:前沿 160ms(见第 5 节),不要纯尾随 `setTimeout`。
### 3.2 页面壳 → 预览组件
```tsx
{sandboxOpen && 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>
)}
```
流式正文依赖虚拟 URL:
```text
write-file:${path}?message_id=...&tool_call_id=...&language=markdown
```
`loadArtifactContentFromToolCall` **只认** URL 里的 `message_id` + `tool_call_id`,从 `thread.messages` 对应 tool call 的 `args.content` 取值。
因此:**可以没有真实磁盘 path 就开沙箱**;path 主要用于文件名展示和语言猜测。
### 3.3 对话子组件内部必做状态(可复用现有逻辑)
重新实现探测时,至少保留这些语义(不必同名,语义要对):
| 状态 | 作用 |
| --- | --- |
| `armed` | 仅本轮用户 `send` 成功武装后才允许自动 `onPreviewArtifact` |
| `baselineCallKeys` | send 前已有的 `messageId:toolCallId` 集合;忽略历史 write,防止一提问就开旧文件 |
| `activePreview` | 当前已打开的 previewKey / path / toolCallId,用于 path 升级与同 call 去重 |
| 线程绑定保护 | `node.threadId` 从 null→本节点刚创建的 id 时,**不要** disarm |
发送时:
```text
baseline = 当前历史 write keys
armed = true
activePreview = null
→ sendMessage(...)
```
探测时(`isLoading`):
```text
找到最新 write_file / str_replace
若不在 baseline:
- 有完整扩展名 path → 用真实 path
- write_file 且仅有 content → 临时名(未命名文件.md / .html)
- str_replace 无 path → 先不报
previewKey = 同 tool call 复用,否则新 key
→ onPreviewArtifact({ ..., auto: true })
```
---
## 4. 打开 / 关闭时序(对接时按此实现)
### 4.1 打开(自动)
```text
用户发送
→ armed=true + 拍 baseline
→ 模型开始 write_file
→ 步骤条出现「写入文件」(可能还没有 path)
→ content 或完整 path 任一满足探测条件
→ onPreviewArtifact(auto)
→ 页面壳打开 ArtifactFileDetail + 推送 thread 快照
→ 源码视图滚动更新
→(可选)真实 path 到达:同 previewKey 改名(path upgrade,不重挂)
→ isLoading=false:共享组件自动切预览
```
### 4.2 同轮第二次写入(重新生成)
```text
write_file #1 → previewKey = msg:tc1 → 打开
write_file #2 同 path → previewKey = msg:tc2(必须变)
→ React key 变 → 组件重挂 → 视觉上清空再生成
```
**禁止**用「path 相同 ⇒ 复用 previewKey」。那会导致步骤条有「重新生成」、沙箱却不动。
### 4.3 关闭
| 触发 | 行为 |
| --- | --- |
| 用户点关闭 | `closedSandboxKeyRef = sandboxCloseKey(current)`;清 artifact / store / 定时器 |
| 切岗位、切节点、新会话、切历史 | 先 `closeSandbox()`(可按产品决定是否写入抑制键;至少清面板) |
| 自动打开被抑制 | 仅当 `auto && closedKey === 当前 key`;手动打开应清抑制 |
抑制粒度必须是**单次交付(previewKey)**,不能是「整个 thread 永远不再自动开」。
---
## 5. 重做页面时的必守规则(违反必出 bug)
1. **流式 thread 快照禁止进页面级 `useState`**
大页面每秒重渲染数次会占满主线程:消息列表停更、沙箱冻住、定时器饿死。
正确:外部 store + `useSyncExternalStore`,只让沙箱子树订阅。
2. **节流用前沿(leading-edge),不要纯尾随 `setTimeout`**
距上次 publish ≥ 160ms → 同步 publish;否则补尾定时器。
纯「有 timer 就 return」在忙主线程下会十几秒不 flush。
3. **不要等完整 `path` 才开沙箱**
报告类 agent 常先流 `content`、最后才流 `path`,硬等会晚几十秒。
`write_file` + 已有 content → 临时名打开;`str_replace` 仍等真实 path。
4. **扩展名要完整再当「真实 path」**
流式中 `.m` → `.md`,过早打开会把语言锁成 TEXT。
5. **previewKey 按 tool call,不按 path**
同 path 多次写入 = 多次交付。
6. **打开瞬间同步推进 `sandboxArtifactRef`**
若 flush 快照时只看 ref、而 setState 尚未提交,首帧会被「threadId 不匹配」丢掉。
7. **不要依赖 `onToolStart` 作为唯一打开手段**
当前后端对部分 stream mode 可能过滤;主路径必须是 `thread.messages` 里渐进出现的 `tool_calls`。
8. **path upgrade 不要当第二次打开**
同 previewKey 改 path:更新 artifact,不要乱关抽屉 / 不要重挂。
9. **「结束后才切预览」不是打开 bug**
`ArtifactFileDetail` 对 markdown/html 在 `isLoading` 期间强制源码视图,与普通对话页相同。
---
## 6. 步骤条怎么对接理解
步骤条来自消息里的 tool call 渲染(`message-group`),标题优先 `args.description`,否则文案「写入文件」;子行显示 `args.path`。
| 你看到的步骤条 | 含义 | 沙箱应处于 |
| --- | --- | --- |
| 仅「写入文件」,无路径 | tool call 已开始,path 可能还没流到 | **应已打开**(临时名) |
| 「写入文件」+ 路径 | path 已可用 | 已打开;若刚从临时名升级则只改名 |
| 第二条「重新生成…」+ 同路径 | 新的 write_file(新 toolCallId) | **应重挂**并重新流式 |
| 生成中顶部「正在生成,完成后自动预览」 | 仍在 code 视图 | 正常,等 isLoading 结束切预览 |
对接时不要把「步骤条出现文件名」当成「开沙箱的唯一信号」——那会在 content-first 模型上系统性偏晚。
---
## 7. 排障手册(按症状查)
### 7.1 沙箱开得很晚 / 要等文件名才开
检查顺序:
1. 探测是否仍要求「必须有完整 path」?→ 改为 content 可开临时名。
2. `armed` 是否在 `threadId` 回写时被误清?→ 保护「本节点绑定自己的新 thread」。
3. 是否被 `closedSandboxKey` 抑制?→ 看是否刚关过同 previewKey。
4. 新 call 是否落在 baseline 里?→ baseline 只能在 send 时拍一次。
### 7.2 沙箱打开了但内容空白 / 冻住,生成完才一口气出来
1. 快照是否写进了页面 `useState`?→ 改 store。
2. 节流是否纯尾随定时器?→ 改前沿。
3. `SandboxThreadProvider` 是否把带 `messages` 的 thread 传给了 `ArtifactFileDetail`?
4. 虚拟 URL 的 `tool_call_id` 是否与消息里一致?
5. 打开时 `sandboxArtifactRef` 是否已同步(避免首帧 flush 被丢)?
### 7.3 消息列表也不实时,整页很卡
优先怀疑页面壳在流式频率下整页重渲染。
确认没有把 `onThreadSnapshot` 落到大页面 state;临时 `console.debug` 每 chunk 打大对象也会加重(排障完删掉)。
### 7.4 第二次「重新生成」步骤有了,沙箱不变
previewKey 是否按 path 复用了?→ 必须按 `messageId:toolCallId`。
`ArtifactFileDetail` 的 `key` 是否跟 previewKey 走?否则不会重挂。
### 7.5 第一次实时开,关掉后再问就不实时 / 很晚才开
抑制是否绑在整个 `threadId` 上?→ 改为单次 `sandboxCloseKey(previewKey)`。
### 7.6 预览变成纯文本 / 不像 Markdown
1. 是否在 `.m` 阶段就当完整 path 打开了?
2. 临时名是否带 `.md` / URL 是否带 `language=markdown` / 是否传了 `mimeType`?
3. path 是否有首尾空格?
### 7.7 只靠 onToolStart,线上偶发永远不开
后端可能不推 tools/events。主路径改为扫描 `thread.messages` 的 streaming `tool_calls`。
---
## 8. 对接自测清单(重做页面后必跑)
- [ ] 首轮:步骤条「写入文件」出现时,沙箱是否已开并开始出字?
- [ ] content-first agent:无文件名阶段是否也能开?
- [ ] path 到达后:是否只改名、不整页闪?
- [ ] 同轮第二次同路径 write:是否清空并重新流式?
- [ ] 流式中消息列表是否仍实时?页面是否明显卡顿?
- [ ] 手动关闭后本次是否不再弹?下一轮是否仍可自动开?
- [ ] 结束后是否自动切 Markdown 预览?
- [ ] 切节点 / 切岗位 / 新会话:沙箱是否关闭且内容不串?
---
## 9. 最小接线伪代码(新页面可抄)
```tsx
// 页面壳
const [sandboxArtifact, setSandboxArtifact] = useState<PositionArtifactPreview | null>(null);
const sandboxArtifactRef = useRef(sandboxArtifact);
const closedSandboxKeyRef = useRef<string | null>(null);
const store = useMemo(createSandboxThreadStore, []);
function handlePreviewArtifact(artifact: PositionArtifactPreview) {
const key = sandboxCloseKey(artifact);
if (artifact.auto && closedSandboxKeyRef.current === key) return;
const prev = sandboxArtifactRef.current;
const sameKey = prev?.previewKey && prev.previewKey === artifact.previewKey
&& prev.threadId === artifact.threadId;
const pathOnlyUpgrade = sameKey && prev.path !== artifact.path;
sandboxArtifactRef.current = artifact;
setSandboxArtifact(artifact);
// publish latest thread snapshot into store (leading-edge throttle)
// ArtifactsContext.select + setOpen(true)
if (!pathOnlyUpgrade) closeRightDrawer();
}
function closeSandbox() {
if (sandboxArtifactRef.current) {
closedSandboxKeyRef.current = sandboxCloseKey(sandboxArtifactRef.current);
}
sandboxArtifactRef.current = null;
setSandboxArtifact(null);
store.publish(null);
}
// 对话子组件
<PositionNodeChat
onPreviewArtifact={handlePreviewArtifact}
onThreadSnapshot={handleNodeThreadSnapshot}
/>
```
对话侧探测逻辑优先**复用** `PositionNodeChat` 内的 `latestStreamingWriteFile` / armed / baseline,而不是从零再写一套——这是最容易写错的部分。
---
## 10. 术语
| 词 | 含义 |
| --- | --- |
| armed | 本轮用户发送后,允许自动打开沙箱 |
| baseline | send 前已有 write tool call 集合,用于忽略历史产物 |
| previewKey | 一次交付身份,推荐 `messageId:toolCallId` |
| sandboxCloseKey | 关闭抑制键 + React key |
| provisional path | 无真实 path 时的临时文件名 |
| path upgrade | 同 previewKey 下临时名 → 真实 path |
| sandboxThreadStore | 流式线程外部 store,避免整页重渲染 |
| write-file: URL | 用 message/tool call id 定位流式 content 的虚拟路径 |
---
## 11. 相关提交(背景)
若需对照修复动机,可查:
- `fix(frontend): 岗位会商沙箱在 write_file 流式阶段实时打开并刷新` — 临时名打开、store、前沿节流
- `fix(frontend): 同路径二次 write_file 时重挂岗位会商沙箱` — previewKey 按 tool call
排障时优先对照本文第 5、7 节,再对照上述提交的 diff。