diff --git a/frontend-web/docs/roundtable-step2-sandbox-progress.md b/frontend-web/docs/roundtable-step2-sandbox-progress.md new file mode 100644 index 00000000..d2c1e6a0 --- /dev/null +++ b/frontend-web/docs/roundtable-step2-sandbox-progress.md @@ -0,0 +1,171 @@ +# 圆桌 Step 2 沙箱预览 · 开发进度 + +> 最后更新: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`) + +--- + +## 1. 目标 + +在 Step 2「多智能体圆桌研讨」中,当子智能体调用 `write_file` / `str_replace` / `present_files` 时,**行为与主聊天一致**: + +| 能力 | 主聊天 | Step 2 目标 | +|------|--------|-------------| +| `write_file` 流式期间自动打开右侧沙箱 | ✅ | ✅ 已实现(虚拟 URL) | +| 步骤卡里点击文件路径打开沙箱 | ✅ | ✅ 已实现 | +| 消息下方文件卡片(`present_files`)点击打开 | ✅ | ❌ 未做 | +| 沙箱打开时收起 workspace 左侧栏 | ✅(`ArtifactsProvider.select`) | ✅ Step 2 左栏联动 | +| 60/40 对话 \| 产物预览分栏 | ✅ `ChatBox` | ✅ `Step2SandboxLayout` | +| 流式 MD 从 tool_call.args.content 预览 | ✅ 虚拟 URL + stub messages | ✅ `seatStubMessages` | +| 落盘后 HTTP 拉取真实文件 | ✅ artifacts API | ⚠️ 仅 `present_files` 手动路径 | + +--- + +## 2. 已完成 + +### 2.1 Step 2 布局调整 + +| 项 | 状态 | 说明 | +|----|------|------| +| 推理模型下拉 | ✅ | 从右侧栏迁至 Step 2 顶栏「多智能体圆桌会商中心」右侧 | +| 意图理解 | ✅ | 移至左侧栏顶部,支持展开/收起;收起时仅显示「目标」 | +| 任务概览 | ✅ | 放在「人工参与」下方 | +| 右侧栏 Step 2/3 隐藏 | ✅ | `RightSidebar` 仅在 `currentStep === 1` 渲染 | +| 左侧栏整体收起 | ✅ | 宽度 56px;展开按钮在顶部;研讨中角色 ring + ping 动画 | +| 沙箱打开 → 左栏收起 | ✅ | `Step2SandboxSidebarSync` 监听 `useArtifacts().open` | + +**主要文件**:`RoundtablePlanningPage.tsx`(`step2SidebarExpanded`、`LeftSidebar`、`Step2SandboxSidebarSync`) + +### 2.2 沙箱基础设施(Phase 1) + +| 项 | 状态 | 说明 | +|----|------|------| +| 路由包 `ChatRuntime` | ✅ | `WorkspaceRoutes.tsx` → `roundtable/planning` 外包 `ArtifactsProvider` | +| 60/40 分栏壳 | ✅ | 新建 `Step2SandboxLayout.tsx`,对齐 `chat-box.tsx` | +| `ArtifactFileDetail` / `ArtifactFileList` | ✅ | 嵌在右栏 ResizablePanel | +| `ThreadContext` stub | ✅ | 最小 `BaseStream` stub,供 `useArtifactContent` 工作 | + +### 2.3 沙箱打开逻辑(Phase 2) + +| 项 | 状态 | 说明 | +|----|------|------| +| `write_file` 自动打开 | ✅ | `handleArtifactToolPhase` 在 `tool_calling` + 完整 `args.path` 时触发 | +| 步骤卡点击打开 | ✅ | `MessageStepsCard` → `openArtifactFromStep` | +| 虚拟 URL 机制 | ✅ | `write-file:?message_id=...&tool_call_id=...`(对齐主聊天) | +| Stub messages 注入 | ✅ | `seatStubMessages` + `upsertSeatToolCall` → `loadArtifactContentFromToolCall` | +| 重置清理 | ✅ | `resetStep2State` 清空 artifacts / stub / deselect | +| `present_files` 步骤点击 | ✅ | 走真实路径 `openArtifactPreview`(HTTP API) | + +**主要文件**: + +``` +frontend-web/src/pages/WorkspaceRoutes.tsx +frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +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 +``` + +### 2.4 与主聊天对齐的核心数据流(当前实现) + +``` +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) + ↓ +Step2SandboxLayout + ThreadContext.thread.messages = seatStubMessages[activeThreadId] + ArtifactFileDetail(filepath=virtualUrl, threadId=seatThreadId) + ↓ +useArtifactContent → loadArtifactContentFromToolCall + → 从 thread.messages 读 tool_call.args.content(不发 HTTP) +``` + +**为何不用真实 path 直接预览**:流式期间文件可能尚未落盘,直接调 `/api/threads/:id/artifacts/...` 会返回 `404 {"detail":"Not Found"}`。主聊天同样用虚拟 URL 绕过此问题。 + +--- + +## 3. 未完成 / 待验证 + +### 3.1 功能缺口(按优先级) + +| 优先级 | 项 | 状态 | 说明 | +|--------|----|------|------| +| 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 | +| 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 完整那一帧——与主聊天一致,但可能比用户预期晚。 +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__` 门控。 + +--- + +## 4. 分阶段计划(原始规划 vs 现状) + +| 阶段 | 内容 | 状态 | +|------|------|------| +| Phase 1 | `ChatRuntime` + 分栏壳 + 侧栏联动 | ✅ 完成 | +| Phase 2 | `write_file` 自动/点击打开 + 虚拟 URL 预览 | ✅ 代码完成,⚠️ 待 E2E 验证 | +| Phase 3 | `onUpstreamFrame` 注入完整 LangGraph messages | ❌ 未开始 | +| Phase 4 | `present_files` 卡片 + Step 3 文件区衔接 | ❌ 未开始 | +| Phase 5 | 草稿 stub 持久化 + 文档更新 | ❌ 未开始 | + +--- + +## 5. 测试清单 + +在 `/page/workspace/roundtable/planning` Step 2 下逐项勾选: + +- [ ] 子智能体 `write_file` 进入 `tool_calling` 后,右侧沙箱 **自动** 打开(约 100ms 延迟,对齐主聊天) +- [ ] 沙箱标题为文件名(如 `sample-paper.md`),**不是** `{"detail":"Not Found"}` +- [ ] 流式期间 MD 内容随 `args.content` 更新(需 stub 收到完整 args) +- [ ] 点击 ChainOfThought 步骤「写入文件」/ 路径 chip → 手动打开同一文件 +- [ ] 沙箱打开时左侧「意图理解」栏 **自动收起** +- [ ] 关闭沙箱(X)后对话区恢复全宽 +- [ ] `present_files` 步骤点击 → HTTP 预览(文件已落盘时) +- [ ] 「新建任务」后 artifacts / 沙箱状态清空 +- [ ] 与主聊天 `/page/workspace/chats/:threadId` 对比:同一 write_file 行为一致 + +**调试**:DevTools Console 过滤 `[roundtable-sandbox]` 或 `[stream:special:...]`。 + +--- + +## 6. 关键代码索引 + +| 用途 | 路径 | +|------|------| +| 主聊天 write_file 虚拟 URL + 自动打开 | `src/components/workspace/messages/message-group.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/components/Step2SandboxLayout.tsx` | +| SSE phase 推断 | `src/roundtable-planning/api/multi-agent.ts` → `setPhase` | +| 主页面侧栏 + 联动 | `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | + +--- + +## 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`」。 diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index e232b5b1..9c111b62 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -220,7 +220,9 @@ export default function WorkspaceRoutes({ path="roundtable/planning" element={ - + + + } /> diff --git a/frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx b/frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx index e99f8330..faffcc91 100644 --- a/frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx +++ b/frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx @@ -44,6 +44,8 @@ export interface MessageStepsCardProps { /** 是否流式中 —— 决定最后一步显示 `active` 还是 `complete`。 */ streaming?: boolean; className?: string; + /** 点击 write_file / present_files 等产物相关步骤时打开沙箱。 */ + onArtifactStepClick?: (step: BubbleStep) => void; } /** 工具调用阶段的 step 才进入折叠卡;非工具步骤(connecting/streaming/tool_result)归到「思考」段。 */ @@ -51,7 +53,12 @@ function isToolStep(step: BubbleStep): boolean { return step.key.startsWith("tool_calling:") && step.key.length > "tool_calling:".length; } -export function MessageStepsCard({ steps, streaming, className }: MessageStepsCardProps) { +export function MessageStepsCard({ + steps, + streaming, + className, + onArtifactStepClick, +}: MessageStepsCardProps) { // 把步骤拆成「工具调用」与「思考过程」两类 —— 与主聊天 MessageGroup 的 `lastToolCallStep` / // `lastReasoningStep` 划分对齐。 const toolSteps = steps.filter(isToolStep); @@ -119,11 +126,17 @@ export function MessageStepsCard({ steps, streaming, className }: MessageStepsCa {showAbove && priorToolSteps.map((s) => ( - + ))} )} @@ -141,16 +154,30 @@ export function MessageStepsCard({ steps, streaming, className }: MessageStepsCa function ToolStep({ step, status, + onArtifactStepClick, }: { step: BubbleStep; status: "complete" | "active"; + onArtifactStepClick?: (step: BubbleStep) => void; }) { const display = getStepDisplay(step.key, step.label, step.args); const argNode = renderStepArgs(step.key, step.args); + const [, toolName] = step.key.split(":"); + const isArtifactStep = + toolName === "write_file" || + toolName === "str_replace" || + toolName === "present_files"; + return ( onArtifactStepClick(step) + : undefined + } label={ {display.label} diff --git a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx index b872c93c..c347246d 100644 --- a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx @@ -27,6 +27,7 @@ import { import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer"; import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; +import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; import { getAvatar } from "@/roundtable-planning/components/Avatars"; import { MarkdownContent, @@ -69,6 +70,12 @@ export interface Step2PanelProps { availableModels: ModelOption[]; modelsLoading: boolean; setSelectedModel: React.Dispatch>; + activeArtifactThreadId: string | null; + seatStubMessages: Record; + openArtifactFromStep: ( + dialogue: Step2Dialogue, + step: NonNullable[number], + ) => void; // ─── Setters ────────────────────────────────────────────────── setIsRoundTableRunning: React.Dispatch>; setClarificationDraft: React.Dispatch>; @@ -102,6 +109,9 @@ export function Step2Panel(props: Step2PanelProps) { availableModels, modelsLoading, setSelectedModel, + activeArtifactThreadId, + seatStubMessages, + openArtifactFromStep, setIsRoundTableRunning, setClarificationDraft, setCustomAgentInput, @@ -118,8 +128,12 @@ export function Step2Panel(props: Step2PanelProps) { const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } = useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]); - return ( -
+ const isSandboxStreaming = + isOrchestrating || + step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true); + + const chatBody = ( + <> {/* Headline */}
@@ -213,7 +227,11 @@ export function Step2Panel(props: Step2PanelProps) {
)} {step2RoundtableDialogues.map((msg) => ( - + openArtifactFromStep(msg, step)} + /> ))}
{!step2AtBottom && } @@ -328,7 +346,21 @@ export function Step2Panel(props: Step2PanelProps) { 达成共识生成全案成果 >
- + + ); + + const activeSeatMessages = activeArtifactThreadId + ? seatStubMessages[activeArtifactThreadId] + : undefined; + + return ( + +
{chatBody}
+
); } @@ -342,9 +374,12 @@ export function Step2Panel(props: Step2PanelProps) { */ interface Step2MessageProps { msg: Step2Dialogue; + onArtifactStepClick?: ( + step: NonNullable[number], + ) => void; } -function Step2Message({ msg }: Step2MessageProps) { +function Step2Message({ msg, onArtifactStepClick }: Step2MessageProps) { const isInjected = msg.sender === "用户 (插话干预)"; const isStreaming = msg.streaming === true; // 抽出 `...` 作 reasoning;剩下的是要渲染的正文。 @@ -367,7 +402,11 @@ function Step2Message({ msg }: Step2MessageProps) { } > {Array.isArray(msg.steps) && msg.steps.length > 0 && ( - + )} {reasoning && ( {reasoning} diff --git a/frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx b/frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx new file mode 100644 index 00000000..5998cbf6 --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx @@ -0,0 +1,163 @@ +/** + * Step 2 沙箱分栏 —— 对齐主聊天 `ChatBox` 的 60/40 对话 | 产物预览布局。 + * + * 圆桌 Step 2 走自定义 SSE,没有 LangGraph SDK thread;这里用最小 ThreadContext stub + * 供 `ArtifactFileDetail` / `useArtifactContent` 工作(真实 path 走 artifacts API 轮询)。 + */ +import type { BaseStream } from "@langchain/langgraph-sdk/react"; +import { FilesIcon, XIcon } from "lucide-react"; +import { useEffect, useMemo, useRef } from "react"; +import type { GroupImperativeHandle } from "react-resizable-panels"; + +import { ConversationEmptyState } from "@/components/ai-elements/conversation"; +import { Button } from "@/components/ui/button"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { + ArtifactFileDetail, + ArtifactFileList, + useArtifacts, +} from "@/components/workspace/artifacts"; +import { ThreadContext } from "@/components/workspace/messages/context"; +import type { AgentThreadState } from "@/core/threads"; +import { cn } from "@/lib/utils"; + +const CLOSE_MODE = { chat: 100, artifacts: 0 }; +const OPEN_MODE = { chat: 60, artifacts: 40 }; + +interface Step2SandboxLayoutProps { + children: React.ReactNode; + /** 当前预览文件所属的席位 thread id。 */ + activeThreadId: string | null; + /** 是否有流式 run 进行中 —— 开启 artifacts API 轮询。 */ + isStreaming?: boolean; + /** + * 当前 active 席位的 stub ai messages —— 让 useArtifactContent 通过虚拟 URL + * `write-file:...?message_id=...&tool_call_id=...` 直接读 tool_call.args.content, + * 对齐主聊天 chats/:threadId 的实时预览体验。 + */ + seatMessages?: unknown[]; +} + +export function Step2SandboxLayout({ + children, + activeThreadId, + isStreaming = false, + seatMessages, +}: Step2SandboxLayoutProps) { + const layoutRef = useRef(null); + const { + artifacts, + open: artifactsOpen, + setOpen: setArtifactsOpen, + selectedArtifact, + } = useArtifacts(); + + const artifactPanelOpen = artifactsOpen; + + useEffect(() => { + if (layoutRef.current) { + layoutRef.current.setLayout( + artifactPanelOpen ? OPEN_MODE : CLOSE_MODE, + ); + } + }, [artifactPanelOpen]); + + const threadStub = useMemo(() => { + const messages = seatMessages ?? []; + return { + messages, + isLoading: isStreaming, + isThreadLoading: false, + values: { + title: "", + messages, + artifacts, + }, + } as unknown as BaseStream; + }, [artifacts, isStreaming, seatMessages]); + + const previewThreadId = activeThreadId ?? ""; + + return ( + + + + {children} + + + +
+ {selectedArtifact && previewThreadId ? ( + + ) : ( +
+
+ +
+ {artifacts.length === 0 ? ( + } + title="未选择文件" + description="智能体写入文件后将在此预览" + /> + ) : ( +
+
+

产出文件

+
+
+ {previewThreadId ? ( + + ) : null} +
+
+ )} +
+ )} +
+
+
+
+ ); +} diff --git a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts index dc654013..4a661971 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts @@ -16,6 +16,7 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useArtifacts } from "@/components/workspace/artifacts"; import { initMultiAgent, streamMultiAgent, @@ -44,6 +45,8 @@ export type Step2Dialogue = { time: string; confidence: string; streaming?: boolean; + /** special 席位气泡对应的 LangGraph thread,用于沙箱预览。 */ + agentThreadId?: string; steps?: Array<{ id: string; key: string; @@ -53,6 +56,24 @@ export type Step2Dialogue = { }>; }; +/** + * Stub AI message —— 用于把 special seat SSE 拿到的工具调用注入到 ThreadContext + * 的 `thread.messages` 里,让 `useArtifactContent` 在沙箱里通过虚拟 URL + * `write-file:?message_id=...&tool_call_id=...` 直接读 `args.content`, + * 完全对齐主聊天 `chats/:threadId` 的 write_file 实时预览体验。 + */ +export interface StubToolCall { + id: string; + name: string; + args: Record; +} +export interface StubAiMessage { + id: string; + type: "ai"; + content: string; + tool_calls: StubToolCall[]; +} + /** Persona 抽屉的 per-seat 覆盖(prompt / temp 仅前端记录,model 真实生效)。 */ export type AgentConfig = { model: string; @@ -158,6 +179,24 @@ export interface UseStep2OrchestrationReturn { hydrateFromDraft: (snapshot: Step2Snapshot) => void; /** 「新建任务」清空 Step 2 数据(threadIds / dialogues / 共识等)。 */ resetStep2State: () => void; + /** 当前沙箱预览绑定的席位 thread id。 */ + activeArtifactThreadId: string | null; + /** + * 各 special seat 的 stub messages —— 注入到沙箱 ThreadContext.thread.messages, + * 让 `useArtifactContent` 通过虚拟 URL 直接读 `args.content`(对齐主聊天)。 + */ + seatStubMessages: Record; + /** 从工具步骤或流式 write_file 打开沙箱预览。 */ + openArtifactPreview: ( + threadId: string, + path: string, + options?: { auto?: boolean }, + ) => void; + /** 点击气泡内工具步骤时打开沙箱。 */ + openArtifactFromStep: ( + dialogue: Step2Dialogue, + step: NonNullable[number], + ) => void; } export function useStep2Orchestration( @@ -172,7 +211,26 @@ export function useStep2Orchestration( loadedFromDraftRef, } = options; + const { + artifacts, + setArtifacts, + select: selectArtifact, + setOpen: setArtifactsOpen, + deselect: deselectArtifact, + } = useArtifacts(); + const artifactPathsRef = useRef([]); + useEffect(() => { + artifactPathsRef.current = artifacts; + }, [artifacts]); + // ─── State ───────────────────────────────────────────────────────── + const [activeArtifactThreadId, setActiveArtifactThreadId] = useState( + null, + ); + const [seatStubMessages, setSeatStubMessages] = useState< + Record + >({}); + const seatStubMessagesRef = useRef>({}); const [selectedAgents, setSelectedAgents] = useState( DEFAULT_SELECTED_AGENTS, ); @@ -251,6 +309,208 @@ export function useStep2Orchestration( isOrchestratingRef.current = isOrchestrating; }, [isOrchestrating]); + const resolveSeatThreadId = useCallback((ids: ThreadIdMap, agentName: string) => { + return ids[agentName] ?? ids[agentName.toLowerCase()]; + }, []); + + /** + * 把工具调用幂等地写进 seat 的 stub ai message —— **同 (toolName, path)** 视为同一次 + * 调用,后续帧只更新 args(LangGraph 流式期间会多次发送完整 tool_calls 覆盖 args)。 + * 返回的 `messageId`/`toolCallId` 用于拼出 `write-file:` 虚拟 URL,沙箱 useArtifactContent + * 据此从 thread.messages 取出 args.content,完全绕过 HTTP。 + */ + const upsertSeatToolCall = useCallback( + ( + seatThreadId: string, + toolName: string, + args: Record, + ): { messageId: string; toolCallId: string } => { + const path = typeof args.path === "string" ? args.path : ""; + const current = seatStubMessagesRef.current[seatThreadId] ?? []; + const existing = current[0]; + const baseMsg: StubAiMessage = existing ?? { + id: `roundtable-stub-${seatThreadId}`, + type: "ai", + content: "", + tool_calls: [], + }; + const idx = baseMsg.tool_calls.findIndex( + (tc) => + tc.name === toolName && + String((tc.args as { path?: unknown })?.path ?? "") === path, + ); + const toolCallId = + idx >= 0 + ? baseMsg.tool_calls[idx].id + : `tc-${seatThreadId}-${baseMsg.tool_calls.length}`; + const nextToolCall: StubToolCall = { + id: toolCallId, + name: toolName, + args, + }; + const nextToolCalls = + idx >= 0 + ? baseMsg.tool_calls.map((tc, i) => + i === idx ? nextToolCall : tc, + ) + : [...baseMsg.tool_calls, nextToolCall]; + const nextAiMsg: StubAiMessage = { + ...baseMsg, + tool_calls: nextToolCalls, + }; + const nextMap = { + ...seatStubMessagesRef.current, + [seatThreadId]: [nextAiMsg], + }; + seatStubMessagesRef.current = nextMap; + setSeatStubMessages(nextMap); + return { messageId: nextAiMsg.id, toolCallId }; + }, + [], + ); + + /** + * 用「虚拟 URL」(write-file: scheme)打开沙箱 —— 内容来自 stub messages 的 args.content, + * 不污染 `artifacts[]` 真实路径列表(列表后续可由 present_files / 显式 setArtifacts 填充)。 + */ + const openSeatVirtualArtifact = useCallback( + (seatThreadId: string, virtualUrl: string, auto: boolean) => { + setActiveArtifactThreadId(seatThreadId); + const open = () => { + selectArtifact(virtualUrl, auto); + setArtifactsOpen(true); + }; + if (auto) { + window.setTimeout(open, 100); + } else { + open(); + } + }, + [selectArtifact, setArtifactsOpen], + ); + + const buildWriteFileVirtualUrl = useCallback( + (path: string, messageId: string, toolCallId: string) => + new URL( + `write-file:${path}?message_id=${encodeURIComponent( + messageId, + )}&tool_call_id=${encodeURIComponent(toolCallId)}`, + ).toString(), + [], + ); + + /** + * `openArtifactPreview` —— 走真实路径(present_files 等已落盘场景),走 HTTP API 加载。 + * 沙箱 write_file 实时预览请用 `openSeatVirtualArtifact`。 + */ + const openArtifactPreview = useCallback( + (threadId: string, path: string, options?: { auto?: boolean }) => { + const normalized = path.trim(); + if (!normalized || !threadId) return; + + setActiveArtifactThreadId(threadId); + const prev = artifactPathsRef.current; + if (!prev.includes(normalized)) { + const next = [...prev, normalized]; + artifactPathsRef.current = next; + setArtifacts(next); + } + + const openPanel = () => { + selectArtifact(normalized, options?.auto ?? false); + setArtifactsOpen(true); + }; + + if (options?.auto) { + window.setTimeout(openPanel, 100); + } else { + openPanel(); + } + }, + [selectArtifact, setArtifacts, setArtifactsOpen], + ); + + const openArtifactFromStep = useCallback( + ( + dialogue: Step2Dialogue, + step: NonNullable[number], + ) => { + const seatThreadId = dialogue.agentThreadId; + if (!seatThreadId) return; + const [, toolName] = step.key.split(":"); + if (toolName === "write_file" || toolName === "str_replace") { + const path = step.args?.path; + if (typeof path !== "string" || !step.args) return; + const { messageId, toolCallId } = upsertSeatToolCall( + seatThreadId, + toolName, + step.args, + ); + const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId); + openSeatVirtualArtifact(seatThreadId, virtualUrl, false); + return; + } + if (toolName === "present_files") { + const filepaths = step.args?.filepaths; + if (Array.isArray(filepaths)) { + const first = filepaths.find( + (p): p is string => typeof p === "string" && p.length > 0, + ); + if (first) openArtifactPreview(seatThreadId, first); + } + } + }, + [ + buildWriteFileVirtualUrl, + openArtifactPreview, + openSeatVirtualArtifact, + upsertSeatToolCall, + ], + ); + + const handleArtifactToolPhase = useCallback( + ( + phase: StreamPhase, + toolName: string | undefined, + args: Record | undefined, + seatThreadId: string | undefined, + ) => { + if (phase !== "tool_calling") return; + if (!seatThreadId) return; + const isArtifactTool = + toolName === "write_file" || toolName === "str_replace"; + if (!isArtifactTool) return; + const path = args?.path; + if (typeof path !== "string" || !path || !args) { + if (typeof window !== "undefined") { + console.debug( + "[roundtable-sandbox] tool_calling without path yet", + { toolName, args }, + ); + } + return; + } + const { messageId, toolCallId } = upsertSeatToolCall( + seatThreadId, + toolName as string, + args, + ); + const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId); + if (typeof window !== "undefined") { + console.debug( + "[roundtable-sandbox] auto-open from phase", + { toolName, path, seatThreadId, virtualUrl }, + ); + } + openSeatVirtualArtifact(seatThreadId, virtualUrl, true); + }, + [ + buildWriteFileVirtualUrl, + openSeatVirtualArtifact, + upsertSeatToolCall, + ], + ); + // ─── Dialogue / Speaker helpers ──────────────────────────────────── const appendDialogue = useCallback( (card: { @@ -349,6 +609,7 @@ export function useStep2Orchestration( avatarType: string; initialContent?: string; confidence?: string; + agentThreadId?: string; }): number => { const id = Date.now() + Math.random(); setStep2RoundtableDialogues((prev) => [ @@ -362,6 +623,7 @@ export function useStep2Orchestration( confidence: card.confidence ?? "正在输出…", streaming: true, steps: [], + agentThreadId: card.agentThreadId, }, ]); return id; @@ -587,11 +849,13 @@ export function useStep2Orchestration( ? agentConfigsRef.current[meta.speakerId]?.model : undefined; + const seatThreadId = resolveSeatThreadId(ids, subName); const subBubbleId = appendStreamingDialogue({ sender: meta?.displayName ?? subName, avatarType: meta?.avatarType ?? "blue", initialContent: "", confidence: "调度中", + agentThreadId: seatThreadId, }); const subAborter = new AbortController(); @@ -616,6 +880,12 @@ export function useStep2Orchestration( `${phase}:${toolName ?? ""}`, args, ); + handleArtifactToolPhase( + phase, + toolName, + args, + seatThreadId, + ); }, }); if (orchestrationCancelRef.current) { @@ -684,6 +954,8 @@ export function useStep2Orchestration( markSpeakerIdle, clearAllStreamingSpeakers, triggerToast, + handleArtifactToolPhase, + resolveSeatThreadId, ], ); @@ -960,8 +1232,14 @@ export function useStep2Orchestration( setLastLeaderContent(""); setHasConsensus(false); setConsensusPercentage(0); + setActiveArtifactThreadId(null); + artifactPathsRef.current = []; + setArtifacts([]); + seatStubMessagesRef.current = {}; + setSeatStubMessages({}); + deselectArtifact(); step2InitRef.current = false; - }, [abortAllInFlight]); + }, [abortAllInFlight, deselectArtifact, setArtifacts]); return { selectedAgents, @@ -1003,5 +1281,9 @@ export function useStep2Orchestration( handleSaveAgentAndRecalculate, hydrateFromDraft, resetStep2State, + activeArtifactThreadId, + seatStubMessages, + openArtifactPreview, + openArtifactFromStep, }; } diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 20805ce4..a598f0cc 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -46,6 +46,7 @@ import { } from "lucide-react"; import { useTheme } from "@/contexts/ThemeContext"; +import { useArtifacts } from "@/components/workspace/artifacts"; import { cn } from "@/lib/utils"; import { useModels } from "@/core/models/hooks"; @@ -81,6 +82,7 @@ export default function RoundtablePlanningPage() { const [showToast, setShowToast] = useState(null); const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState(false); const [selectedModel, setSelectedModel] = useState(""); + const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true); // 角色面板(LeftSidebar)。原页面把它当成两步共用的展示数据,这里继续放在 // 主页 —— Step 1 / Step 2 / Step 3 都会读 / 写它,任何 hook 单独持有都不合适。 @@ -419,6 +421,11 @@ export default function RoundtablePlanningPage() { {/* MAIN */} +
{/* LEFT SIDEBAR */} {/* MIDDLE VIEWPORT */} @@ -478,6 +487,9 @@ export default function RoundtablePlanningPage() { availableModels={availableModels} modelsLoading={modelsLoading} setSelectedModel={setSelectedModel} + activeArtifactThreadId={step2.activeArtifactThreadId} + seatStubMessages={step2.seatStubMessages} + openArtifactFromStep={step2.openArtifactFromStep} setIsRoundTableRunning={step2.setIsRoundTableRunning} setClarificationDraft={step2.setClarificationDraft} setCustomAgentInput={step2.setCustomAgentInput} @@ -794,6 +806,34 @@ interface LeftSidebarProps { selectedAgents: SelectedAgent[]; budgetLimit: number; intentReady: ReturnType["intentReady"]; + sidebarExpanded: boolean; + setSidebarExpanded: React.Dispatch>; +} + +function Step2SandboxSidebarSync({ + currentStep, + sidebarExpanded, + setSidebarExpanded, +}: { + currentStep: number; + sidebarExpanded: boolean; + setSidebarExpanded: React.Dispatch>; +}) { + const { open } = useArtifacts(); + const prevOpenRef = useRef(open); + + useEffect(() => { + if (currentStep !== 2) { + prevOpenRef.current = open; + return; + } + if (open && !prevOpenRef.current && sidebarExpanded) { + setSidebarExpanded(false); + } + prevOpenRef.current = open; + }, [currentStep, open, setSidebarExpanded, sidebarExpanded]); + + return null; } function LeftSidebar({ @@ -803,8 +843,9 @@ function LeftSidebar({ selectedAgents, budgetLimit: _budgetLimit, intentReady, + sidebarExpanded, + setSidebarExpanded, }: LeftSidebarProps) { - const [sidebarExpanded, setSidebarExpanded] = useState(true); const isStep2 = currentStep === 2; return (