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..d3fd7fed --- /dev/null +++ b/frontend-web/docs/roundtable-step2-sandbox-progress.md @@ -0,0 +1,207 @@ +# 圆桌 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`)点击打开 | ✅ | ✅ `PresentFilesRow` | +| 沙箱打开时收起 workspace 左侧栏 | ✅(`ArtifactsProvider.select`) | ✅ Step 2 左栏联动 | +| 60/40 对话 \| 产物预览分栏 | ✅ `ChatBox` | ✅ `Step2SandboxLayout` | +| 流式 MD 从 tool_call.args.content 预览 | ✅ 虚拟 URL + stub messages | ✅ `seatStubMessages`(双通道:`onPhaseChange` + `onUpstreamFrame`) | +| 落盘后 HTTP 拉取真实文件 | ✅ artifacts API | ✅ `present_files` 卡片走 HTTP | +| 草稿 reload 后历史文件可预览 | ✅ | ✅ `seatStubMessages` 一并入草稿 | + +--- + +## 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) | + +### 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 加上该字段 | + +**主要文件**: + +``` +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 +frontend-web/src/roundtable-planning/utils/drafts.ts +``` + +### 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) + │ + └── 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) +``` + +**为何不用真实 path 直接预览**:流式期间文件可能尚未落盘,直接调 `/api/threads/:id/artifacts/...` 会返回 `404 {"detail":"Not Found"}`。主聊天同样用虚拟 URL 绕过此问题。 + +--- + +## 3. 未完成 / 待验证 + +### 3.1 功能缺口(按优先级) + +| 优先级 | 项 | 状态 | 说明 | +|--------|----|------|------| +| P0 | 端到端回归 | ⚠️ 待验证 | 虚拟 URL + 双通道 + 文件卡 + 草稿持久化全链路尚未在浏览器里跑一遍 | +| P2 | `str_replace` 流式编辑预览 | ❌ | 主聊天 `applyPendingTextEdit` 依赖完整 message 链;stub 未模拟 tool result / 多轮 str_replace 累加 | +| P2 | 沙箱 `artifacts[]` 列表与虚拟 URL 混排 | ❌ | 虚拟 URL 不写入 `setArtifacts([])`;多文件切换、文件名下拉体验未完全对齐主聊天 | +| 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` 并接 `onUpstreamFrame`;总控若直接 write_file,需单独接 thread id 与帧回调。 +4. **`present_files` 文件大小**:流式期间 backend 尚未给出真实大小;卡片只显示扩展名徽章,不显示 size。落盘后用户点开走 HTTP API 自然能看到完整内容。 +5. **调试日志**:控制台 `[roundtable-sandbox]` 前缀;生产环境可考虑加 `window.__ROUNDTABLE_SANDBOX_DEBUG__` 门控。 + +--- + +## 4. 分阶段计划(原始规划 vs 现状) + +| 阶段 | 内容 | 状态 | +|------|------|------| +| Phase 1 | `ChatRuntime` + 分栏壳 + 侧栏联动 | ✅ 完成 | +| Phase 2 | `write_file` 自动/点击打开 + 虚拟 URL 预览 | ✅ 完成 | +| Phase 3 | `onUpstreamFrame` 注入完整 LangGraph messages | ✅ 完成(双通道兜底) | +| Phase 4 | `present_files` 卡片 + Step 3 文件区衔接 | ⚠️ 卡片完成;Step 3 衔接遗留 | +| Phase 5 | 草稿 stub 持久化 + 文档更新 | ✅ stub 完成;本文档同步更新 | + +--- + +## 5. 测试清单 + +在 `/page/workspace/roundtable/planning` Step 2 下逐项勾选: + +- [ ] 子智能体 `write_file` 进入 `tool_calling` 后,右侧沙箱 **自动** 打开(约 100ms 延迟,对齐主聊天) +- [ ] 沙箱标题为文件名(如 `sample-paper.md`),**不是** `{"detail":"Not Found"}` +- [ ] 流式期间 MD 内容随 `args.content` 更新(双通道:phase 切换帧 + 兜底帧) +- [ ] 点击 ChainOfThought 步骤「写入文件」/ 路径 chip → 手动打开同一文件 +- [ ] `present_files` 后气泡下方出现文件卡(带扩展名徽章),点击 → HTTP 预览 +- [ ] 沙箱打开时左侧「意图理解」栏 **自动收起** +- [ ] 关闭沙箱(X)后对话区恢复全宽 +- [ ] 「新建任务」后 artifacts / 沙箱 / `seatStubMessages` 全部清空 +- [ ] 「保存草稿」→「加载草稿」后,点击历史 `write_file` 步骤可立即预览(stub 已还原) +- [ ] 与主聊天 `/page/workspace/chats/:threadId` 对比:同一 write_file 行为一致 + +**调试**:DevTools Console 过滤 `[roundtable-sandbox]` 或 `[stream:special:...]`。 + +--- + +## 6. 关键代码索引 + +| 用途 | 路径 | +|------|------| +| 主聊天 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/components/Step2SandboxLayout.tsx` | +| 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 测试清单**,确认 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/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index e49623da..04326f70 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -217,7 +217,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 4fbbb315..557643c5 100644 --- a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx @@ -2,7 +2,7 @@ * Step 2 主面板 —— 多智能体圆桌研讨。 * * 包含 4 块: - * 1) 顶部 Headline + 挂起/恢复按钮 + * 1) 顶部 Headline + 推理模型下拉 + 挂起/恢复按钮 * 2) Persona 抽屉(点击席位头像后展开,见 `PersonaDrawer.tsx`) * 3) 对话流(初始化占位 + 气泡列表 + ChainOfThought 折叠卡 + markdown) * 4) 底部澄清条 + 用户干预输入 + 「达成共识」入口 @@ -18,6 +18,7 @@ import { useRef } from "react"; import { Cpu, + FileIcon, HelpCircle, Loader2, Pause, @@ -27,6 +28,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, @@ -68,6 +70,15 @@ export interface Step2PanelProps { selectedModel: string; availableModels: ModelOption[]; modelsLoading: boolean; + setSelectedModel: React.Dispatch>; + activeArtifactThreadId: string | null; + seatStubMessages: Record; + openArtifactFromStep: ( + dialogue: Step2Dialogue, + step: NonNullable[number], + ) => void; + /** 主聊天里 `present_files` 会在气泡下方渲染卡片;Step 2 沿用同一交互。 */ + openArtifactPreview: (threadId: string, path: string) => void; // ─── Setters ────────────────────────────────────────────────── setIsRoundTableRunning: React.Dispatch>; setClarificationDraft: React.Dispatch>; @@ -100,6 +111,11 @@ export function Step2Panel(props: Step2PanelProps) { selectedModel, availableModels, modelsLoading, + setSelectedModel, + activeArtifactThreadId, + seatStubMessages, + openArtifactFromStep, + openArtifactPreview, setIsRoundTableRunning, setClarificationDraft, setCustomAgentInput, @@ -116,8 +132,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 */}
@@ -129,19 +149,44 @@ export function Step2Panel(props: Step2PanelProps) {

-
- {isRoundTableRunning && isOrchestrating ? ( +
+ + {isRoundTableRunning ? ( - ) : !isRoundTableRunning ? ( + ) : ( - ) : null} + )}
@@ -186,7 +231,16 @@ export function Step2Panel(props: Step2PanelProps) {
)} {step2RoundtableDialogues.map((msg) => ( - + openArtifactFromStep(msg, step)} + onFileCardClick={(path) => { + if (msg.agentThreadId) { + openArtifactPreview(msg.agentThreadId, path); + } + }} + /> ))} {!step2AtBottom && } @@ -301,7 +355,21 @@ export function Step2Panel(props: Step2PanelProps) { 达成共识生成全案成果 > - + + ); + + const activeSeatMessages = activeArtifactThreadId + ? seatStubMessages[activeArtifactThreadId] + : undefined; + + return ( + +
{chatBody}
+
); } @@ -315,9 +383,18 @@ export function Step2Panel(props: Step2PanelProps) { */ interface Step2MessageProps { msg: Step2Dialogue; + onArtifactStepClick?: ( + step: NonNullable[number], + ) => void; + /** 点击气泡下方 `present_files` 文件卡时打开沙箱(走真实 HTTP 路径)。 */ + onFileCardClick?: (path: string) => void; } -function Step2Message({ msg }: Step2MessageProps) { +function Step2Message({ + msg, + onArtifactStepClick, + onFileCardClick, +}: Step2MessageProps) { const isInjected = msg.sender === "用户 (插话干预)"; const isStreaming = msg.streaming === true; // 抽出 `...` 作 reasoning;剩下的是要渲染的正文。 @@ -326,6 +403,7 @@ function Step2Message({ msg }: Step2MessageProps) { const rawContent = msg.content ?? ""; const { content, reasoning } = splitInlineReasoning(rawContent); const hasContent = content.length > 0; + const presentedFiles = collectPresentedFiles(msg.steps); return ( {Array.isArray(msg.steps) && msg.steps.length > 0 && ( - + )} {reasoning && ( {reasoning} @@ -351,6 +433,122 @@ function Step2Message({ msg }: Step2MessageProps) { {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/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..7758b912 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; @@ -70,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; }; /** @@ -158,6 +185,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 +217,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, ); @@ -222,6 +286,9 @@ export function useStep2Orchestration( const orchestrationCancelRef = useRef(false); const step2InitRef = useRef(false); const activeAbortersRef = useRef>(new Set()); + /** 干预研审:暂停当前轮次后待执行的 leader prompt。 */ + const pendingInjectPromptRef = useRef(null); + const [injectFlushToken, setInjectFlushToken] = useState(0); // selectedModel / agentConfigs / agentNameToRole / threadIds / coordinatorName // 等等被 runOrchestration 在循环里反复读取。把它们打到 ref 上,可以让 // runOrchestration 的 useCallback deps 大幅收窄,避免每次模型切换、agentConfigs @@ -251,6 +318,241 @@ 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, + ], + ); + + /** + * 兜底:从 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: { @@ -349,6 +651,7 @@ export function useStep2Orchestration( avatarType: string; initialContent?: string; confidence?: string; + agentThreadId?: string; }): number => { const id = Date.now() + Math.random(); setStep2RoundtableDialogues((prev) => [ @@ -362,6 +665,7 @@ export function useStep2Orchestration( confidence: card.confidence ?? "正在输出…", streaming: true, steps: [], + agentThreadId: card.agentThreadId, }, ]); return id; @@ -587,11 +891,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,7 +922,15 @@ export function useStep2Orchestration( `${phase}:${toolName ?? ""}`, args, ); + handleArtifactToolPhase( + phase, + toolName, + args, + seatThreadId, + ); }, + onUpstreamFrame: (frame) => + handleUpstreamFrame(frame, seatThreadId), }); if (orchestrationCancelRef.current) { finalizeDialogue( @@ -684,6 +998,9 @@ export function useStep2Orchestration( markSpeakerIdle, clearAllStreamingSpeakers, triggerToast, + handleArtifactToolPhase, + handleUpstreamFrame, + resolveSeatThreadId, ], ); @@ -771,7 +1088,11 @@ export function useStep2Orchestration( useEffect(() => { if (currentStep !== 2) return; if (!isRoundTableRunning) { - stopOrchestration(); + // 干预研审会先写入 pending 再置 false;此时 stop 已在 handler 里触发, + // 避免 effect 再次 stop 误杀随后 flush 启动的 inject 编排。 + if (!pendingInjectPromptRef.current) { + stopOrchestration(); + } return; } if ( @@ -789,6 +1110,21 @@ export function useStep2Orchestration( // eslint-disable-next-line react-hooks/exhaustive-deps }, [isRoundTableRunning]); + // 干预研审:当前轮次停止后 flush 待执行的 inject 编排(编排期间 UI 仍为「恢复会商」)。 + useEffect(() => { + if (isOrchestrating) return; + const pending = pendingInjectPromptRef.current; + if (!pending) return; + if (!threadIdsRef.current || !coordinatorNameRef.current) return; + pendingInjectPromptRef.current = null; + triggerToast("您的人工纠偏指令已同步至圆桌节点!"); + void runOrchestration( + pending, + threadIdsRef.current, + coordinatorNameRef.current, + ); + }, [isOrchestrating, injectFlushToken, runOrchestration, triggerToast]); + // ─── Public handlers ───────────────────────────────────────────── const handleInjectToRoundtable = useCallback(() => { if (!customAgentInput.trim()) return; @@ -796,28 +1132,31 @@ export function useStep2Orchestration( triggerToast("⚠️ 圆桌尚未就绪,请稍候"); return; } - if (isOrchestratingRef.current) { - triggerToast("⏳ 当前轮次仍在进行中,请等待或暂停后再发送"); - return; - } const userText = customAgentInput.trim(); setCustomAgentInput(""); + const prompt = `用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`; + pendingInjectPromptRef.current = prompt; + + // 与 Step2Panel 右上角「暂停会商 / 恢复会商」联动:立即进入挂起态。 + setIsRoundTableRunning(false); + stopOrchestration(); + appendDialogue({ sender: "用户 (插话干预)", avatarType: "human", content: `💡 (人工指令介入) ${userText}`, confidence: "最高指令", }); - triggerToast("您的人工纠偏指令已同步至圆桌节点!"); - void runOrchestration( - `用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`, - threadIdsRef.current, - coordinatorNameRef.current, - ); - }, [appendDialogue, customAgentInput, runOrchestration, triggerToast]); + if (isOrchestratingRef.current) { + triggerToast("⏸️ 会商已暂停,当前轮次结束后将处理您的干预指令"); + } else { + triggerToast("⏸️ 会商已暂停,正在处理您的干预指令…"); + } + setInjectFlushToken((n) => n + 1); + }, [appendDialogue, customAgentInput, stopOrchestration, triggerToast]); const submitClarification = useCallback( (rawAnswer: string) => { @@ -939,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; @@ -960,8 +1302,15 @@ export function useStep2Orchestration( setLastLeaderContent(""); setHasConsensus(false); setConsensusPercentage(0); + setActiveArtifactThreadId(null); + artifactPathsRef.current = []; + setArtifacts([]); + seatStubMessagesRef.current = {}; + setSeatStubMessages({}); + pendingInjectPromptRef.current = null; + deselectArtifact(); step2InitRef.current = false; - }, [abortAllInFlight]); + }, [abortAllInFlight, deselectArtifact, setArtifacts]); return { selectedAgents, @@ -1003,5 +1352,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 ac94240d..0b2f6842 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -3,9 +3,9 @@ * * 重构后的主文件只负责"组合 + 编排": * - 顶栏(步骤切换 + 草稿菜单 + 新建任务 + 设置) - * - 左侧栏(任务概览 + 角色面板 + 人工参与) - * - 中间视区:Step1Panel / Step2Panel / Step3Panel(`currentStep` 切换) - * - 右侧栏(推理模型 / 当前意图摘要 / 澄清记录) + * - 左侧栏:Step1/2 为任务概览 + 角色 + 人工参与;Step2 侧栏可整体收起为图标条,含可收起的意图理解 + * - 中间视区:Step1Panel / Step2Panel / Step3Panel(`currentStep` 切换);Step2 顶栏含推理模型下拉 + * - 右侧栏:仅 Step1(推理模型 / 当前意图摘要 / 澄清记录);Step2/3 隐藏以让中间区自适应 * - 推荐弹窗 + Toast * * 业务状态全部下沉到 hook: @@ -27,6 +27,10 @@ import { Check, CheckCircle, ChevronRight, + ChevronDown, + ChevronUp, + PanelLeftClose, + PanelLeftOpen, Clipboard, Clock, Cpu, @@ -42,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"; @@ -77,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 单独持有都不合适。 @@ -188,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, @@ -345,37 +352,41 @@ export default function RoundtablePlanningPage() { 新建任务 - + {currentStep !== 2 && ( + <> + - + + + )} + )} + {currentStep === 3 ? ( <> {/* Step 3 时只保留「参与与结论贡献」面板;原文件里其它两个 hidden 块未启用, @@ -849,41 +922,67 @@ function LeftSidebar({ - ) : ( - <> - {/* 任务概览 */} -
-
- -

任务概览

-
-
-
- - 当前节点 - -
- 初始任务 - - {currentStep === 1 ? "待澄清" : currentStep === 2 ? "研讨中" : "已规划交付"} - -
-

- 围绕某复杂任务制定规划方案 -

+ ) : currentStep === 2 ? ( + sidebarExpanded ? ( + <> + setSidebarExpanded((v) => !v), + }} + /> + +
+
+ +

参与角色

+
+
+ {roles.map((role) => ( + + ))}
-
- {/* 参与角色 */} + + + + ) : ( + <> + + + + + +
+ {roles.map((role) => ( + + ))} +
+
+ + + + + + + + + + ) + ) : ( + <> + +
@@ -896,32 +995,317 @@ function LeftSidebar({
- {/* 人工参与 */} -
-
- -

人工参与

-
-
-
- - 用户 -
-
- - - - - 在线 -
-
-
+ )} ); } +function TaskOverviewCard({ currentStep }: { currentStep: number }) { + return ( +
+
+ +

任务概览

+
+
+
+ + 当前节点 + +
+ 初始任务 + + {currentStep === 1 ? "待澄清" : currentStep === 2 ? "研讨中" : "已规划交付"} + +
+

+ 围绕某复杂任务制定规划方案 +

+
+
+
+ ); +} + +function HumanParticipationCard() { + return ( +
+
+ +

人工参与

+
+
+
+ + 用户 +
+
+ + + + + 在线 +
+
+
+ ); +} + +interface ModelSelectorCardProps { + selectedModel: string; + setSelectedModel: React.Dispatch>; + availableModels: ReadonlyArray<{ name: string; display_name?: string }>; + modelsLoading: boolean; +} + +function ModelSelectorCard({ + selectedModel, + setSelectedModel, + availableModels, + modelsLoading, +}: ModelSelectorCardProps) { + return ( +
+
+ +

推理模型

+
+ +
+ ); +} + +interface IntentSummaryCardProps { + intentReady: ReturnType["intentReady"]; + title?: string; + /** Step 2 侧栏: 收起时仅展示目标,展开后显示约束与假设。 */ + collapsible?: boolean; + /** 侧栏整体收起按钮,放在标题栏右侧。 */ + sidebarToggle?: { + expanded: boolean; + onToggle: () => void; + }; +} + +function IntentSummaryCard({ + intentReady, + title = "当前意图摘要", + collapsible = false, + sidebarToggle, +}: IntentSummaryCardProps) { + const [expanded, setExpanded] = useState(!collapsible); + const showFullDetail = !collapsible || expanded; + + return ( +
+
+
+ +

{title}

+
+ {sidebarToggle && ( + + )} +
+ +
+ {intentReady ? ( + <> +
    +
  • + • + + 目标: {intentReady.objective} + +
  • +
+ {collapsible && ( + + )} + {showFullDetail && ( +
    + {intentReady.constraints.map((c, i) => ( +
  • + • + + 约束: {c} + +
  • + ))} + {(intentReady.assumptions ?? []).map((a, i) => ( +
  • + • + + 假设: {a} + +
  • + ))} +
+ )} + + ) : ( +

+ 等待任务理解智能体澄清完成... +

+ )} +
+
+ ); +} + +function IntentSectionIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function CollapsedSidebarSection({ + title, + children, + className, +}: { + title: string; + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +function renderRoleAvatar( + role: RoleEntry, + agentNameToRole: Record< + string, + { speakerId: number; avatarType: string; displayName: string } + >, + size: string, +) { + if (role.type === "human") { + return ; + } + return getAvatar( + role.agentId ? agentNameToRole[role.agentId]?.avatarType ?? "blue" : "blue", + size, + ); +} + +function RoleAvatarBadge({ + role, + agentNameToRole, + size = "h-7 w-7", + showSpeakingAnimation = false, +}: { + role: RoleEntry; + agentNameToRole: Record< + string, + { speakerId: number; avatarType: string; displayName: string } + >; + size?: string; + showSpeakingAnimation?: boolean; +}) { + const isOngoing = role.status === "研讨中"; + const animate = showSpeakingAnimation && isOngoing; + + return ( +
+ {animate && ( + + )} +
+ {renderRoleAvatar(role, agentNameToRole, size)} +
+
+ ); +} + function RoleRow({ role, agentNameToRole, @@ -949,14 +1333,7 @@ function RoleRow({ }`} >
- {role.type === "human" ? ( - - ) : ( - getAvatar( - role.agentId ? agentNameToRole[role.agentId]?.avatarType ?? "blue" : "blue", - "h-7 w-7", - ) - )} +
{role.name} {role.type === "agent" && ( @@ -1024,89 +1401,21 @@ function RightSidebar({ timelineEntries, triggerToast, }: RightSidebarProps) { - if (currentStep === 3) return null; + // Step 2 侧栏内容已迁至左侧;Step 3 沿用原逻辑不展示右侧栏。 + if (currentStep !== 1) return null; return (