报告沙箱
This commit is contained in:
1 parent
3bd5cfab9d
commit
4da8a1301e
7 files changed
+736
-11
No files matched your search
@@ -220,7 +220,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) {
|
||||
达成共识生成全案成果 >
|
||||
</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 (
|
||||
|
||||
Reference in new issue
Block a user