报告沙箱

This commit is contained in:
董宏凯 committed 2026-05-27 17:20:41 +08:00
1 parent d6b88b842d
commit 198fc18c50
7 files changed
+736 -11

No files matched your search

@@ -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:<path>?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`」。
+3 -1
View File
@@ -217,7 +217,9 @@ export default function WorkspaceRoutes({
path="roundtable/planning"
element={
<WorkspaceLayout {...layoutProps}>
<RoundtablePlanningPage />
<ChatRuntime>
<RoundtablePlanningPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
@@ -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
<ChainOfThoughtContent className="px-3 pb-2">
{showAbove &&
priorToolSteps.map((s) => (
<ToolStep key={s.id} step={s} status="complete" />
<ToolStep
key={s.id}
step={s}
status="complete"
onArtifactStepClick={onArtifactStepClick}
/>
))}
<ToolStep
step={lastToolStep}
status={streaming && trailingReasoning.length === 0 ? "active" : "complete"}
onArtifactStepClick={onArtifactStepClick}
/>
</ChainOfThoughtContent>
)}
@@ -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 (
<ChainOfThoughtStep
icon={display.icon}
status={status}
className={isArtifactStep && onArtifactStepClick ? "cursor-pointer" : undefined}
onClick={
isArtifactStep && onArtifactStepClick
? () => onArtifactStepClick(step)
: undefined
}
label={
<span className="text-[12px] text-slate-700">
<span className="font-medium">{display.label}</span>
@@ -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<React.SetStateAction<string>>;
activeArtifactThreadId: string | null;
seatStubMessages: Record<string, unknown[]>;
openArtifactFromStep: (
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
) => void;
// ─── Setters ──────────────────────────────────────────────────
setIsRoundTableRunning: React.Dispatch<React.SetStateAction<boolean>>;
setClarificationDraft: React.Dispatch<React.SetStateAction<string>>;
@@ -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 (
<div className="flex-1 flex flex-col min-h-0 bg-slate-50/20">
const isSandboxStreaming =
isOrchestrating ||
step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true);
const chatBody = (
<>
{/* Headline */}
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm">
<div className="flex items-center gap-2">
@@ -213,7 +227,11 @@ export function Step2Panel(props: Step2PanelProps) {
</div>
)}
{step2RoundtableDialogues.map((msg) => (
<Step2Message key={msg.id} msg={msg} />
<Step2Message
key={msg.id}
msg={msg}
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
/>
))}
</div>
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
@@ -328,7 +346,21 @@ export function Step2Panel(props: Step2PanelProps) {
达成共识生成全案成果 &gt;
</button>
</div>
</div>
</>
);
const activeSeatMessages = activeArtifactThreadId
? seatStubMessages[activeArtifactThreadId]
: undefined;
return (
<Step2SandboxLayout
activeThreadId={activeArtifactThreadId}
isStreaming={isSandboxStreaming}
seatMessages={activeSeatMessages}
>
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">{chatBody}</div>
</Step2SandboxLayout>
);
}
@@ -342,9 +374,12 @@ export function Step2Panel(props: Step2PanelProps) {
*/
interface Step2MessageProps {
msg: Step2Dialogue;
onArtifactStepClick?: (
step: NonNullable<Step2Dialogue["steps"]>[number],
) => void;
}
function Step2Message({ msg }: Step2MessageProps) {
function Step2Message({ msg, onArtifactStepClick }: Step2MessageProps) {
const isInjected = msg.sender === "用户 (插话干预)";
const isStreaming = msg.streaming === true;
// 抽出 `<think>...</think>` 作 reasoning;剩下的是要渲染的正文。
@@ -367,7 +402,11 @@ function Step2Message({ msg }: Step2MessageProps) {
}
>
{Array.isArray(msg.steps) && msg.steps.length > 0 && (
<MessageStepsCard steps={msg.steps} streaming={isStreaming} />
<MessageStepsCard
steps={msg.steps}
streaming={isStreaming}
onArtifactStepClick={onArtifactStepClick}
/>
)}
{reasoning && (
<ReasoningBlock streaming={isStreaming}>{reasoning}</ReasoningBlock>
@@ -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<GroupImperativeHandle>(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<AgentThreadState>;
}, [artifacts, isStreaming, seatMessages]);
const previewThreadId = activeThreadId ?? "";
return (
<ThreadContext.Provider value={{ thread: threadStub, isMock: false }}>
<ResizablePanelGroup
id="roundtable-step2-panels"
orientation="horizontal"
defaultLayout={{ chat: 100, artifacts: 0 }}
groupRef={layoutRef}
className="h-full min-h-0 flex-1"
>
<ResizablePanel className="relative" defaultSize={100} id="chat">
{children}
</ResizablePanel>
<ResizableHandle
id="roundtable-step2-separator"
className={cn(
"opacity-33 hover:opacity-100",
!artifactPanelOpen && "pointer-events-none opacity-0",
)}
/>
<ResizablePanel
className={cn(
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
!artifactsOpen && "opacity-0",
)}
id="artifacts"
>
<div
className={cn(
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
)}
>
{selectedArtifact && previewThreadId ? (
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={previewThreadId}
/>
) : (
<div className="relative flex size-full justify-center">
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => setArtifactsOpen(false)}
>
<XIcon />
</Button>
</div>
{artifacts.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title="未选择文件"
description="智能体写入文件后将在此预览"
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">产出文件</h2>
</header>
<main className="min-h-0 grow">
{previewThreadId ? (
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={artifacts}
threadId={previewThreadId}
/>
) : null}
</main>
</div>
)}
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ThreadContext.Provider>
);
}
@@ -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:<path>?message_id=...&tool_call_id=...` 直接读 `args.content`,
* 完全对齐主聊天 `chats/:threadId` 的 write_file 实时预览体验。
*/
export interface StubToolCall {
id: string;
name: string;
args: Record<string, unknown>;
}
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<string, StubAiMessage[]>;
/** 从工具步骤或流式 write_file 打开沙箱预览。 */
openArtifactPreview: (
threadId: string,
path: string,
options?: { auto?: boolean },
) => void;
/** 点击气泡内工具步骤时打开沙箱。 */
openArtifactFromStep: (
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[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<string[]>([]);
useEffect(() => {
artifactPathsRef.current = artifacts;
}, [artifacts]);
// ─── State ─────────────────────────────────────────────────────────
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<string | null>(
null,
);
const [seatStubMessages, setSeatStubMessages] = useState<
Record<string, StubAiMessage[]>
>({});
const seatStubMessagesRef = useRef<Record<string, StubAiMessage[]>>({});
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>(
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<string, unknown>,
): { 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<Step2Dialogue["steps"]>[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<string, unknown> | 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,
};
}
@@ -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<string | null>(null);
const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState<boolean>(false);
const [selectedModel, setSelectedModel] = useState<string>("");
const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true);
// 角色面板(LeftSidebar)。原页面把它当成两步共用的展示数据,这里继续放在
// 主页 —— Step 1 / Step 2 / Step 3 都会读 / 写它,任何 hook 单独持有都不合适。
@@ -419,6 +421,11 @@ export default function RoundtablePlanningPage() {
</header>
{/* MAIN */}
<Step2SandboxSidebarSync
currentStep={currentStep}
sidebarExpanded={step2SidebarExpanded}
setSidebarExpanded={setStep2SidebarExpanded}
/>
<main className="flex-1 overflow-hidden p-4 flex gap-4 min-h-0">
{/* LEFT SIDEBAR */}
<LeftSidebar
@@ -428,6 +435,8 @@ export default function RoundtablePlanningPage() {
selectedAgents={step2.selectedAgents}
budgetLimit={step2.budgetLimit}
intentReady={step1.intentReady}
sidebarExpanded={step2SidebarExpanded}
setSidebarExpanded={setStep2SidebarExpanded}
/>
{/* 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<typeof useStep1Intent>["intentReady"];
sidebarExpanded: boolean;
setSidebarExpanded: React.Dispatch<React.SetStateAction<boolean>>;
}
function Step2SandboxSidebarSync({
currentStep,
sidebarExpanded,
setSidebarExpanded,
}: {
currentStep: number;
sidebarExpanded: boolean;
setSidebarExpanded: React.Dispatch<React.SetStateAction<boolean>>;
}) {
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 (