diff --git a/frontend-web/src/position-roundtable/README.md b/frontend-web/src/position-roundtable/README.md index 2dcdfd22..b7edf6c0 100644 --- a/frontend-web/src/position-roundtable/README.md +++ b/frontend-web/src/position-roundtable/README.md @@ -132,6 +132,9 @@ PositionRoundtablePage 页面只维护一个 `sandboxArtifact`。`PositionNodeChat` 和 `PositionBuiltinAgentChat` 通过 `onPreviewArtifact` 上报预览目标;父页面统一渲染 `ArtifactFileDetail`。 +**对接 / 重做页面 / 排障指南(接口约定、必守规则、自测与症状表):** +[`docs/沙箱打开关闭与步骤条.md`](./docs/沙箱打开关闭与步骤条.md) + 设计要点: - 文件正在由 `write_file` 流式生成时,优先显示源码;完成后交由预览组件切换 Markdown 预览; diff --git a/frontend-web/src/position-roundtable/docs/沙箱打开关闭与步骤条.md b/frontend-web/src/position-roundtable/docs/沙箱打开关闭与步骤条.md new file mode 100644 index 00000000..3a80f5fe --- /dev/null +++ b/frontend-web/src/position-roundtable/docs/沙箱打开关闭与步骤条.md @@ -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 && ( + + + +)} +``` + +流式正文依赖虚拟 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(null); +const sandboxArtifactRef = useRef(sandboxArtifact); +const closedSandboxKeyRef = useRef(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` 内的 `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。