165 lines
5.4 KiB
TypeScript
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>
|
|
);
|
|
}
|