From 920fde9cd048205fde1f31bf183fe638803fa9c7 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Wed, 27 May 2026 17:47:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/roundtable-step2-sandbox-progress.md | 120 ++++++++++------ .../components/Step2Panel.tsx | 134 +++++++++++++++++- .../hooks/useStep2Orchestration.ts | 45 ++++++ .../pages/RoundtablePlanningPage.tsx | 2 + .../src/roundtable-planning/utils/drafts.ts | 5 + 5 files changed, 263 insertions(+), 43 deletions(-) diff --git a/frontend-web/docs/roundtable-step2-sandbox-progress.md b/frontend-web/docs/roundtable-step2-sandbox-progress.md index d2c1e6a0..d3fd7fed 100644 --- a/frontend-web/docs/roundtable-step2-sandbox-progress.md +++ b/frontend-web/docs/roundtable-step2-sandbox-progress.md @@ -1,7 +1,7 @@ # 圆桌 Step 2 沙箱预览 · 开发进度 -> 最后更新:2026-05-27 -> 关联文档:`multi-agent-frontend-dev.md`(总览)、`multi-agent-backend-dev.md`(后端)、`multi-agent-api.md`(接口) +> 最后更新:2026-05-27 +> 关联文档:`multi-agent-frontend-dev.md`(总览)、`multi-agent-backend-dev.md`(后端)、`multi-agent-api.md`(接口) > 对照参考:主聊天 `/page/workspace/chats/:threadId`(`ChatBox` + `ArtifactsProvider` + `message-group.tsx`) --- @@ -12,13 +12,14 @@ | 能力 | 主聊天 | Step 2 目标 | |------|--------|-------------| -| `write_file` 流式期间自动打开右侧沙箱 | ✅ | ✅ 已实现(虚拟 URL) | -| 步骤卡里点击文件路径打开沙箱 | ✅ | ✅ 已实现 | -| 消息下方文件卡片(`present_files`)点击打开 | ✅ | ❌ 未做 | +| `write_file` 流式期间自动打开右侧沙箱 | ✅ | ✅ 虚拟 URL | +| 步骤卡里点击文件路径打开沙箱 | ✅ | ✅ | +| 消息下方文件卡片(`present_files`)点击打开 | ✅ | ✅ `PresentFilesRow` | | 沙箱打开时收起 workspace 左侧栏 | ✅(`ArtifactsProvider.select`) | ✅ Step 2 左栏联动 | | 60/40 对话 \| 产物预览分栏 | ✅ `ChatBox` | ✅ `Step2SandboxLayout` | -| 流式 MD 从 tool_call.args.content 预览 | ✅ 虚拟 URL + stub messages | ✅ `seatStubMessages` | -| 落盘后 HTTP 拉取真实文件 | ✅ artifacts API | ⚠️ 仅 `present_files` 手动路径 | +| 流式 MD 从 tool_call.args.content 预览 | ✅ 虚拟 URL + stub messages | ✅ `seatStubMessages`(双通道:`onPhaseChange` + `onUpstreamFrame`) | +| 落盘后 HTTP 拉取真实文件 | ✅ artifacts API | ✅ `present_files` 卡片走 HTTP | +| 草稿 reload 后历史文件可预览 | ✅ | ✅ `seatStubMessages` 一并入草稿 | --- @@ -42,7 +43,7 @@ | 项 | 状态 | 说明 | |----|------|------| | 路由包 `ChatRuntime` | ✅ | `WorkspaceRoutes.tsx` → `roundtable/planning` 外包 `ArtifactsProvider` | -| 60/40 分栏壳 | ✅ | 新建 `Step2SandboxLayout.tsx`,对齐 `chat-box.tsx` | +| 60/40 分栏壳 | ✅ | `Step2SandboxLayout.tsx`,对齐 `chat-box.tsx` | | `ArtifactFileDetail` / `ArtifactFileList` | ✅ | 嵌在右栏 ResizablePanel | | `ThreadContext` stub | ✅ | 最小 `BaseStream` stub,供 `useArtifactContent` 工作 | @@ -57,6 +58,33 @@ | 重置清理 | ✅ | `resetStep2State` 清空 artifacts / stub / deselect | | `present_files` 步骤点击 | ✅ | 走真实路径 `openArtifactPreview`(HTTP API) | +### 2.4 上游帧兜底注入(Phase 3) + +| 项 | 状态 | 说明 | +|----|------|------| +| `onPhaseChange` 主通道 | ✅ | `tool_calling` 阶段每帧把完整 `args` 喂进 stub;首次出现 `path` 即触发自动打开 | +| `onUpstreamFrame` 兜底通道 | ✅ | `handleUpstreamFrame` 扫描 messages-tuple 帧的 `tool_calls[]`,对 `write_file`/`str_replace` 持续 upsert `args.content`;覆盖 phase 已切走但 tool_calls 又回包的边界 | +| 双通道幂等 | ✅ | `upsertSeatToolCall` 按 `(toolName, path)` 去重,两个通道并发写入安全 | + +### 2.5 `present_files` 文件卡(Phase 4) + +| 项 | 状态 | 说明 | +|----|------|------| +| 气泡下方文件卡行 | ✅ | `Step2Message` → `collectPresentedFiles` → `PresentFilesRow` | +| 卡片样式 | ✅ | 文件名 + 扩展名徽章(FILE_EXT_LABELS),最小宽度 30/最大 50(hover 边框亮色) | +| 点击 → HTTP 预览 | ✅ | `openArtifactPreview(agentThreadId, path)`,与步骤卡 `present_files` 走同一通道 | +| 席位 thread 缺失保护 | ✅ | 没有 `agentThreadId` 时 disabled + tooltip | +| 同气泡多次 present_files | ✅ | `filepaths` 顺序去重,全部聚合到一行卡片 | + +### 2.6 草稿持久化(Phase 5) + +| 项 | 状态 | 说明 | +|----|------|------| +| `seatStubMessages` 进 `Step2Snapshot` | ✅ | `useStep2Orchestration.ts` Step2Snapshot 新增字段 | +| `seatStubMessages` 进 `DraftStep2Snapshot` | ✅ | `drafts.ts` 加 optional 字段,老草稿 reload 时回退到 `{}` | +| `hydrateFromDraft` 还原 stub | ✅ | 同步写 `seatStubMessagesRef` + state,加载草稿后点击历史 write_file 步骤可直接预览 | +| `getStep2Snapshot` 透出 stub | ✅ | `RoundtablePlanningPage.tsx` 给 `useDraftPersistence` 的 getter 加上该字段 | + **主要文件**: ``` @@ -66,29 +94,34 @@ frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx frontend-web/src/roundtable-planning/components/Step2Panel.tsx frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +frontend-web/src/roundtable-planning/utils/drafts.ts ``` -### 2.4 与主聊天对齐的核心数据流(当前实现) +### 2.7 与主聊天对齐的核心数据流 ``` special seat SSE (streamMultiAgent) - onPhaseChange("tool_calling", "write_file", args) - ↓ -upsertSeatToolCall(seatThreadId, "write_file", args) - → seatStubMessages[seatThreadId] = [{ type:"ai", tool_calls:[{ id, name, args }] }] - ↓ -buildWriteFileVirtualUrl(path, messageId, toolCallId) - → "write-file:/mnt/user-data/outputs/foo.md?message_id=...&tool_call_id=..." - ↓ -openSeatVirtualArtifact(seatThreadId, virtualUrl, auto=true) - → selectArtifact(virtualUrl) + setArtifactsOpen(true) - ↓ + ├── onPhaseChange("tool_calling", "write_file", args) + │ ↓ + │ upsertSeatToolCall(seatThreadId, "write_file", args) + │ → seatStubMessages[seatThreadId] = [{ type:"ai", tool_calls:[{ id, name, args }] }] + │ ↓ + │ buildWriteFileVirtualUrl(path, messageId, toolCallId) + │ → "write-file:/mnt/user-data/outputs/foo.md?message_id=...&tool_call_id=..." + │ ↓ + │ openSeatVirtualArtifact(seatThreadId, virtualUrl, auto=true) + │ → selectArtifact(virtualUrl) + setArtifactsOpen(true) + │ + └── onUpstreamFrame(frame) ← 兜底通道 + ↓ + handleUpstreamFrame —— 持续 upsert tool_calls 里完整的 args(不打开沙箱) + Step2SandboxLayout ThreadContext.thread.messages = seatStubMessages[activeThreadId] ArtifactFileDetail(filepath=virtualUrl, threadId=seatThreadId) ↓ -useArtifactContent → loadArtifactContentFromToolCall - → 从 thread.messages 读 tool_call.args.content(不发 HTTP) + useArtifactContent → loadArtifactContentFromToolCall + → 从 thread.messages 读 tool_call.args.content(不发 HTTP) ``` **为何不用真实 path 直接预览**:流式期间文件可能尚未落盘,直接调 `/api/threads/:id/artifacts/...` 会返回 `404 {"detail":"Not Found"}`。主聊天同样用虚拟 URL 绕过此问题。 @@ -101,21 +134,19 @@ useArtifactContent → loadArtifactContentFromToolCall | 优先级 | 项 | 状态 | 说明 | |--------|----|------|------| -| P0 | 端到端回归 | ⚠️ 待验证 | 虚拟 URL 改造后需确认:自动打开时机、MD 内容、点击步骤、左栏收起 | -| P1 | `present_files` 文件卡片 | ❌ | 主聊天在消息正文下方渲染 `RichFileCard`(`message-list-item.tsx`);Step 2 气泡仅有 Markdown 文本,**无文件卡片 UI** | -| P1 | `onUpstreamFrame` 完整消息注入 | ❌ | 当前仅从 `onPhaseChange` 的 `args` 写 stub;若 LangGraph 在 tool_call_chunks 阶段不传完整 `args.content`,预览可能空白,需从上游 messages-tuple 帧补全 | -| P2 | `str_replace` 流式编辑预览 | ❌ | 主聊天 `applyPendingTextEdit` 依赖完整 message 链;stub 未模拟 tool result / 多轮 str_replace | +| P0 | 端到端回归 | ⚠️ 待验证 | 虚拟 URL + 双通道 + 文件卡 + 草稿持久化全链路尚未在浏览器里跑一遍 | +| P2 | `str_replace` 流式编辑预览 | ❌ | 主聊天 `applyPendingTextEdit` 依赖完整 message 链;stub 未模拟 tool result / 多轮 str_replace 累加 | | P2 | 沙箱 `artifacts[]` 列表与虚拟 URL 混排 | ❌ | 虚拟 URL 不写入 `setArtifacts([])`;多文件切换、文件名下拉体验未完全对齐主聊天 | -| P2 | 草稿持久化 `seatStubMessages` | ❌ | `Step2Snapshot` / `hydrateFromDraft` 未保存 stub;加载草稿后点击历史 write_file 步骤可能无法预览 | | P3 | Step 3 与沙箱衔接 | ❌ | Step 3 仍用 `HighFidelityReport` + `listSessionArtifacts`;与 Step 2 沙箱选中态无联动 | | P3 | 文档同步 | ❌ | `multi-agent-frontend-dev.md` 仍描述旧布局(右侧栏模型选择器等),需更新 § 布局 + 沙箱章节 | ### 3.2 已知风险 / 边界 -1. **打开时机**:仅在 SSE 进入 `tool_calling` 且 `tool_calls[0].args` 为完整对象时触发(`multi-agent.ts` 刻意忽略 `tool_call_chunks` 的拼接 args)。若模型只发 chunks、迟迟不发完整 tool_calls,自动打开会延迟到 args 完整那一帧——与主聊天一致,但可能比用户预期晚。 +1. **打开时机**:仅在 SSE 进入 `tool_calling` 且 `tool_calls[0].args` 为完整对象时触发自动打开(`multi-agent.ts` 刻意忽略 `tool_call_chunks` 的拼接 args)。若模型只发 chunks、迟迟不发完整 tool_calls,自动打开会延迟到 args 完整那一帧——与主聊天一致。 2. **多 write_file 同气泡**:`upsertSeatToolCall` 按 `(toolName, path)` 幂等;同 path 多次 write 会更新 args,不会新建多条 stub tool_call。 -3. **leader 席位**:当前仅 special 流绑定 `agentThreadId`;总控若直接 write_file,需单独接 thread id。 -4. **调试日志**:控制台 `[roundtable-sandbox]` 前缀;生产环境可考虑加 `window.__ROUNDTABLE_SANDBOX_DEBUG__` 门控。 +3. **leader 席位**:当前仅 special 流绑定 `agentThreadId` 并接 `onUpstreamFrame`;总控若直接 write_file,需单独接 thread id 与帧回调。 +4. **`present_files` 文件大小**:流式期间 backend 尚未给出真实大小;卡片只显示扩展名徽章,不显示 size。落盘后用户点开走 HTTP API 自然能看到完整内容。 +5. **调试日志**:控制台 `[roundtable-sandbox]` 前缀;生产环境可考虑加 `window.__ROUNDTABLE_SANDBOX_DEBUG__` 门控。 --- @@ -124,10 +155,10 @@ useArtifactContent → loadArtifactContentFromToolCall | 阶段 | 内容 | 状态 | |------|------|------| | Phase 1 | `ChatRuntime` + 分栏壳 + 侧栏联动 | ✅ 完成 | -| Phase 2 | `write_file` 自动/点击打开 + 虚拟 URL 预览 | ✅ 代码完成,⚠️ 待 E2E 验证 | -| Phase 3 | `onUpstreamFrame` 注入完整 LangGraph messages | ❌ 未开始 | -| Phase 4 | `present_files` 卡片 + Step 3 文件区衔接 | ❌ 未开始 | -| Phase 5 | 草稿 stub 持久化 + 文档更新 | ❌ 未开始 | +| Phase 2 | `write_file` 自动/点击打开 + 虚拟 URL 预览 | ✅ 完成 | +| Phase 3 | `onUpstreamFrame` 注入完整 LangGraph messages | ✅ 完成(双通道兜底) | +| Phase 4 | `present_files` 卡片 + Step 3 文件区衔接 | ⚠️ 卡片完成;Step 3 衔接遗留 | +| Phase 5 | 草稿 stub 持久化 + 文档更新 | ✅ stub 完成;本文档同步更新 | --- @@ -137,12 +168,13 @@ useArtifactContent → loadArtifactContentFromToolCall - [ ] 子智能体 `write_file` 进入 `tool_calling` 后,右侧沙箱 **自动** 打开(约 100ms 延迟,对齐主聊天) - [ ] 沙箱标题为文件名(如 `sample-paper.md`),**不是** `{"detail":"Not Found"}` -- [ ] 流式期间 MD 内容随 `args.content` 更新(需 stub 收到完整 args) +- [ ] 流式期间 MD 内容随 `args.content` 更新(双通道:phase 切换帧 + 兜底帧) - [ ] 点击 ChainOfThought 步骤「写入文件」/ 路径 chip → 手动打开同一文件 +- [ ] `present_files` 后气泡下方出现文件卡(带扩展名徽章),点击 → HTTP 预览 - [ ] 沙箱打开时左侧「意图理解」栏 **自动收起** - [ ] 关闭沙箱(X)后对话区恢复全宽 -- [ ] `present_files` 步骤点击 → HTTP 预览(文件已落盘时) -- [ ] 「新建任务」后 artifacts / 沙箱状态清空 +- [ ] 「新建任务」后 artifacts / 沙箱 / `seatStubMessages` 全部清空 +- [ ] 「保存草稿」→「加载草稿」后,点击历史 `write_file` 步骤可立即预览(stub 已还原) - [ ] 与主聊天 `/page/workspace/chats/:threadId` 对比:同一 write_file 行为一致 **调试**:DevTools Console 过滤 `[roundtable-sandbox]` 或 `[stream:special:...]`。 @@ -154,18 +186,22 @@ useArtifactContent → loadArtifactContentFromToolCall | 用途 | 路径 | |------|------| | 主聊天 write_file 虚拟 URL + 自动打开 | `src/components/workspace/messages/message-group.tsx` | +| 主聊天 `RichFileCard`(present_files) | `src/components/workspace/messages/message-list-item.tsx` | | 主聊天 60/40 分栏 | `src/components/workspace/chats/chat-box.tsx` | | 虚拟 URL 内容加载 | `src/core/artifacts/loader.ts` → `loadArtifactContentFromToolCall` | | Artifacts 上下文 | `src/components/workspace/artifacts/context.tsx` | -| Step 2 编排 + 沙箱状态 | `src/roundtable-planning/hooks/useStep2Orchestration.ts` | +| Step 2 编排 + 沙箱状态 + 草稿快照 | `src/roundtable-planning/hooks/useStep2Orchestration.ts` | | Step 2 沙箱布局 | `src/roundtable-planning/components/Step2SandboxLayout.tsx` | -| SSE phase 推断 | `src/roundtable-planning/api/multi-agent.ts` → `setPhase` | +| Step 2 文件卡 + 步骤渲染 | `src/roundtable-planning/components/Step2Panel.tsx` | +| SSE phase 推断 + `onUpstreamFrame` | `src/roundtable-planning/api/multi-agent.ts` → `setPhase` | | 主页面侧栏 + 联动 | `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | +| 草稿持久化 | `src/roundtable-planning/utils/drafts.ts`、`hooks/useDraftPersistence.ts` | --- ## 7. 下一步建议(给接续开发者) -1. **先跑通 P0 测试清单**,确认虚拟 URL 改造后无回归;若内容空白,优先做 Phase 3(`onUpstreamFrame` 解析 messages-tuple 写入 stub)。 -2. **Phase 4**:在 `Step2Message` / 气泡 Markdown 渲染后检测 `present_files` 或解析 assistant 正文里的文件引用,复用 `RichFileCard` 或抽轻量 `RoundtableFileCard`。 -3. **更新** `multi-agent-frontend-dev.md`:Step 2 布局、沙箱架构、`seatStubMessages` 数据流,避免与本文档重复——可在总览文档加一节「见 `roundtable-step2-sandbox-progress.md`」。 +1. **先跑通 P0 测试清单**,确认 Phase 3/4/5 改造后无回归;重点观察双通道并发写入 stub 时 args 是否被旧帧覆盖(理论上 LangGraph 完整 tool_calls 是单调累积的,应该没问题)。 +2. **Phase 4 收尾**:把 Step 3 的 `listSessionArtifacts` / `HighFidelityReport` 接进沙箱选中态——Step 3 进入时复用 Step 2 的 `ArtifactsProvider`,让用户在 Step 3 也能预览 Step 2 留下的产物。 +3. **Phase 2 边界**:`str_replace` 多轮编辑的 stub 模拟(追加 tool result 消息让 `applyPendingTextEdit` 能 replay edit 序列),优先级低。 +4. **更新** `multi-agent-frontend-dev.md`:Step 2 布局、沙箱架构、`seatStubMessages` 数据流,避免与本文档重复——可在总览文档加一节「见 `roundtable-step2-sandbox-progress.md`」。 diff --git a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx index 425808dd..557643c5 100644 --- a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx @@ -18,6 +18,7 @@ import { useRef } from "react"; import { Cpu, + FileIcon, HelpCircle, Loader2, Pause, @@ -76,6 +77,8 @@ export interface Step2PanelProps { dialogue: Step2Dialogue, step: NonNullable[number], ) => void; + /** 主聊天里 `present_files` 会在气泡下方渲染卡片;Step 2 沿用同一交互。 */ + openArtifactPreview: (threadId: string, path: string) => void; // ─── Setters ────────────────────────────────────────────────── setIsRoundTableRunning: React.Dispatch>; setClarificationDraft: React.Dispatch>; @@ -112,6 +115,7 @@ export function Step2Panel(props: Step2PanelProps) { activeArtifactThreadId, seatStubMessages, openArtifactFromStep, + openArtifactPreview, setIsRoundTableRunning, setClarificationDraft, setCustomAgentInput, @@ -231,6 +235,11 @@ export function Step2Panel(props: Step2PanelProps) { key={msg.id} msg={msg} onArtifactStepClick={(step) => openArtifactFromStep(msg, step)} + onFileCardClick={(path) => { + if (msg.agentThreadId) { + openArtifactPreview(msg.agentThreadId, path); + } + }} /> ))} @@ -377,9 +386,15 @@ interface Step2MessageProps { onArtifactStepClick?: ( step: NonNullable[number], ) => void; + /** 点击气泡下方 `present_files` 文件卡时打开沙箱(走真实 HTTP 路径)。 */ + onFileCardClick?: (path: string) => void; } -function Step2Message({ msg, onArtifactStepClick }: Step2MessageProps) { +function Step2Message({ + msg, + onArtifactStepClick, + onFileCardClick, +}: Step2MessageProps) { const isInjected = msg.sender === "用户 (插话干预)"; const isStreaming = msg.streaming === true; // 抽出 `...` 作 reasoning;剩下的是要渲染的正文。 @@ -388,6 +403,7 @@ function Step2Message({ msg, onArtifactStepClick }: Step2MessageProps) { const rawContent = msg.content ?? ""; const { content, reasoning } = splitInlineReasoning(rawContent); const hasContent = content.length > 0; + const presentedFiles = collectPresentedFiles(msg.steps); return ( {content} {isStreaming && } + + {presentedFiles.length > 0 && ( + + )} ); } + +/** + * 扫描所有 `tool_calling:present_files` 步骤里的 `filepaths`,按出现顺序去重。 + * 同一气泡可能多次 present_files(总控分批交付),都聚合到一行卡片里。 + */ +function collectPresentedFiles( + steps: Step2Dialogue["steps"], +): string[] { + if (!Array.isArray(steps) || steps.length === 0) return []; + const out: string[] = []; + const seen = new Set(); + for (const step of steps) { + if (step.key !== "tool_calling:present_files") continue; + const fps = step.args?.filepaths; + if (!Array.isArray(fps)) continue; + for (const fp of fps) { + if (typeof fp !== "string" || !fp || seen.has(fp)) continue; + seen.add(fp); + out.push(fp); + } + } + return out; +} + +function PresentFilesRow({ + files, + onClick, + disabled, +}: { + files: string[]; + onClick?: (path: string) => void; + disabled: boolean; +}) { + return ( +
+ {files.map((path) => ( + onClick?.(path)} + /> + ))} +
+ ); +} + +const FILE_EXT_LABELS: Record = { + md: "Markdown", + json: "JSON", + csv: "CSV", + txt: "TXT", + py: "Python", + js: "JS", + ts: "TS", + tsx: "TSX", + jsx: "JSX", + html: "HTML", + css: "CSS", + xml: "XML", + yaml: "YAML", + yml: "YAML", + pdf: "PDF", + png: "PNG", + jpg: "JPG", + jpeg: "JPEG", + svg: "SVG", + zip: "ZIP", +}; + +function PresentFileCard({ + path, + disabled, + onClick, +}: { + path: string; + disabled: boolean; + onClick: () => void; +}) { + const filename = path.split("/").pop() ?? path; + const ext = filename.split(".").pop()?.toLowerCase() ?? ""; + const typeLabel = FILE_EXT_LABELS[ext] ?? (ext.toUpperCase() || "FILE"); + return ( + + ); +} diff --git a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts index a3b45de2..7758b912 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts @@ -91,6 +91,12 @@ export type Step2Snapshot = { hasConsensus: boolean; consensusPercentage: number; budgetLimit: number; + /** + * 每个 seat thread 的 stub ai messages —— 加载草稿后点击历史 write_file 步骤, + * 沙箱要能复读 args.content。不存这部分 stub 的话,虚拟 URL 解析会拿不到内容。 + * 老草稿可能没有这字段,反序列化时按空对象兜底。 + */ + seatStubMessages: Record; }; /** @@ -514,6 +520,39 @@ export function useStep2Orchestration( ], ); + /** + * 兜底:从 LangGraph messages-tuple 帧里扫描完整 tool_calls,把 args 持续灌进 stub。 + * 与 `handleArtifactToolPhase` 相比: + * - 后者依赖 `onPhaseChange` 在 phase==="tool_calling" 时回调,首次出现 path 即触发自动打开; + * - 前者覆盖「phase 已切到 tool_result / streaming 后,后续帧又带回完整 tool_calls」的边界, + * 保证沙箱 MD 始终拿到 args.content 的最新版本(LangGraph 在收口阶段仍可能再发一次)。 + * 不做自动打开 —— 那是 handleArtifactToolPhase 的职责;这里只做 args 同步。 + */ + const handleUpstreamFrame = useCallback( + (frame: unknown, seatThreadId: string | undefined) => { + if (!seatThreadId) return; + if (!Array.isArray(frame) || frame.length === 0) return; + const chunk = frame[0]; + if (!chunk || typeof chunk !== "object") return; + const obj = chunk as Record; + const toolCalls = obj.tool_calls; + if (!Array.isArray(toolCalls) || toolCalls.length === 0) return; + for (const tc of toolCalls) { + if (!tc || typeof tc !== "object") continue; + const tcObj = tc as Record; + const name = typeof tcObj.name === "string" ? tcObj.name : ""; + if (name !== "write_file" && name !== "str_replace") continue; + const tcArgs = tcObj.args; + if (!tcArgs || typeof tcArgs !== "object") continue; + const argsObj = tcArgs as Record; + const path = argsObj.path; + if (typeof path !== "string" || !path) continue; + upsertSeatToolCall(seatThreadId, name, argsObj); + } + }, + [upsertSeatToolCall], + ); + // ─── Dialogue / Speaker helpers ──────────────────────────────────── const appendDialogue = useCallback( (card: { @@ -890,6 +929,8 @@ export function useStep2Orchestration( seatThreadId, ); }, + onUpstreamFrame: (frame) => + handleUpstreamFrame(frame, seatThreadId), }); if (orchestrationCancelRef.current) { finalizeDialogue( @@ -958,6 +999,7 @@ export function useStep2Orchestration( clearAllStreamingSpeakers, triggerToast, handleArtifactToolPhase, + handleUpstreamFrame, resolveSeatThreadId, ], ); @@ -1236,6 +1278,9 @@ export function useStep2Orchestration( setHasConsensus(snapshot.hasConsensus); setConsensusPercentage(snapshot.consensusPercentage); setBudgetLimit(snapshot.budgetLimit); + const restoredStubs = snapshot.seatStubMessages ?? {}; + seatStubMessagesRef.current = restoredStubs; + setSeatStubMessages(restoredStubs); // 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上, // 调用方需要的话再 resetInitGate()。 step2InitRef.current = true; diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 63f8e4e1..0b2f6842 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -194,6 +194,7 @@ export default function RoundtablePlanningPage() { hasConsensus: step2.hasConsensus, consensusPercentage: step2.consensusPercentage, budgetLimit: step2.budgetLimit, + seatStubMessages: step2.seatStubMessages, }), hydrateStep1: step1.hydrateFromDraft, hydrateStep2: step2.hydrateFromDraft, @@ -494,6 +495,7 @@ export default function RoundtablePlanningPage() { activeArtifactThreadId={step2.activeArtifactThreadId} seatStubMessages={step2.seatStubMessages} openArtifactFromStep={step2.openArtifactFromStep} + openArtifactPreview={step2.openArtifactPreview} setIsRoundTableRunning={step2.setIsRoundTableRunning} setClarificationDraft={step2.setClarificationDraft} setCustomAgentInput={step2.setCustomAgentInput} diff --git a/frontend-web/src/roundtable-planning/utils/drafts.ts b/frontend-web/src/roundtable-planning/utils/drafts.ts index ce0b97c2..bcb4a6f3 100644 --- a/frontend-web/src/roundtable-planning/utils/drafts.ts +++ b/frontend-web/src/roundtable-planning/utils/drafts.ts @@ -35,6 +35,11 @@ export interface DraftStep2Snapshot { hasConsensus: boolean; consensusPercentage: number; budgetLimit: number; + /** + * Per-seat stub AI messages used by the sandbox `write-file:` virtual URL + * loader. Older drafts won't have this — callers should default to `{}`. + */ + seatStubMessages?: Record; } export interface DraftRecord {