"use client"; import { AssistantRuntimeProvider, useAuiState } from "@assistant-ui/react"; import { useAISDKRuntime } from "@assistant-ui/react-ai-sdk"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { AlertCircle, Box, Loader2, Moon, Sun } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { activeCheckpointPreview, latestSuccessfulResult } from "@/lib/cad-artifacts"; import { normalizeCadMessages } from "@/lib/cad-messages"; import type { ViewerSelectionContext } from "@/lib/viewer-selection"; import type { BackendConfig, CadError, CadAttachment, CadProgress, CadResult, CadUIMessage, ConversationRecord, TaskRecord, } from "@/lib/cad-types"; import { AgentThread } from "./agent-thread"; import { CadViewerPreview } from "./cad-viewer-preview"; import type { AssistantRuntime } from "@assistant-ui/react"; type LoadState = "loading" | "ready" | "error"; export function AgentStudio() { const [loadState, setLoadState] = useState("loading"); const [conversationId, setConversationId] = useState(""); const [selectedTaskId, setSelectedTaskId] = useState(""); const [initialMessages, setInitialMessages] = useState([]); const [config, setConfig] = useState(null); const [cadResult, setCadResult] = useState(null); const [lastError, setLastError] = useState(""); const [attachments, setAttachments] = useState([]); const [uploading, setUploading] = useState(false); const [uploadError, setUploadError] = useState(""); const [providerId, setProviderId] = useState(""); const [modelId, setModelId] = useState(""); const [theme, setTheme] = useState<"light" | "dark">("light"); const [viewerSelection, setViewerSelection] = useState(null); const [taskRunning, setTaskRunning] = useState(false); const [taskRecord, setTaskRecord] = useState(null); const syncUrl = useCallback((conversation: string, task: string) => { const params = new URLSearchParams(window.location.search); if (conversation) params.set("conversationId", conversation); if (task) params.set("taskId", task); else params.delete("taskId"); window.history.replaceState(null, "", `${window.location.pathname}?${params.toString()}`); }, []); useEffect(() => { let cancelled = false; async function boot() { try { const params = new URLSearchParams(window.location.search); let nextConversationId = params.get("conversationId") || ""; const urlTaskId = params.get("taskId") || ""; const [configResponse] = await Promise.all([fetch("/api/config", { cache: "no-store" })]); if (!configResponse.ok) throw new Error(await configResponse.text()); const nextConfig = (await configResponse.json()) as BackendConfig; let conversation: ConversationRecord; if (nextConversationId) { const response = await fetch(`/api/conversations/${encodeURIComponent(nextConversationId)}`, { cache: "no-store" }); if (!response.ok) throw new Error(await response.text()); conversation = (await response.json()) as ConversationRecord; } else { const response = await fetch("/api/conversations", { method: "POST" }); if (!response.ok) throw new Error(await response.text()); conversation = (await response.json()) as ConversationRecord; nextConversationId = conversation.conversation_id; } const taskId = urlTaskId || conversation.current_task_id || ""; let restored: CadResult | null = null; let restoredTask: TaskRecord | null = null; if (taskId) { const taskResponse = await fetch(`/api/tasks/${encodeURIComponent(taskId)}`, { cache: "no-store" }); if (taskResponse.ok) { restoredTask = (await taskResponse.json()) as TaskRecord; restored = activeCheckpointPreview(restoredTask) ?? latestSuccessfulResult(restoredTask); } } if (cancelled) return; setConfig(nextConfig); setConversationId(nextConversationId); setSelectedTaskId(taskId); setInitialMessages(normalizeCadMessages(conversation.messages)); setAttachments(conversation.attachments || []); const defaultProvider = nextConfig.providers.find((provider) => provider.id === nextConfig.default_provider) ?? nextConfig.providers[0]; setProviderId(defaultProvider?.id || ""); setModelId(defaultProvider?.models.find((model) => model.id === nextConfig.default_model)?.id || defaultProvider?.models[0]?.id || ""); setCadResult(restored); setTaskRunning(restoredTask?.lifecycle === "running"); setTaskRecord(restoredTask); setLoadState("ready"); syncUrl(nextConversationId, taskId); } catch (error) { if (cancelled) return; setLastError(error instanceof Error ? error.message : "启动失败"); setLoadState("error"); } } void boot(); return () => { cancelled = true; }; }, [syncUrl]); useEffect(() => { const stored = window.localStorage.getItem("cdsl-cad.ui-theme"); const next = stored === "dark" || stored === "light" ? stored : window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; setTheme(next); document.documentElement.dataset.uiTheme = next; }, []); const toggleTheme = useCallback(() => { setTheme((current) => { const next = current === "light" ? "dark" : "light"; document.documentElement.dataset.uiTheme = next; window.localStorage.setItem("cdsl-cad.ui-theme", next); return next; }); }, []); const handleResult = useCallback((result: CadResult) => { setCadResult(result); setViewerSelection(null); setSelectedTaskId(result.taskId); setLastError(""); if (result.lifecycle) setTaskRunning(result.lifecycle === "running"); if (conversationId) { syncUrl(conversationId, result.taskId); void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentTaskId: result.taskId }), }); } }, [conversationId, syncUrl]); const handleTaskState = useCallback((progress: CadProgress) => { const taskId = String(progress.taskId || ""); if (taskId) { setSelectedTaskId(taskId); if (conversationId) { syncUrl(conversationId, taskId); void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentTaskId: taskId }), }); } } if (progress.step === "requirements_document" && progress.status === "running") setTaskRunning(true); if (progress.step === "task_terminal") setTaskRunning(progress.lifecycle === "running"); }, [conversationId, syncUrl]); const handleError = useCallback((error: CadError) => { setLastError(error.message); if (error.stage === "generation") setTaskRunning(false); }, []); const handleCancel = useCallback(() => { setTaskRunning(false); setLastError("正在停止 CAD 任务..."); void (async () => { let taskId = selectedTaskId; if (!taskId && conversationId) { const conversationResponse = await fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, { cache: "no-store" }); if (conversationResponse.ok) { const conversation = await conversationResponse.json() as ConversationRecord; taskId = conversation.current_task_id || ""; } } if (!taskId) throw new Error("尚未取得运行中的任务编号,请稍后重试"); const response = await fetch(`/api/tasks/${encodeURIComponent(taskId)}`, { method: "DELETE" }); if (!response.ok) { const payload = await response.json().catch(() => ({})) as { error?: string }; throw new Error(payload.error || "停止 CAD 任务失败"); } setLastError("CAD 任务已停止"); })().catch((error) => { setLastError(error instanceof Error ? error.message : "停止 CAD 任务失败"); }); }, [conversationId, selectedTaskId]); const handleUpload = useCallback(async (files: FileList | null) => { const selectedFiles = Array.from(files || []); if (!selectedFiles.length) return; setUploadError(""); setUploading(true); try { const uploaded: CadAttachment[] = []; for (const file of selectedFiles) { const form = new FormData(); form.set("file", file); const response = await fetch(`/api/conversations/${encodeURIComponent(conversationId)}/attachments`, { method: "POST", body: form }); const payload = await response.json() as CadAttachment & { error?: string }; if (!response.ok) throw new Error(payload.error || `${file.name} 上传失败`); uploaded.push(payload); } setAttachments((current) => [...current, ...uploaded]); setLastError(""); } catch (error) { const message = error instanceof Error ? error.message : "附件上传失败"; setUploadError(message); setLastError(message); } finally { setUploading(false); } }, [conversationId]); useEffect(() => { if (!taskRunning || !selectedTaskId) return; let cancelled = false; const refresh = async () => { try { const response = await fetch(`/api/tasks/${encodeURIComponent(selectedTaskId)}`, { cache: "no-store" }); if (!response.ok) return; const next = await response.json() as TaskRecord; if (cancelled) return; setTaskRecord(next); const running = next.lifecycle === "running"; setTaskRunning(running); if (running) { const preview = activeCheckpointPreview(next); if (preview) setCadResult(preview); } else { const restored = latestSuccessfulResult(next); setCadResult(restored); } } catch { // Keep the persisted lock until a later poll can prove a terminal state. } }; void refresh(); const timer = window.setInterval(() => void refresh(), 2000); return () => { cancelled = true; window.clearInterval(timer); }; }, [selectedTaskId, taskRunning]); if (loadState === "loading") { return ; } if (loadState === "error") { return ; } return ( ); } function AgentRuntime({ conversationId, selectedTaskId, providerId, modelId, viewerSelection, initialMessages, onCadResult, onCadError, onCadProgress, children, }: { conversationId: string; selectedTaskId: string; providerId: string; modelId: string; viewerSelection: ViewerSelectionContext | null; initialMessages: CadUIMessage[]; onCadResult: (result: CadResult) => void; onCadError: (error: CadError) => void; onCadProgress: (progress: CadProgress) => void; children: React.ReactNode; }) { const conversationRef = useRef(conversationId); const taskRef = useRef(selectedTaskId); const providerRef = useRef(providerId); const modelRef = useRef(modelId); const viewerSelectionRef = useRef(viewerSelection); const onCadResultRef = useRef(onCadResult); const onCadErrorRef = useRef(onCadError); const onCadProgressRef = useRef(onCadProgress); conversationRef.current = conversationId; taskRef.current = selectedTaskId; providerRef.current = providerId; modelRef.current = modelId; viewerSelectionRef.current = viewerSelection; onCadResultRef.current = onCadResult; onCadErrorRef.current = onCadError; onCadProgressRef.current = onCadProgress; const transport = useMemo(() => new DefaultChatTransport({ api: "/api/chat", prepareSendMessagesRequest: (options) => ({ body: { ...options.body, id: options.id, messages: options.messages, conversationId: conversationRef.current, selectedTaskId: taskRef.current || null, providerId: providerRef.current || null, modelId: modelRef.current || null, viewerContext: viewerSelectionRef.current ? [viewerSelectionRef.current] : [], trigger: options.trigger, messageId: options.messageId, }, }), }), []); const chat = useChat({ id: conversationId, messages: initialMessages, transport, onData: (part) => { if (part.type === "data-cad-progress") { onCadProgressRef.current(part.data as CadProgress); } if (part.type === "data-cad-result") { onCadResultRef.current(part.data as CadResult); taskRef.current = (part.data as CadResult).taskId; } if (part.type === "data-cad-error") { onCadErrorRef.current(part.data as CadError); } }, onError: (error) => { onCadErrorRef.current({ stage: "chat", message: error.message }); }, }); const runtime = useAISDKRuntime(chat, { joinStrategy: "none", }); const stableRuntime = useMemo(() => stabilizeAssistantRuntimeSnapshots(runtime), [runtime]); return {children}; } const stableSnapshotSymbol = Symbol.for("cdsl-cad.stableRuntimeSnapshot"); const stableChildRuntimeSymbol = Symbol.for("cdsl-cad.stableChildRuntimeMethods"); type SnapshotRuntime = { getState: () => unknown; [stableSnapshotSymbol]?: true; [stableChildRuntimeSymbol]?: true; }; function stabilizeAssistantRuntimeSnapshots(runtime: AssistantRuntime) { stabilizeThreadListRuntime(runtime.threads); stabilizeThreadRuntime(runtime.thread); return runtime; } function stabilizeThreadListRuntime(runtime: AssistantRuntime["threads"]) { const threadList = runtime as AssistantRuntime["threads"] & SnapshotRuntime; stabilizeSnapshot(threadList); stabilizeThreadRuntime(threadList.main); stabilizeSnapshotIfRuntime(threadList.mainItem); if (threadList[stableChildRuntimeSymbol]) return; wrapRuntimeFactory(threadList, "getById", stabilizeThreadRuntime); wrapRuntimeFactory(threadList, "getItemById", stabilizeSnapshotIfRuntime); wrapRuntimeFactory(threadList, "getItemByIndex", stabilizeSnapshotIfRuntime); wrapRuntimeFactory(threadList, "getArchivedItemByIndex", stabilizeSnapshotIfRuntime); threadList[stableChildRuntimeSymbol] = true; } function stabilizeThreadRuntime(runtime: unknown) { if (!isSnapshotRuntime(runtime)) return runtime; stabilizeSnapshot(runtime); const thread = runtime as SnapshotRuntime & { composer?: unknown; getMessageById?: (...args: unknown[]) => unknown; getMessageByIndex?: (...args: unknown[]) => unknown; }; stabilizeComposerRuntime(thread.composer); if (thread[stableChildRuntimeSymbol]) return thread; wrapRuntimeFactory(thread, "getMessageById", stabilizeMessageRuntime); wrapRuntimeFactory(thread, "getMessageByIndex", stabilizeMessageRuntime); thread[stableChildRuntimeSymbol] = true; return thread; } function stabilizeMessageRuntime(runtime: unknown) { if (!isSnapshotRuntime(runtime)) return runtime; stabilizeSnapshot(runtime); const message = runtime as SnapshotRuntime & { composer?: unknown; getAttachmentByIndex?: (...args: unknown[]) => unknown; getMessagePartByIndex?: (...args: unknown[]) => unknown; getMessagePartByToolCallId?: (...args: unknown[]) => unknown; }; stabilizeComposerRuntime(message.composer); if (message[stableChildRuntimeSymbol]) return message; wrapRuntimeFactory(message, "getAttachmentByIndex", stabilizeSnapshotIfRuntime); wrapRuntimeFactory(message, "getMessagePartByIndex", stabilizeSnapshotIfRuntime); wrapRuntimeFactory(message, "getMessagePartByToolCallId", stabilizeSnapshotIfRuntime); message[stableChildRuntimeSymbol] = true; return message; } function stabilizeComposerRuntime(runtime: unknown) { if (!isSnapshotRuntime(runtime)) return runtime; stabilizeSnapshot(runtime); const composer = runtime as SnapshotRuntime & { getAttachmentByIndex?: (...args: unknown[]) => unknown; }; if (composer[stableChildRuntimeSymbol]) return composer; wrapRuntimeFactory(composer, "getAttachmentByIndex", stabilizeSnapshotIfRuntime); composer[stableChildRuntimeSymbol] = true; return composer; } function stabilizeSnapshot(runtime: SnapshotRuntime) { if (runtime[stableSnapshotSymbol]) return; const getState = runtime.getState.bind(runtime); let previous: unknown; runtime.getState = () => { const next = getState(); if (isShallowSameSnapshot(previous, next)) return previous; previous = next; return next; }; runtime[stableSnapshotSymbol] = true; } function stabilizeSnapshotIfRuntime(value: unknown) { if (isSnapshotRuntime(value)) stabilizeSnapshot(value); return value; } function wrapRuntimeFactory( runtime: Record, method: string, stabilize: (value: unknown) => unknown, ) { const original = runtime[method]; if (typeof original !== "function") return; runtime[method] = (...args: unknown[]) => stabilize(original.apply(runtime, args)); } function isSnapshotRuntime(value: unknown): value is SnapshotRuntime { return Boolean(value && typeof value === "object" && typeof (value as SnapshotRuntime).getState === "function"); } function isShallowSameSnapshot(left: unknown, right: unknown) { if (Object.is(left, right)) return true; if (!left || !right || typeof left !== "object" || typeof right !== "object") return false; const leftRecord = left as Record; const rightRecord = right as Record; const leftKeys = Object.keys(leftRecord); if (leftKeys.length !== Object.keys(rightRecord).length) return false; return leftKeys.every((key) => Object.is(leftRecord[key], rightRecord[key])); } function StudioShell({ config, cadResult, lastError, attachments, uploading, uploadError, onUpload, onCancel, theme, onToggleTheme, providerId, modelId, onProviderChange, onModelChange, onCadResult, onCadError, onSelectionChange, taskRunning, }: { config: BackendConfig | null; cadResult: CadResult | null; lastError: string; attachments: CadAttachment[]; uploading: boolean; uploadError: string; onUpload: (files: FileList | null) => void; onCancel: () => void; theme: "light" | "dark"; onToggleTheme: () => void; providerId: string; modelId: string; onProviderChange: (id: string) => void; onModelChange: (id: string) => void; onCadResult: (result: CadResult) => void; onCadError: (error: CadError) => void; onSelectionChange: (selection: ViewerSelectionContext | null) => void; taskRunning: boolean; }) { const running = useAuiState((state) => state.thread.isRunning) || taskRunning; const provider = config?.providers.find((item) => item.id === providerId); const handleViewerError = useCallback((message: string) => { onCadError({ stage: "viewer", message }); }, [onCadError]); return (
CDSL CAD Studio{cadResult ? {cadResult.taskId} : null}
{!config?.configured ?
未配置模型环境变量,聊天会保留诊断但不会生成虚假模型。
: null} {config?.autonomous_generation && !config.review_configured ?
最终视觉复核未配置,任务在最终发布前会停止:{config.review_error || "请配置独立视觉模型。"}
: null}
); } function StudioLoading() { return (
启动 CDSL CAD Studio
); } function StudioError({ message }: { message: string }) { return (
{message}
); }