Files
deerflow-code/frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx
T
2026-05-28 18:29:44 +08:00

165 lines
5.4 KiB
TypeScript

/**
* 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: rawArtifacts,
open: artifactsOpen,
setOpen: setArtifactsOpen,
selectedArtifact,
} = useArtifacts();
const artifacts = rawArtifacts ?? [];
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>
);
}