"use client"; import { ArrowUpRight, Bot, Box, BrainCircuit, CheckCircle2, ChevronDown, ChevronRight, Copy, Download, FileCode2, FolderOpen, GitBranch, Grid3X3, History, House, Loader2, MousePointer2, Orbit, Pencil, Plug, Plus, RotateCcw, Send, Settings2, ThumbsDown, ThumbsUp, Trash2, WandSparkles, XCircle, } from "lucide-react"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { type ImperativePanelHandle, Panel, PanelGroup, PanelResizeHandle, } from "react-resizable-panels"; import { Model3DViewer, type Model3DViewerSettings, } from "@/components/canvas/model3d/Model3DViewer"; import { CodeBlock, CodeBlockCopyButton, } from "@/components/elements/code-block"; import { Reasoning, ReasoningContent, ReasoningTrigger, } from "@/components/elements/reasoning"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { type AiCadModelingCommandPayload, type AiCadProviderStatus, type AiCadWorkspaceSummary, aiCadGenerationService, } from "@/services/ai-cad"; import { type Model3DTaskStatus, model3DGenerationService, } from "@/services/canvas"; type CadGenerationProvider = "kittycad-cad" | "tripo-3d"; type ChatMessageState = | "thinking" | "queued" | "running" | "success" | "failed"; type VersionFeedback = "positive" | "negative"; type ChatMessage = { id: string; role: "assistant" | "user"; content: string; createdAt: number; state?: ChatMessageState; versionId?: string; }; type GenerationVersion = { id: string; versionNumber: number; provider: CadGenerationProvider; taskId: string; generationMode?: "create" | "iterate"; parentVersionId?: string | null; prompt: string; modelVersion: string; createdAt: number; status: Model3DTaskStatus; progress: number | null; modelUrl?: string | null; previewImageUrl?: string | null; codeUrl?: string | null; errorMessage?: string | null; conversationId?: string | null; downloadUrls?: Record | null; feedback?: VersionFeedback | null; assistantMessageId: string; }; type PersistedAiCadWorkbenchState = { prompt: string; generationProvider: CadGenerationProvider; modelVersion: string; viewerSettings: Model3DViewerSettings; messages: ChatMessage[]; versions: GenerationVersion[]; activeVersionId: string | null; savedAt?: number; }; type ModelingConnectionState = | "connected" | "connecting" | "disconnected" | "error"; type ModelingCutType = "chamfer" | "fillet"; type WorkspaceListItem = AiCadWorkspaceSummary; const MODEL_VERSION_OPTIONS = [ "Turbo-v1.0-20250506", "v3.1-20260211", "v3.0-20250812", "v2.5-20250123", ] as const; const VIEWER_ENV_PRESETS = [ "studio", "city", "sunset", "dawn", "night", "warehouse", "forest", "apartment", "park", "lobby", ] as const; const SUGGESTED_PROMPTS = [ "设计一把极简风铝合金折叠椅,强调轻量和可堆叠结构", "做一个桌面级模块化收纳盒,带磁吸翻盖和可替换分隔槽", "生成一款人体工学游戏鼠标外壳,右手握持,侧裙有蜂窝纹理", "创建一个便携咖啡杯杯盖结构,带旋转式开合口和防漏密封圈", ] as const; const FINAL_TASK_STATUS = new Set([ "success", "failed", "banned", "expired", "cancelled", "unknown", ]); const DEFAULT_VIEWER_SETTINGS: Model3DViewerSettings = { cameraPreset: "iso", envPreset: "studio", lightIntensity: 1.1, autoRotate: true, showGrid: true, wireframe: false, }; const DOWNLOAD_ORDER = [ "glb", "step", "stp", "gltf", "kcl", "stl", "obj", "fbx", "3mf", ] as const; const MODEL_TASK_STATUSES: Model3DTaskStatus[] = [ "queued", "running", "success", "failed", "banned", "expired", "cancelled", "unknown", ]; const AI_CAD_WORKBENCH_STORAGE_PREFIX = "ai-cad-workbench:v2"; const AI_CAD_ACTIVE_WORKSPACE_STORAGE_KEY = "ai-cad-active-workspace-id:v1"; const NEWLINE_SPLIT_RE = /\r?\n/; const DEFAULT_WORKSPACE_TITLE_RE = /^AI-CAD 工作台(?: \d+)?$/; const STRUCTURED_PROMPT_DIRECTIVES = [ { key: "keep", label: "保留", helper: "把不想被改动的主体写清楚。", }, { key: "change", label: "修改", helper: "说明这轮真正要动的结构部位。", }, { key: "remove", label: "删除", helper: "指出这轮要去掉或回退的内容。", }, { key: "constraints", label: "约束", helper: "补充壁厚、公差、工艺或装配要求。", }, { key: "material", label: "材料", helper: "交代材料、表面处理和使用场景。", }, ] as const; type StructuredPromptDirectiveKey = (typeof STRUCTURED_PROMPT_DIRECTIVES)[number]["key"]; type StructuredPromptDirective = { key: StructuredPromptDirectiveKey; label: string; helper: string; value: string; }; type CodeDiffKind = "added" | "changed" | "equal" | "removed"; type CodeDiffRow = { kind: CodeDiffKind; leftLineNumber: number | null; leftText: string; rightLineNumber: number | null; rightText: string; }; type SemanticDiffCategory = { description: string; followUpHint: string; keywords: RegExp[]; label: string; shortLabel: string; }; type SemanticDiffInsight = { count: number; detail: string; examples: string[]; key: string; label: string; shortLabel: string; }; const SEMANTIC_DIFF_CATEGORIES: Record = { thickness: { description: "这轮更像是在调整壁厚、抽壳或整体壳体厚度。", followUpHint: "下一轮可以明确写出目标壁厚、抽壳方向和哪些区域不能再变薄。", keywords: [ /\bthickness\b/i, /\bwall\b/i, /\bshell\b/i, /壁厚|壳厚|厚度|抽壳/, ], label: "壁厚 / 壳厚", shortLabel: "壁厚", }, edgeTreatment: { description: "这轮集中在圆角、倒角或边缘过渡处理。", followUpHint: "下一轮可以把哪些边要保留锐边、哪些边需要统一圆角半径写得更明确。", keywords: [ /\bfillet\b/i, /\bchamfer\b/i, /\bround(ed|ing)?\b/i, /圆角|倒角|边缘过渡|转角/, ], label: "圆角 / 倒角", shortLabel: "圆角", }, holeLayout: { description: "这轮重点动了孔位、通孔、沉头孔或安装孔表达。", followUpHint: "下一轮可以把孔径、孔深、孔间距和安装基准面写清楚。", keywords: [ /\bhole\b/i, /\bbore\b/i, /\bdrill\b/i, /\bcounter(sink|bore)\b/i, /\bmount(ing)?\b/i, /孔位|孔径|通孔|沉头孔|安装孔|安装点/, ], label: "孔位 / 安装点", shortLabel: "孔位", }, dimensions: { description: "这轮主要改了尺寸、半径、直径、偏移或间隙参数。", followUpHint: "下一轮可以补上关键尺寸、间隙、公差范围,避免模型只改了语气没改到数值。", keywords: [ /\blength\b/i, /\bwidth\b/i, /\bheight\b/i, /\bradius\b/i, /\bdiameter\b/i, /\boffset\b/i, /\bclearance\b/i, /\bgap\b/i, /\btolerance\b/i, /尺寸|长宽高|半径|直径|偏移|间隙|公差|厚薄|比例/, ], label: "尺寸 / 公差", shortLabel: "尺寸", }, patternLayout: { description: "这轮涉及阵列、镜像、间距或布局组织方式。", followUpHint: "下一轮可以说明阵列方向、数量、间距,以及是否要镜像或错位。", keywords: [ /\bpattern\b/i, /\barray\b/i, /\bmirror\b/i, /\bspacing\b/i, /\brepeat\b/i, /\btranslate\b/i, /\brotate\b/i, /阵列|镜像|间距|排布|布局|重复|旋转分布/, ], label: "阵列 / 布局", shortLabel: "阵列", }, profileShape: { description: "这轮更像是在改主体成形方式,比如拉伸、旋转或放样轮廓。", followUpHint: "下一轮可以把主体轮廓、截面变化和成形方式拆开描述,减少形体歧义。", keywords: [ /\bextrude\b/i, /\brevolve\b/i, /\bloft\b/i, /\bsweep\b/i, /\bsketch\b/i, /\bprofile\b/i, /拉伸|旋转|放样|扫描|轮廓|草图|截面|主体形态/, ], label: "轮廓 / 成形", shortLabel: "成形", }, materialProcess: { description: "这轮补充了材料、表面、制造约束或装配工艺信息。", followUpHint: "下一轮可以把材料、表面处理、加工方式和装配限制拆成独立要求。", keywords: [ /\bmaterial\b/i, /\bfinish\b/i, /\banod/i, /\bcoating\b/i, /\bprint(ed|ing)?\b/i, /\bmold(ing)?\b/i, /\bassembly\b/i, /\bfastener\b/i, /材料|表面处理|阳极|喷涂|打印|注塑|装配|工艺|紧固件/, ], label: "材料 / 工艺", shortLabel: "工艺", }, }; function createId() { return crypto.randomUUID(); } function swallowAsync(task: Promise) { task.catch((_error) => null); } function formatClockTime(timestamp: number) { return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", }).format(new Date(timestamp)); } function formatVersionDetailTime(timestamp: number) { return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }).format(new Date(timestamp)); } function truncateIdentifier(value?: string | null, length = 10) { const normalizedValue = String(value || "").trim(); if (!normalizedValue) { return "未记录"; } return normalizedValue.length > length ? `${normalizedValue.slice(0, length)}...` : normalizedValue; } function truncateText(value?: string | null, length = 88) { const normalizedValue = String(value || "") .trim() .replace(/\s+/g, " "); if (!normalizedValue) { return "未填写"; } return normalizedValue.length > length ? `${normalizedValue.slice(0, length).trim()}...` : normalizedValue; } function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function getStatusLabel(status: Model3DTaskStatus) { switch (status) { case "queued": return "排队中"; case "running": return "生成中"; case "success": return "已完成"; case "failed": return "失败"; case "banned": return "已拦截"; case "expired": return "已过期"; case "cancelled": return "已取消"; default: return "未知"; } } function getStatusBadgeClass(status: Model3DTaskStatus) { switch (status) { case "success": return "border-emerald-200 bg-emerald-50 text-emerald-700"; case "running": return "border-sky-200 bg-sky-50 text-sky-700"; case "queued": return "border-amber-200 bg-amber-50 text-amber-700"; case "failed": case "banned": case "cancelled": case "expired": return "border-rose-200 bg-rose-50 text-rose-700"; default: return "border-[#ddd7ca] bg-white text-[#2a261f]"; } } function getMessageStateLabel(state?: ChatMessageState) { switch (state) { case "thinking": return "Thinking"; case "queued": return "Queued"; case "running": return "Running"; case "success": return "Done"; case "failed": return "Failed"; default: return "Thread"; } } function getDownloadLabel(modelUrl?: string | null) { const normalized = String(modelUrl || "") .trim() .toLowerCase(); if (!normalized) { return "下载模型"; } if (normalized.includes(".step") || normalized.includes(".stp")) { return "下载 STEP"; } if (normalized.includes(".gltf") || normalized.includes(".glb")) { return "下载 GLTF"; } if (normalized.includes(".stl")) { return "下载 STL"; } if (normalized.includes(".obj")) { return "下载 OBJ"; } return "下载模型"; } function getFileExtension(value?: string | null) { const normalized = String(value || "") .trim() .toLowerCase(); if (!normalized) { return ""; } const sanitized = normalized.split("?")[0]?.split("#")[0] || normalized; const segments = sanitized.split("."); return segments.length > 1 ? segments.at(-1) || "" : ""; } function getProviderLabel(provider: CadGenerationProvider) { return provider === "kittycad-cad" ? "KittyCAD CAD" : "Tripo3D"; } function readNullableText(value: unknown) { return typeof value === "string" && value.trim() ? value : null; } function parsePositiveNumberInput(value: string, label: string) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { throw new Error(`${label} 需要是一个大于 0 的数字`); } return parsed; } function parseEdgeIndexList(value: string) { const items = value .split(",") .map((item) => item.trim()) .filter(Boolean); if (items.length === 0) { throw new Error("请至少填写一个边索引,例如 0,1,2,3"); } const parsed = items.map((item) => Number(item)); if ( parsed.some( (item) => !Number.isInteger(item) || !Number.isFinite(item) || item < 0 ) ) { throw new Error("边索引必须是 0 开始的整数,例如 0,1,2,3"); } return [...new Set(parsed)]; } function getModelingResponseData( payload?: AiCadModelingCommandPayload | null ): Record | null { return payload?.responseData && typeof payload.responseData === "object" && !Array.isArray(payload.responseData) ? (payload.responseData as Record) : null; } function getDownloadEntryLabel(name: string, url: string) { const extension = getFileExtension(name) || getFileExtension(url); switch (extension) { case "step": case "stp": return "STEP"; case "glb": return "GLB"; case "gltf": return "GLTF"; case "kcl": return "KCL"; case "stl": return "STL"; case "obj": return "OBJ"; case "fbx": return "FBX"; case "3mf": return "3MF"; default: return getDownloadLabel(url); } } function buildDownloadEntries(version?: GenerationVersion | null) { const entries = Object.entries(version?.downloadUrls || {}).filter( (entry): entry is [string, string] => typeof entry[0] === "string" && entry[0].trim().length > 0 && typeof entry[1] === "string" && entry[1].trim().length > 0 ); if ( version?.modelUrl && !entries.some( ([, value]) => String(value || "").trim() === String(version.modelUrl || "").trim() ) ) { entries.unshift([ `model.${getFileExtension(version.modelUrl) || "glb"}`, version.modelUrl, ]); } return entries .map(([name, url]) => ({ key: `${name}:${url}`, name, url, label: getDownloadEntryLabel(name, url), extension: getFileExtension(name) || getFileExtension(url), })) .sort((left, right) => { const leftIndex = DOWNLOAD_ORDER.indexOf( left.extension as (typeof DOWNLOAD_ORDER)[number] ); const rightIndex = DOWNLOAD_ORDER.indexOf( right.extension as (typeof DOWNLOAD_ORDER)[number] ); const safeLeftIndex = leftIndex === -1 ? DOWNLOAD_ORDER.length : leftIndex; const safeRightIndex = rightIndex === -1 ? DOWNLOAD_ORDER.length : rightIndex; return ( safeLeftIndex - safeRightIndex || left.label.localeCompare(right.label, "zh-CN") ); }); } function buildVersionExecutionSummary( version: GenerationVersion, params?: { parentVersion?: GenerationVersion | null; downloadEntries?: ReturnType; previewSourceVersion?: GenerationVersion | null; } ) { const summaryLines = [ version.generationMode === "iterate" ? `这轮采用连续修改模式,基于 ${params?.parentVersion ? `V${params.parentVersion.versionNumber}` : "上一轮 CAD 结果"} 继续推进,而不是重新从零开始。` : "这轮采用首轮创建模式,系统会把当前自然语言描述当成一个新的 CAD 起点来组织结构。", `当前走的是 ${getProviderLabel(version.provider)} 链路,状态为“${getStatusLabel(version.status)}”。`, params?.downloadEntries && params.downloadEntries.length > 0 ? `服务端目前已经返回这些结果:${params.downloadEntries .slice(0, 5) .map((entry) => entry.label) .join(" / ")}。` : "服务端还没有返回可下载文件,通常意味着任务还在排队、执行中,或这轮结果只回来了部分代码。", version.codeUrl && !version.modelUrl ? "这一轮目前更偏代码结果,KCL 已可查看,但可预览的 3D 结果还没完全回到当前版本。" : "当前版本已经具备可继续查看、下载或作为下一轮参考的结果基础。", params?.previewSourceVersion && params.previewSourceVersion.id !== version.id ? `由于当前版本暂时没有新的可视化模型,右侧 3D 预览会继续借用 V${params.previewSourceVersion.versionNumber} 作为参考。` : "右侧预览会优先展示当前版本自己的模型结果,方便你直接判断这一轮是否达到了目标。", version.feedback === "positive" ? "你已经把这轮结果标记为“有帮助”,这有助于后续继续沿着当前方向迭代。" : version.feedback === "negative" ? "你已经把这轮结果标记为“不理想”,后续更适合在下一轮明确指出要保留和要修正的部分。" : "如果这轮方向对了,可以直接继续细化;如果偏了,建议下一轮明确写出“保留什么、修改什么、删除什么”。", ]; return summaryLines.join("\n\n"); } function buildFollowUpSuggestions(params: { activeVersion?: GenerationVersion | null; activeParentVersion?: GenerationVersion | null; previewSourceVersion?: GenerationVersion | null; semanticFocus?: SemanticDiffInsight[]; }) { const { activeVersion, activeParentVersion, previewSourceVersion, semanticFocus, } = params; const primarySemanticFocus = semanticFocus?.[0] ?? null; if (!activeVersion) { return [...SUGGESTED_PROMPTS]; } if (activeVersion.status === "failed") { return [ "把需求拆短一点,先只描述主体轮廓和核心结构。", "明确写出保留什么、删除什么、替换什么,再重新生成。", "先做一个更基础的版本,下一轮再补细节和制造约束。", ]; } if (activeVersion.status === "queued" || activeVersion.status === "running") { return [ "等这轮完成后,继续细化连接结构、壁厚和加强筋。", "下一轮只改局部,保留主体轮廓,优化支撑与装配方式。", "如果方向对了,再补充材料、公差和可制造性约束。", ]; } if (activeVersion.feedback === "negative") { return [ `保留 ${activeParentVersion ? `V${activeParentVersion.versionNumber}` : "上一轮"} 的整体比例,重做当前版本最突出的结构问题。`, primarySemanticFocus ? `不要改主体外轮廓,只重新梳理${primarySemanticFocus.label},把目标值和限制条件说清楚。` : "不要改主体外轮廓,只把局部支撑、转角和厚度重新组织。", "按更工程化的方式重写需求:外形、连接、装配、公差分别说明。", ]; } if (activeVersion.generationMode === "iterate") { return [ primarySemanticFocus ? `继续沿着 V${activeVersion.versionNumber} 往下改,但这次只聚焦 ${primarySemanticFocus.label},别同时改太多区域。` : `继续沿着 V${activeVersion.versionNumber} 往下改,但只微调局部结构和连接细节。`, primarySemanticFocus ? `围绕 ${primarySemanticFocus.label} 继续细化,把目标数值、保留项和不能动的区域一起写清楚。` : "保留当前整体语言,补充圆角、壁厚、拔模和装配干涉约束。", previewSourceVersion && previewSourceVersion.id !== activeVersion.id ? `以当前代码结果为准,再让可视化结果追上 V${activeVersion.versionNumber} 的修改方向。` : primarySemanticFocus ? SEMANTIC_DIFF_CATEGORIES[primarySemanticFocus.key]?.followUpHint || "不要改主体轮廓,只把当前版本做得更轻量、更容易制造。" : "不要改主体轮廓,只把当前版本做得更轻量、更容易制造。", ]; } return [ primarySemanticFocus ? `基于 V${activeVersion.versionNumber} 继续细化:先保留主体比例,重点收紧 ${primarySemanticFocus.label}。` : `基于 V${activeVersion.versionNumber} 继续细化:保留主体比例,优化局部结构和连接方式。`, "在当前版本上增加工程约束,例如壁厚、公差、卡扣和加强筋。", primarySemanticFocus ? SEMANTIC_DIFF_CATEGORIES[primarySemanticFocus.key]?.followUpHint || "如果这个方向对了,下一轮可以只针对一个局部做更明确的修改说明。" : "如果这个方向对了,下一轮可以只针对一个局部做更明确的修改说明。", ]; } function getStructuredDirectivePlaceholder( key: StructuredPromptDirectiveKey, version?: GenerationVersion | null ) { switch (key) { case "keep": return version ? `V${version.versionNumber} 的主体比例、核心轮廓和装配关系` : "主体比例、核心轮廓和主要装配关系"; case "change": return "局部支撑、转角、连接方式和需要细化的区域"; case "remove": return "冗余装饰、过厚区域和不必要的悬挑"; case "constraints": return "补充壁厚、公差、圆角、工艺和装配要求"; case "material": return "说明材料、表面处理、使用场景和手感偏好"; default: return ""; } } function extractStructuredPromptDirectives(prompt: string) { return STRUCTURED_PROMPT_DIRECTIVES.flatMap((directive) => { const pattern = new RegExp( `(?:^|\\n)${escapeRegExp(directive.label)}[::]\\s*([^\\n]+)`, "m" ); const matchedValue = prompt.match(pattern)?.[1]?.trim(); if (!matchedValue) { return []; } return [ { key: directive.key, label: directive.label, helper: directive.helper, value: matchedValue, } satisfies StructuredPromptDirective, ]; }); } function upsertStructuredPromptDirective( prompt: string, directiveKey: StructuredPromptDirectiveKey, version?: GenerationVersion | null ) { const directive = STRUCTURED_PROMPT_DIRECTIVES.find( (item) => item.key === directiveKey ); if (!directive) { return prompt; } const nextLine = `${directive.label}:${getStructuredDirectivePlaceholder( directiveKey, version )}`; const normalizedPrompt = prompt.trimEnd(); const hasLine = new RegExp( `(?:^|\\n)${escapeRegExp(directive.label)}[::]\\s*`, "m" ).test(normalizedPrompt); if (hasLine) { return prompt; } return normalizedPrompt ? `${normalizedPrompt}\n${nextLine}` : nextLine; } function buildStructuredPromptTemplate( prompt: string, version?: GenerationVersion | null ) { return STRUCTURED_PROMPT_DIRECTIVES.reduce( (currentPrompt, directive) => upsertStructuredPromptDirective(currentPrompt, directive.key, version), prompt.trimEnd() ); } function buildVersionCompareNotes(params: { activeVersion?: GenerationVersion | null; parentVersion?: GenerationVersion | null; previewSourceVersion?: GenerationVersion | null; }) { const { activeVersion, parentVersion, previewSourceVersion } = params; if (!activeVersion) { return []; } const activeDirectives = extractStructuredPromptDirectives( activeVersion.prompt ); const notes: string[] = []; if (activeDirectives.length > 0) { notes.push( ...activeDirectives.map( (directive) => `${directive.label}:${directive.value}` ) ); } else if (parentVersion && activeVersion.prompt !== parentVersion.prompt) { notes.push( `这轮需求从“${truncateText(parentVersion.prompt, 48)}”推进到了“${truncateText(activeVersion.prompt, 64)}”。` ); } else { notes.push( `当前版本的核心要求是:${truncateText(activeVersion.prompt, 72)}。` ); } if (parentVersion && activeVersion.generationMode === "iterate") { notes.push( `这轮沿着 V${parentVersion.versionNumber} 继续修改,适合把关注点缩小到局部结构和制造约束。` ); } if (previewSourceVersion && previewSourceVersion.id !== activeVersion.id) { notes.push( `当前 3D 预览仍借用了 V${previewSourceVersion.versionNumber},说明这轮更偏代码或结构迭代结果。` ); } if (parentVersion && activeVersion.provider !== parentVersion.provider) { notes.push( `当前版本的生成链路已经切换到 ${getProviderLabel(activeVersion.provider)}。` ); } return notes.slice(0, 5); } function buildVersionChildrenMap(versions: GenerationVersion[]) { const map = new Map(); for (const version of versions) { if (!version.parentVersionId) { continue; } const currentChildren = map.get(version.parentVersionId) ?? []; currentChildren.push(version); map.set( version.parentVersionId, currentChildren.sort( (left, right) => left.versionNumber - right.versionNumber ) ); } return map; } function buildVersionLineage( version: GenerationVersion | null, versions: GenerationVersion[] ) { if (!version) { return []; } const lineage: GenerationVersion[] = []; const visitedVersionIds = new Set(); let currentVersion: GenerationVersion | null = version; while (currentVersion) { if (visitedVersionIds.has(currentVersion.id)) { break; } visitedVersionIds.add(currentVersion.id); lineage.unshift(currentVersion); currentVersion = currentVersion.parentVersionId ? findVersionById(versions, currentVersion.parentVersionId) : null; } return lineage; } function getVersionDepth( version: GenerationVersion, versions: GenerationVersion[] ) { let depth = 0; let currentParentId = version.parentVersionId; while (currentParentId) { depth += 1; currentParentId = findVersionById(versions, currentParentId)?.parentVersionId ?? null; } return depth; } function getStatusDotClass(status: Model3DTaskStatus, isActive: boolean) { if (isActive) { return "bg-white"; } switch (status) { case "success": return "bg-emerald-300"; case "running": return "bg-sky-300"; case "queued": return "bg-amber-300"; case "failed": case "banned": case "cancelled": case "expired": return "bg-rose-300"; default: return "bg-white/35"; } } function getSiblingVersionCount( version: GenerationVersion, versions: GenerationVersion[] ) { return versions.filter( (candidate) => candidate.parentVersionId === version.parentVersionId ).length; } function buildCompareCandidateVersions( activeVersion: GenerationVersion | null, versions: GenerationVersion[] ) { if (!activeVersion) { return []; } const candidateVersions: GenerationVersion[] = []; const seenVersionIds = new Set([activeVersion.id]); const pushCandidate = (version: GenerationVersion | null) => { if (!version || seenVersionIds.has(version.id)) { return; } seenVersionIds.add(version.id); candidateVersions.push(version); }; pushCandidate( activeVersion.parentVersionId ? findVersionById(versions, activeVersion.parentVersionId) : null ); const siblingCandidates = versions .filter( (version) => version.parentVersionId === activeVersion.parentVersionId && version.id !== activeVersion.id ) .sort((left, right) => right.versionNumber - left.versionNumber); for (const version of siblingCandidates) { pushCandidate(version); } const childCandidates = versions .filter((version) => version.parentVersionId === activeVersion.id) .sort((left, right) => right.versionNumber - left.versionNumber); for (const version of childCandidates) { pushCandidate(version); } const lineageCandidates = buildVersionLineage(activeVersion, versions) .slice(0, -1) .reverse(); for (const version of lineageCandidates) { pushCandidate(version); } const fallbackCandidates = versions .slice() .sort((left, right) => right.versionNumber - left.versionNumber); for (const version of fallbackCandidates) { pushCandidate(version); } return candidateVersions; } function buildVersionDiffHighlights(params: { activeVersion?: GenerationVersion | null; compareVersion?: GenerationVersion | null; comparePreviewSourceVersion?: GenerationVersion | null; previewSourceVersion?: GenerationVersion | null; }) { const { activeVersion, compareVersion, comparePreviewSourceVersion, previewSourceVersion, } = params; if (!activeVersion || !compareVersion) { return []; } const activeDirectives = extractStructuredPromptDirectives( activeVersion.prompt ); const compareDirectives = extractStructuredPromptDirectives( compareVersion.prompt ); const compareDirectiveMap = new Map( compareDirectives.map((directive) => [directive.key, directive]) ); const activeDirectiveMap = new Map( activeDirectives.map((directive) => [directive.key, directive]) ); const highlights: string[] = []; for (const directive of activeDirectives) { const matchedDirective = compareDirectiveMap.get(directive.key); if (!matchedDirective) { highlights.push( `当前版本新增了“${directive.label}”要求:${truncateText(directive.value, 40)}。` ); continue; } if (matchedDirective.value !== directive.value) { highlights.push( `“${directive.label}”从“${truncateText(matchedDirective.value, 24)}”调整为“${truncateText(directive.value, 24)}”。` ); } } for (const directive of compareDirectives) { if (activeDirectiveMap.has(directive.key)) { continue; } highlights.push( `相较于 V${compareVersion.versionNumber},当前版本不再显式强调“${directive.label}”。` ); } if ( highlights.length === 0 && activeVersion.prompt !== compareVersion.prompt ) { highlights.push( `这轮描述从“${truncateText(compareVersion.prompt, 36)}”推进到了“${truncateText(activeVersion.prompt, 52)}”。` ); } if ( comparePreviewSourceVersion && comparePreviewSourceVersion.id !== compareVersion.id ) { highlights.push( `对照版本的 3D 预览当前借用了 V${comparePreviewSourceVersion.versionNumber}。` ); } if (previewSourceVersion && previewSourceVersion.id !== activeVersion.id) { highlights.push( `当前版本的 3D 预览暂时借用了 V${previewSourceVersion.versionNumber}。` ); } if (activeVersion.provider !== compareVersion.provider) { highlights.push( `两版使用的生成链路不同:当前为 ${getProviderLabel(activeVersion.provider)},对照为 ${getProviderLabel(compareVersion.provider)}。` ); } return highlights.slice(0, 5); } function splitCodeLines(code: string) { if (!code.trim()) { return []; } return code.replace(/\r\n/g, "\n").split("\n"); } function buildCodeDiffRows(leftCode: string, rightCode: string) { const leftLines = splitCodeLines(leftCode); const rightLines = splitCodeLines(rightCode); const matrix = Array.from({ length: leftLines.length + 1 }, () => Array.from({ length: rightLines.length + 1 }, () => 0) ); for (let leftIndex = leftLines.length - 1; leftIndex >= 0; leftIndex -= 1) { for ( let rightIndex = rightLines.length - 1; rightIndex >= 0; rightIndex -= 1 ) { matrix[leftIndex][rightIndex] = leftLines[leftIndex] === rightLines[rightIndex] ? matrix[leftIndex + 1][rightIndex + 1] + 1 : Math.max( matrix[leftIndex + 1][rightIndex], matrix[leftIndex][rightIndex + 1] ); } } const rawRows: Pick< CodeDiffRow, "kind" | "leftLineNumber" | "leftText" | "rightLineNumber" | "rightText" >[] = []; let leftIndex = 0; let rightIndex = 0; while (leftIndex < leftLines.length && rightIndex < rightLines.length) { if (leftLines[leftIndex] === rightLines[rightIndex]) { rawRows.push({ kind: "equal", leftLineNumber: leftIndex + 1, leftText: leftLines[leftIndex], rightLineNumber: rightIndex + 1, rightText: rightLines[rightIndex], }); leftIndex += 1; rightIndex += 1; continue; } if ( matrix[leftIndex + 1][rightIndex] >= matrix[leftIndex][rightIndex + 1] ) { rawRows.push({ kind: "removed", leftLineNumber: leftIndex + 1, leftText: leftLines[leftIndex], rightLineNumber: null, rightText: "", }); leftIndex += 1; continue; } rawRows.push({ kind: "added", leftLineNumber: null, leftText: "", rightLineNumber: rightIndex + 1, rightText: rightLines[rightIndex], }); rightIndex += 1; } while (leftIndex < leftLines.length) { rawRows.push({ kind: "removed", leftLineNumber: leftIndex + 1, leftText: leftLines[leftIndex], rightLineNumber: null, rightText: "", }); leftIndex += 1; } while (rightIndex < rightLines.length) { rawRows.push({ kind: "added", leftLineNumber: null, leftText: "", rightLineNumber: rightIndex + 1, rightText: rightLines[rightIndex], }); rightIndex += 1; } const rows: CodeDiffRow[] = []; const summary = { added: 0, changed: 0, removed: 0, unchanged: 0, }; let index = 0; while (index < rawRows.length) { const currentRow = rawRows[index]; if (currentRow.kind === "equal") { rows.push(currentRow); summary.unchanged += 1; index += 1; continue; } const removedRows: typeof rawRows = []; const addedRows: typeof rawRows = []; while (index < rawRows.length && rawRows[index]?.kind !== "equal") { const row = rawRows[index]; if (row?.kind === "removed") { removedRows.push(row); } else if (row?.kind === "added") { addedRows.push(row); } index += 1; } const sharedLength = Math.min(removedRows.length, addedRows.length); for (let pairIndex = 0; pairIndex < sharedLength; pairIndex += 1) { rows.push({ kind: "changed", leftLineNumber: removedRows[pairIndex]?.leftLineNumber ?? null, leftText: removedRows[pairIndex]?.leftText ?? "", rightLineNumber: addedRows[pairIndex]?.rightLineNumber ?? null, rightText: addedRows[pairIndex]?.rightText ?? "", }); summary.changed += 1; } for (const row of removedRows.slice(sharedLength)) { rows.push(row); summary.removed += 1; } for (const row of addedRows.slice(sharedLength)) { rows.push(row); summary.added += 1; } } return { rows, summary }; } function buildCodeDiffInsights(rows: CodeDiffRow[]) { const insights: string[] = []; let index = 0; while (index < rows.length && insights.length < 4) { if (rows[index]?.kind === "equal") { index += 1; continue; } const hunkRows: CodeDiffRow[] = []; while (index < rows.length && rows[index]?.kind !== "equal") { const row = rows[index]; if (row) { hunkRows.push(row); } index += 1; } if (hunkRows.length === 0) { continue; } const addedCount = hunkRows.filter((row) => row.kind === "added").length; const removedCount = hunkRows.filter( (row) => row.kind === "removed" ).length; const changedCount = hunkRows.filter( (row) => row.kind === "changed" ).length; const firstLeftLine = hunkRows.find( (row) => row.leftLineNumber )?.leftLineNumber; const firstRightLine = hunkRows.find( (row) => row.rightLineNumber )?.rightLineNumber; const focusSnippet = truncateText( hunkRows.find((row) => row.rightText.trim())?.rightText || hunkRows.find((row) => row.leftText.trim())?.leftText || "", 44 ); const locationLabel = firstRightLine || firstLeftLine ? `第 ${firstRightLine ?? firstLeftLine} 行附近` : "当前代码块中"; const summaryParts = [ changedCount > 0 ? `改写 ${changedCount} 行` : null, addedCount > 0 ? `新增 ${addedCount} 行` : null, removedCount > 0 ? `删除 ${removedCount} 行` : null, ].filter(Boolean); insights.push( `${locationLabel}${summaryParts.length > 0 ? `主要${summaryParts.join(",")}` : "发生了调整"},重点片段:${focusSnippet}。` ); } return insights; } function summarizeSemanticDiffCount(matchCount: number) { if (matchCount >= 6) { return "高频改动"; } if (matchCount >= 3) { return "连续改动"; } return "局部改动"; } function collectSemanticInsightsFromTexts( texts: string[], options?: { limit?: number; } ) { const matches = new Map< string, { count: number; examples: string[]; } >(); for (const rawText of texts) { const text = rawText.trim(); if (!text) { continue; } for (const [key, category] of Object.entries(SEMANTIC_DIFF_CATEGORIES)) { const hasMatchedKeyword = category.keywords.some((keyword) => keyword.test(text) ); if (!hasMatchedKeyword) { continue; } const current = matches.get(key) || { count: 0, examples: [], }; current.count += 1; if (current.examples.length < 2 && !current.examples.includes(text)) { current.examples.push(truncateText(text, 60)); } matches.set(key, current); } } return Array.from(matches.entries()) .map(([key, match]) => { const category = SEMANTIC_DIFF_CATEGORIES[key]; if (!category) { return null; } return { count: match.count, detail: `${summarizeSemanticDiffCount(match.count)},${category.description}`, examples: match.examples, key, label: category.label, shortLabel: category.shortLabel, } satisfies SemanticDiffInsight; }) .filter((insight): insight is SemanticDiffInsight => Boolean(insight)) .sort((left, right) => right.count - left.count) .slice(0, options?.limit ?? 4); } function buildSemanticDiffInsights(rows: CodeDiffRow[]) { const diffTexts = rows.flatMap((row) => row.kind === "equal" ? [] : [row.leftText, row.rightText] .map((value) => value.trim()) .filter(Boolean) ); return collectSemanticInsightsFromTexts(diffTexts); } function buildVersionSemanticInsights(version?: GenerationVersion | null) { if (!version) { return []; } const structuredTexts = extractStructuredPromptDirectives(version.prompt).map( (directive) => `${directive.label}:${directive.value}` ); return collectSemanticInsightsFromTexts( [version.prompt, ...structuredTexts], { limit: 3 } ); } function getCodeDiffRowClass(kind: CodeDiffKind, side: "left" | "right") { if (kind === "equal") { return "bg-transparent"; } if (kind === "changed") { return "bg-amber-50"; } if (kind === "removed") { return side === "left" ? "bg-rose-50" : "bg-rose-50/55"; } return side === "right" ? "bg-emerald-50" : "bg-emerald-50/55"; } function AiCadCodeDiffPane({ code, emptyMessage, error, loading, onCopy, openUrl, rowsEmptyMessage, rows, side, subtitle, title, }: { code?: string | null; emptyMessage: string; error?: string | null; loading: boolean; onCopy?: (code: string) => void; openUrl?: string | null; rowsEmptyMessage?: string; rows: CodeDiffRow[]; side: "left" | "right"; subtitle: string; title: string; }) { return (
{title}

{subtitle}

{code ? ( ) : null} {openUrl ? ( ) : null}
{loading ? (
正在加载代码差异...
) : error ? (
{error}
) : rows.length === 0 ? (
{rowsEmptyMessage || "当前过滤条件下没有可显示的差异行。"}
) : code ? (
{rows.map((row, index) => { const lineNumber = side === "left" ? row.leftLineNumber : row.rightLineNumber; const lineText = side === "left" ? row.leftText : row.rightText; const isPlaceholder = lineNumber === null; return (
{lineNumber ?? ""}
                      {isPlaceholder ? " " : lineText || " "}
                    
); })}
) : (
{emptyMessage}
)}
); } function buildAssistantMessage( versionNumber: number, status: Model3DTaskStatus, progress?: number | null, errorMessage?: string | null ) { const prefix = `V${versionNumber}`; switch (status) { case "queued": return `${prefix} 已进入队列,AI-CAD 正在整理这轮设计意图。`; case "running": return `${prefix} 正在生成中${typeof progress === "number" ? `,当前进度 ${Math.max(0, Math.round(progress))}%` : ""}。你可以继续思考下一轮修改方向。`; case "success": return `${prefix} 已完成。现在可以继续描述修改意见,我会把这轮结果当成新的版本节点。`; case "failed": return `${prefix} 生成失败${errorMessage ? `:${errorMessage}` : "。请换一种更具体的结构描述再试一次。"}`; case "banned": return `${prefix} 被服务端拦截,请调整描述内容后重试。`; case "expired": return `${prefix} 已过期,请重新发起生成。`; case "cancelled": return `${prefix} 已取消。`; default: return `${prefix} 状态暂不可用,稍后会继续同步。`; } } function findVersionById( versions: GenerationVersion[], versionId?: string | null ) { if (!versionId) { return null; } return versions.find((version) => version.id === versionId) ?? null; } function getPreviewVersion( version: GenerationVersion | null, versions: GenerationVersion[] ) { const visitedVersionIds = new Set(); let currentVersion = version; while (currentVersion) { if (currentVersion.modelUrl || currentVersion.previewImageUrl) { return currentVersion; } if (!currentVersion.parentVersionId) { return null; } if (visitedVersionIds.has(currentVersion.parentVersionId)) { return null; } visitedVersionIds.add(currentVersion.parentVersionId); currentVersion = findVersionById(versions, currentVersion.parentVersionId); } return null; } function canIterateKittyCadVersion(version: GenerationVersion | null) { return ( version?.provider === "kittycad-cad" && version.status === "success" && Boolean(version.taskId) ); } function createWelcomeMessages(): ChatMessage[] { return [ { id: createId(), role: "assistant", createdAt: Date.now(), state: "success", content: "欢迎来到 AI-CAD。这个工作台先用对话式方式组织每一轮 3D 生成,当前 MVP 会把每轮描述落成一个新版本,方便我们继续往下迭代。", }, ]; } function createDefaultWorkbenchState( provider: CadGenerationProvider = "kittycad-cad" ): PersistedAiCadWorkbenchState { return { prompt: "", generationProvider: provider, modelVersion: MODEL_VERSION_OPTIONS[0], viewerSettings: DEFAULT_VIEWER_SETTINGS, messages: createWelcomeMessages(), versions: [], activeVersionId: null, }; } function normalizeWorkspaceTitle(title: string, fallback = "AI-CAD 工作台") { const normalizedTitle = title.trim().replace(/\s+/g, " "); return normalizedTitle || fallback; } function isDefaultWorkspaceTitle(title: string) { return DEFAULT_WORKSPACE_TITLE_RE.test(title.trim()); } function buildWorkspaceTitleFromPrompt(prompt: string) { const normalizedPrompt = prompt .trim() .replace(/\s+/g, " ") .replace(/[,。;;、,.!?!?]+$/g, ""); if (!normalizedPrompt) { return "AI-CAD 工作台"; } return normalizedPrompt.length > 22 ? `${normalizedPrompt.slice(0, 22).trim()}...` : normalizedPrompt; } function getWorkbenchStorageKey(workspaceId: string) { return `${AI_CAD_WORKBENCH_STORAGE_PREFIX}:${workspaceId}`; } function readPersistedActiveWorkspaceId() { if (typeof window === "undefined") { return null; } const value = window.localStorage.getItem( AI_CAD_ACTIVE_WORKSPACE_STORAGE_KEY ); return typeof value === "string" && value.trim() ? value : null; } function writePersistedActiveWorkspaceId(workspaceId: string | null) { if (typeof window === "undefined") { return; } if (!workspaceId) { window.localStorage.removeItem(AI_CAD_ACTIVE_WORKSPACE_STORAGE_KEY); return; } window.localStorage.setItem(AI_CAD_ACTIVE_WORKSPACE_STORAGE_KEY, workspaceId); } function removePersistedWorkbench(workspaceId: string) { if (typeof window === "undefined") { return; } window.localStorage.removeItem(getWorkbenchStorageKey(workspaceId)); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function isCadGenerationProvider( value: unknown ): value is CadGenerationProvider { return value === "kittycad-cad" || value === "tripo-3d"; } function isChatMessageState(value: unknown): value is ChatMessageState { return ( value === "thinking" || value === "queued" || value === "running" || value === "success" || value === "failed" ); } function isVersionFeedback(value: unknown): value is VersionFeedback { return value === "positive" || value === "negative"; } function isModel3DTaskStatus(value: unknown): value is Model3DTaskStatus { return ( typeof value === "string" && MODEL_TASK_STATUSES.includes(value as Model3DTaskStatus) ); } function normalizeViewerSettings(value: unknown): Model3DViewerSettings { if (!isRecord(value)) { return DEFAULT_VIEWER_SETTINGS; } const envPreset = VIEWER_ENV_PRESETS.includes( value.envPreset as (typeof VIEWER_ENV_PRESETS)[number] ) ? (value.envPreset as Model3DViewerSettings["envPreset"]) : DEFAULT_VIEWER_SETTINGS.envPreset; return { ...DEFAULT_VIEWER_SETTINGS, cameraPreset: value.cameraPreset === "top" || value.cameraPreset === "front" || value.cameraPreset === "right" || value.cameraPreset === "iso" ? value.cameraPreset : DEFAULT_VIEWER_SETTINGS.cameraPreset, envPreset, lightIntensity: typeof value.lightIntensity === "number" && Number.isFinite(value.lightIntensity) ? value.lightIntensity : DEFAULT_VIEWER_SETTINGS.lightIntensity, autoRotate: typeof value.autoRotate === "boolean" ? value.autoRotate : DEFAULT_VIEWER_SETTINGS.autoRotate, showGrid: typeof value.showGrid === "boolean" ? value.showGrid : DEFAULT_VIEWER_SETTINGS.showGrid, wireframe: typeof value.wireframe === "boolean" ? value.wireframe : DEFAULT_VIEWER_SETTINGS.wireframe, }; } function normalizeMessages(value: unknown): ChatMessage[] { if (!Array.isArray(value)) { return createWelcomeMessages(); } const messages = value.flatMap((item) => { if (!isRecord(item)) { return []; } const role: ChatMessage["role"] | null = item.role === "assistant" || item.role === "user" ? item.role : null; const content = typeof item.content === "string" ? item.content : null; if (!role || content === null) { return []; } return [ { id: typeof item.id === "string" && item.id.trim() ? item.id : createId(), role, content, createdAt: typeof item.createdAt === "number" && Number.isFinite(item.createdAt) ? item.createdAt : Date.now(), state: isChatMessageState(item.state) ? item.state : undefined, versionId: typeof item.versionId === "string" && item.versionId.trim() ? item.versionId : undefined, }, ]; }); return messages.length > 0 ? messages : createWelcomeMessages(); } function normalizeDownloadUrls(value: unknown): Record | null { if (!isRecord(value)) { return null; } const entries = Object.entries(value).filter( (entry): entry is [string, string] => typeof entry[0] === "string" && entry[0].trim().length > 0 && typeof entry[1] === "string" && entry[1].trim().length > 0 ); return entries.length > 0 ? Object.fromEntries(entries) : null; } function normalizeVersions(value: unknown): GenerationVersion[] { if (!Array.isArray(value)) { return []; } return value.flatMap((item, index) => { if (!isRecord(item)) { return []; } const id = typeof item.id === "string" && item.id.trim() ? item.id : null; const taskId = typeof item.taskId === "string" && item.taskId.trim() ? item.taskId : null; const prompt = typeof item.prompt === "string" ? item.prompt : null; const modelVersion = typeof item.modelVersion === "string" && item.modelVersion.trim() ? item.modelVersion : null; const assistantMessageId = typeof item.assistantMessageId === "string" && item.assistantMessageId.trim() ? item.assistantMessageId : null; if ( !id || !taskId || prompt === null || !modelVersion || !assistantMessageId ) { return []; } return [ { id, versionNumber: typeof item.versionNumber === "number" && Number.isInteger(item.versionNumber) && item.versionNumber > 0 ? item.versionNumber : index + 1, provider: isCadGenerationProvider(item.provider) ? item.provider : "kittycad-cad", taskId, generationMode: item.generationMode === "iterate" ? "iterate" : "create", parentVersionId: typeof item.parentVersionId === "string" && item.parentVersionId.trim() ? item.parentVersionId : null, prompt, modelVersion, createdAt: typeof item.createdAt === "number" && Number.isFinite(item.createdAt) ? item.createdAt : Date.now(), status: isModel3DTaskStatus(item.status) ? item.status : "queued", progress: typeof item.progress === "number" && Number.isFinite(item.progress) ? item.progress : null, modelUrl: readNullableText(item.modelUrl), previewImageUrl: readNullableText(item.previewImageUrl), codeUrl: readNullableText(item.codeUrl), errorMessage: readNullableText(item.errorMessage), conversationId: readNullableText(item.conversationId), downloadUrls: normalizeDownloadUrls(item.downloadUrls), feedback: isVersionFeedback(item.feedback) ? item.feedback : null, assistantMessageId, }, ]; }); } function normalizePersistedWorkbenchState( value: unknown ): PersistedAiCadWorkbenchState | null { if (!isRecord(value)) { return null; } const versions = normalizeVersions(value.versions); const activeVersionId = typeof value.activeVersionId === "string" && versions.some((version) => version.id === value.activeVersionId) ? value.activeVersionId : (versions[0]?.id ?? null); return { prompt: typeof value.prompt === "string" ? value.prompt : "", generationProvider: isCadGenerationProvider(value.generationProvider) ? value.generationProvider : "kittycad-cad", modelVersion: typeof value.modelVersion === "string" && MODEL_VERSION_OPTIONS.includes( value.modelVersion as (typeof MODEL_VERSION_OPTIONS)[number] ) ? value.modelVersion : MODEL_VERSION_OPTIONS[0], viewerSettings: normalizeViewerSettings(value.viewerSettings), messages: normalizeMessages(value.messages), versions, activeVersionId, savedAt: typeof value.savedAt === "number" && Number.isFinite(value.savedAt) ? value.savedAt : undefined, }; } function serializeWorkbenchState(state: PersistedAiCadWorkbenchState) { const { savedAt: _savedAt, ...payload } = state; return JSON.stringify(payload); } function serializeWorkspaceSyncState(params: { name?: string | null; state: PersistedAiCadWorkbenchState; }) { return JSON.stringify({ name: normalizeWorkspaceTitle(params.name || ""), prompt: params.state.prompt, generationProvider: params.state.generationProvider, modelVersion: params.state.modelVersion, viewerSettings: params.state.viewerSettings, messages: params.state.messages, versions: params.state.versions, activeVersionId: params.state.activeVersionId, }); } function readPersistedWorkbench( workspaceId: string ): PersistedAiCadWorkbenchState | null { if (typeof window === "undefined") { return null; } try { const rawValue = window.localStorage.getItem( getWorkbenchStorageKey(workspaceId) ); if (!rawValue) { return null; } const parsedValue = JSON.parse(rawValue) as unknown; return normalizePersistedWorkbenchState(parsedValue); } catch (_error) { return null; } } function AiCadPanelResizeHandle({ className }: { className?: string }) { return (
); } function AiCadCollapsedPanel({ direction, icon: Icon, onExpand, title, }: { direction: "horizontal" | "vertical"; icon: typeof BrainCircuit; onExpand: () => void; title: string; }) { return (
{title}
); } function AiCadRailButton({ active, icon: Icon, label, onClick, shortLabel, }: { active: boolean; icon: typeof BrainCircuit; label: string; onClick: () => void; shortLabel: string; }) { return (
); } function AiCadToolbarGroup({ children, className, label, }: { children: ReactNode; className?: string; label: string; }) { return (
{label}
{children}
); } function AiCadOrientationCube({ className, onChange, value, }: { className?: string; onChange: (preset: Model3DViewerSettings["cameraPreset"]) => void; value: Model3DViewerSettings["cameraPreset"]; }) { const getFaceClasses = (preset: Model3DViewerSettings["cameraPreset"]) => cn( "cursor-pointer transition-all duration-150 ease-out", value === preset ? "fill-[#e9f7ef] stroke-[#2f8f5b]" : "fill-[#f7f3ea] stroke-[#c9c0ae] hover:fill-[#f1ebde]" ); const getLabelClasses = (preset: Model3DViewerSettings["cameraPreset"]) => cn( "pointer-events-none select-none fill-[#6f695d] text-[5px] font-medium uppercase tracking-[0.08em]", value === preset && "fill-[#1e5c3a]" ); return (
onChange("top")}> Top onChange("front")}> Front onChange("right")}> Right
); } function _AiCadMessageActionButton({ icon: Icon, label, onClick, }: { icon: typeof BrainCircuit; label: string; onClick: () => void; }) { return ( ); } function _AiCadStatusChip({ label, tone = "neutral", value, }: { label: string; tone?: "neutral" | "pending" | "success" | "warning"; value: string; }) { return (
{label} {value}
); } function getLatestWorkspacePrompt( prompt: string, versions: GenerationVersion[] ): string | null { const trimmedPrompt = prompt.trim(); if (trimmedPrompt) { return trimmedPrompt; } return ( versions.find((version) => version.prompt.trim())?.prompt.trim() || null ); } function buildWorkspaceSummaryFromWorkbench( workspace: WorkspaceListItem, state: { prompt: string; generationProvider: CadGenerationProvider; versions: GenerationVersion[]; } ): WorkspaceListItem { return { ...workspace, generationProvider: state.generationProvider, versionCount: state.versions.length, completedCount: state.versions.filter( (version) => version.status === "success" ).length, hasPendingTasks: state.versions.some( (version) => !FINAL_TASK_STATUS.has(version.status) ), latestPrompt: getLatestWorkspacePrompt(state.prompt, state.versions), }; } function sortWorkspaceList(workspaces: WorkspaceListItem[]) { return [...workspaces].sort((left, right) => { const leftUpdatedAt = left.updatedAt ? new Date(left.updatedAt).getTime() : 0; const rightUpdatedAt = right.updatedAt ? new Date(right.updatedAt).getTime() : 0; return rightUpdatedAt - leftUpdatedAt; }); } export function AiCadWorkbench() { const [prompt, setPrompt] = useState(""); const [generationProvider, setGenerationProvider] = useState("kittycad-cad"); const [modelVersion, setModelVersion] = useState( MODEL_VERSION_OPTIONS[0] ); const [kittycadProviderStatus, setKittycadProviderStatus] = useState(null); const [, setIsLoadingKittycadProviderStatus] = useState(true); const [viewerSettings, setViewerSettings] = useState( DEFAULT_VIEWER_SETTINGS ); const [isSubmitting, setIsSubmitting] = useState(false); const [panelDirection, setPanelDirection] = useState< "horizontal" | "vertical" >("vertical"); const [isConversationPanelCollapsed, setIsConversationPanelCollapsed] = useState(false); const [isHistoryPanelCollapsed, setIsHistoryPanelCollapsed] = useState(false); const [isResultPanelCollapsed, setIsResultPanelCollapsed] = useState(false); const [activeLeftPanelView, setActiveLeftPanelView] = useState< "project" | "tree" >("tree"); const [activeResultTab, setActiveResultTab] = useState<"preview" | "code">( "preview" ); const [compareVersionId, setCompareVersionId] = useState(null); const [showOnlyChangedCodeRows, setShowOnlyChangedCodeRows] = useState(false); const [cachedCodeByUrl, setCachedCodeByUrl] = useState< Record >({}); const [loadingCodePreviewByUrl, setLoadingCodePreviewByUrl] = useState< Record >({}); const [codePreviewErrorByUrl, setCodePreviewErrorByUrl] = useState< Record >({}); const [codeActionError, setCodeActionError] = useState(null); const [kclDraft, setKclDraft] = useState(""); const [kclEditPrompt, setKclEditPrompt] = useState(""); const [isApplyingKclEdit, setIsApplyingKclEdit] = useState(false); const [modelingSessionId, setModelingSessionId] = useState( null ); const [modelingConnectionState, setModelingConnectionState] = useState("disconnected"); const [modelingError, setModelingError] = useState(null); const [modelingSnapshotUrl, setModelingSnapshotUrl] = useState( null ); const [modelingPlaneId, setModelingPlaneId] = useState(null); const [modelingSketchId, setModelingSketchId] = useState(null); const [modelingSolidId, setModelingSolidId] = useState(null); const [modelingSelectedEntityIds, setModelingSelectedEntityIds] = useState< string[] >([]); const [modelingLogLines, setModelingLogLines] = useState([]); const [modelingWidth, setModelingWidth] = useState("120"); const [modelingHeight, setModelingHeight] = useState("60"); const [modelingDepth, setModelingDepth] = useState("25"); const [modelingCutSize, setModelingCutSize] = useState("2"); const [modelingEdgeIndices, setModelingEdgeIndices] = useState("0,1,2,3"); const [modelingCutType, setModelingCutType] = useState("fillet"); const [isModelingBusy, setIsModelingBusy] = useState(false); const [isModelingToolsDialogOpen, setIsModelingToolsDialogOpen] = useState(false); const [messages, setMessages] = useState( createWelcomeMessages ); const [versions, setVersions] = useState([]); const [activeVersionId, setActiveVersionId] = useState(null); const [expandedVersionDetails, setExpandedVersionDetails] = useState< Record >({}); const [workspaces, setWorkspaces] = useState([]); const [activeWorkspaceId, setActiveWorkspaceId] = useState( null ); const [isLoadingWorkspaceList, setIsLoadingWorkspaceList] = useState(true); const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false); const [isDeletingWorkspace, setIsDeletingWorkspace] = useState(false); const [workspaceOperationError, setWorkspaceOperationError] = useState< string | null >(null); const [hasHydratedFromStorage, setHasHydratedFromStorage] = useState(false); const [hasHydratedServerWorkspace, setHasHydratedServerWorkspace] = useState(false); const [workspaceSyncState, setWorkspaceSyncState] = useState< "loading" | "syncing" | "synced" | "error" >("loading"); const [workspaceSyncError, setWorkspaceSyncError] = useState( null ); const versionsRef = useRef(versions); const activeWorkspaceIdRef = useRef(null); const modelingSessionIdRef = useRef(null); const conversationPanelRef = useRef(null); const historyPanelRef = useRef(null); const resultPanelRef = useRef(null); const localSavedAtRef = useRef>({}); const lastServerSavedSnapshotRef = useRef>({}); const hasAutoCollapsedExplorerRef = useRef(false); const hasLocalEditsBeforeServerHydrationRef = useRef>( {} ); const isApplyingExternalWorkspaceRef = useRef(false); useEffect(() => { versionsRef.current = versions; }, [versions]); useEffect(() => { if (typeof window === "undefined") { return; } const mediaQuery = window.matchMedia("(min-width: 1024px)"); const syncPanelDirection = () => { setPanelDirection(mediaQuery.matches ? "horizontal" : "vertical"); }; syncPanelDirection(); if (typeof mediaQuery.addEventListener === "function") { mediaQuery.addEventListener("change", syncPanelDirection); return () => { mediaQuery.removeEventListener("change", syncPanelDirection); }; } mediaQuery.addListener(syncPanelDirection); return () => { mediaQuery.removeListener(syncPanelDirection); }; }, []); useEffect(() => { let cancelled = false; const loadWorkspaces = async () => { setIsLoadingWorkspaceList(true); setWorkspaceSyncState("loading"); setWorkspaceSyncError(null); setWorkspaceOperationError(null); const preferredWorkspaceId = readPersistedActiveWorkspaceId(); const result = await aiCadGenerationService.listWorkspaces(); if (cancelled) { return; } if (!result.success) { setWorkspaceSyncState("error"); setWorkspaceSyncError(result.error || "读取工作台列表失败"); setWorkspaceOperationError(result.error || "读取工作台列表失败"); setIsLoadingWorkspaceList(false); return; } let nextWorkspaces = result.data || []; if (nextWorkspaces.length === 0) { const createdResult = await aiCadGenerationService.createWorkspace(); if (cancelled) { return; } if (!createdResult.success || !createdResult.data) { setWorkspaceSyncState("error"); setWorkspaceSyncError(createdResult.error || "初始化默认工作台失败"); setWorkspaceOperationError( createdResult.error || "初始化默认工作台失败" ); setIsLoadingWorkspaceList(false); return; } nextWorkspaces = [createdResult.data]; } const sortedWorkspaces = sortWorkspaceList(nextWorkspaces); setWorkspaces(sortedWorkspaces); setActiveWorkspaceId( sortedWorkspaces.find( (workspace) => workspace.id === preferredWorkspaceId )?.id || sortedWorkspaces[0]?.id || null ); setIsLoadingWorkspaceList(false); }; swallowAsync(loadWorkspaces()); return () => { cancelled = true; }; }, []); useEffect(() => { activeWorkspaceIdRef.current = activeWorkspaceId; }, [activeWorkspaceId]); useEffect(() => { modelingSessionIdRef.current = modelingSessionId; }, [modelingSessionId]); useEffect(() => { writePersistedActiveWorkspaceId(activeWorkspaceId); }, [activeWorkspaceId]); useEffect( () => () => { if (!modelingSessionIdRef.current) { return; } swallowAsync( aiCadGenerationService.closeModelingSession( modelingSessionIdRef.current ) ); }, [] ); useEffect(() => { const hasActiveVersion = Boolean(activeVersionId || versions.length > 0); if ( panelDirection !== "horizontal" || isLoadingWorkspaceList || !hasHydratedFromStorage || !historyPanelRef.current ) { return; } if (!hasActiveVersion) { hasAutoCollapsedExplorerRef.current = true; historyPanelRef.current.collapse(); setIsHistoryPanelCollapsed(true); } else if (versions.length > 0 && hasAutoCollapsedExplorerRef.current) { hasAutoCollapsedExplorerRef.current = false; } }, [ activeVersionId, hasHydratedFromStorage, isLoadingWorkspaceList, panelDirection, versions.length, ]); useEffect(() => { if (!activeWorkspaceId) { setHasHydratedFromStorage(true); setHasHydratedServerWorkspace(true); return; } const fallbackProvider: CadGenerationProvider = kittycadProviderStatus?.configured === false ? "tripo-3d" : "kittycad-cad"; const applyWorkspaceSnapshot = (snapshot: PersistedAiCadWorkbenchState) => { isApplyingExternalWorkspaceRef.current = true; setPrompt(snapshot.prompt); setGenerationProvider(snapshot.generationProvider); setModelVersion(snapshot.modelVersion); setViewerSettings(snapshot.viewerSettings); setMessages(snapshot.messages); setVersions(snapshot.versions); setActiveVersionId(snapshot.activeVersionId); }; const persistedWorkbench = readPersistedWorkbench(activeWorkspaceId); localSavedAtRef.current[activeWorkspaceId] = persistedWorkbench?.savedAt || 0; hasLocalEditsBeforeServerHydrationRef.current[activeWorkspaceId] = false; setHasHydratedFromStorage(false); setHasHydratedServerWorkspace(false); setWorkspaceSyncState("loading"); setWorkspaceSyncError(null); setCodeActionError(null); if (persistedWorkbench) { applyWorkspaceSnapshot(persistedWorkbench); } else { applyWorkspaceSnapshot(createDefaultWorkbenchState(fallbackProvider)); } setHasHydratedFromStorage(true); let cancelled = false; const loadServerWorkspace = async () => { const result = await aiCadGenerationService.getWorkspace(activeWorkspaceId); if (cancelled) { return; } if (result.success && result.data) { const serverSnapshot = normalizePersistedWorkbenchState(result.data); const serverUpdatedAt = result.data.updatedAt ? new Date(result.data.updatedAt).getTime() : 0; if (serverSnapshot) { lastServerSavedSnapshotRef.current[activeWorkspaceId] = serializeWorkspaceSyncState({ name: result.data?.name, state: serverSnapshot, }); setWorkspaces((current) => current.map((workspace) => workspace.id === activeWorkspaceId ? buildWorkspaceSummaryFromWorkbench( { ...workspace, name: result.data?.name || workspace.name, updatedAt: result.data?.updatedAt || workspace.updatedAt, }, { prompt: serverSnapshot.prompt, generationProvider: serverSnapshot.generationProvider, versions: serverSnapshot.versions, } ) : workspace ) ); if ( !hasLocalEditsBeforeServerHydrationRef.current[activeWorkspaceId] && (!persistedWorkbench || serverUpdatedAt > (localSavedAtRef.current[activeWorkspaceId] || 0)) ) { localSavedAtRef.current[activeWorkspaceId] = serverUpdatedAt; applyWorkspaceSnapshot({ ...serverSnapshot, savedAt: serverUpdatedAt, }); } } setWorkspaceSyncState("synced"); setWorkspaceSyncError(null); } else if (result.success) { setWorkspaceSyncState("synced"); setWorkspaceSyncError(null); } else { setWorkspaceSyncState("error"); setWorkspaceSyncError(result.error || "读取云端工作台失败"); } setHasHydratedServerWorkspace(true); }; swallowAsync(loadServerWorkspace()); return () => { cancelled = true; }; }, [activeWorkspaceId, kittycadProviderStatus?.configured]); const activeVersion = versions.find((version) => version.id === activeVersionId) ?? versions[0] ?? null; const activeParentVersion = findVersionById( versions, activeVersion?.parentVersionId ); const activePreviewVersion = getPreviewVersion(activeVersion, versions); const previewSourceVersion = activePreviewVersion && activeVersion && activePreviewVersion.id !== activeVersion.id ? activePreviewVersion : null; const iterationBaseVersion = generationProvider === "kittycad-cad" && canIterateKittyCadVersion(activeVersion) ? activeVersion : null; const activeDownloadEntries = buildDownloadEntries(activeVersion); const primaryDownloadEntry = activeDownloadEntries[0] ?? null; const activeCodeEntry = activeDownloadEntries.find((entry) => entry.extension === "kcl") ?? null; const activeCodeUrl = activeVersion?.codeUrl || activeCodeEntry?.url || null; const activeCodeContent = activeCodeUrl ? cachedCodeByUrl[activeCodeUrl] : null; const isLoadingActiveCodePreview = activeCodeUrl ? Boolean(loadingCodePreviewByUrl[activeCodeUrl]) : false; const activeCodePreviewError = activeCodeUrl ? codePreviewErrorByUrl[activeCodeUrl] || null : null; const canEditActiveKcl = Boolean( activeVersion && activeVersion.provider === "kittycad-cad" && activeCodeUrl && activeCodeContent ); const activeExecutionSummary = activeVersion ? buildVersionExecutionSummary(activeVersion, { parentVersion: activeParentVersion, downloadEntries: activeDownloadEntries, previewSourceVersion, }) : null; const activeVersionStructuredDirectives = activeVersion ? extractStructuredPromptDirectives(activeVersion.prompt) : []; const versionCompareNotes = buildVersionCompareNotes({ activeVersion, parentVersion: activeParentVersion, previewSourceVersion, }); const versionChildrenMap = buildVersionChildrenMap(versions); const activeVersionLineage = buildVersionLineage(activeVersion, versions); const activeVersionLineageIds = new Set( activeVersionLineage.map((version) => version.id) ); const compareCandidateVersions = buildCompareCandidateVersions( activeVersion, versions ); const compareVersion = compareCandidateVersions.find( (version) => version.id === compareVersionId ) ?? null; const comparePreviewVersion = getPreviewVersion(compareVersion, versions); const comparePreviewSourceVersion = comparePreviewVersion && compareVersion && comparePreviewVersion.id !== compareVersion.id ? comparePreviewVersion : null; const compareDownloadEntries = buildDownloadEntries(compareVersion); const compareCodeEntry = compareDownloadEntries.find((entry) => entry.extension === "kcl") ?? null; const compareCodeUrl = compareVersion?.codeUrl || compareCodeEntry?.url || null; const compareCodeContent = compareCodeUrl ? cachedCodeByUrl[compareCodeUrl] : null; const isLoadingCompareCodePreview = compareCodeUrl ? Boolean(loadingCodePreviewByUrl[compareCodeUrl]) : false; const compareCodePreviewError = compareCodeUrl ? codePreviewErrorByUrl[compareCodeUrl] || null : null; const compareStructuredDirectives = compareVersion ? extractStructuredPromptDirectives(compareVersion.prompt) : []; const versionDiffHighlights = buildVersionDiffHighlights({ activeVersion, compareVersion, comparePreviewSourceVersion, previewSourceVersion, }); const rootVersionCount = versions.filter( (version) => !version.parentVersionId ).length; const branchPointCount = versions.filter( (version) => (versionChildrenMap.get(version.id)?.length ?? 0) > 1 ).length; const derivedVariantCount = versions.filter((version) => Boolean(version.parentVersionId) ).length; const activeCodeLineCount = activeCodeContent ? activeCodeContent.split(NEWLINE_SPLIT_RE).length : 0; const compareCodeLineCount = compareCodeContent ? compareCodeContent.split(NEWLINE_SPLIT_RE).length : 0; const codeDiffResult = useMemo( () => buildCodeDiffRows(compareCodeContent || "", activeCodeContent || ""), [activeCodeContent, compareCodeContent] ); const visibleCodeDiffRows = useMemo( () => showOnlyChangedCodeRows ? codeDiffResult.rows.filter((row) => row.kind !== "equal") : codeDiffResult.rows, [codeDiffResult.rows, showOnlyChangedCodeRows] ); const codeDiffInsights = useMemo( () => buildCodeDiffInsights(codeDiffResult.rows), [codeDiffResult.rows] ); const semanticCodeDiffInsights = useMemo( () => buildSemanticDiffInsights(codeDiffResult.rows), [codeDiffResult.rows] ); const activeVersionSemanticInsights = useMemo( () => buildVersionSemanticInsights(activeVersion), [activeVersion] ); const versionSemanticInsightMap = useMemo( () => new Map( versions.map((version) => [ version.id, buildVersionSemanticInsights(version), ]) ), [versions] ); const semanticFocusInsights = semanticCodeDiffInsights.length > 0 ? semanticCodeDiffInsights : activeVersionSemanticInsights; const _followUpSuggestions = buildFollowUpSuggestions({ activeVersion, activeParentVersion, previewSourceVersion, semanticFocus: semanticFocusInsights, }); const codeContextKey = `${activeVersion?.id || "none"}:${compareVersion?.id || "none"}`; const hasPendingTasks = versions.some( (version) => !FINAL_TASK_STATUS.has(version.status) ); const pendingTaskCount = versions.filter( (version) => !FINAL_TASK_STATUS.has(version.status) ).length; const completedCount = versions.filter( (version) => version.status === "success" ).length; const activeWorkspace = workspaces.find((workspace) => workspace.id === activeWorkspaceId) ?? null; const activeWorkspaceName = activeWorkspace?.name || "AI-CAD 工作台"; const normalizedWorkspaceName = normalizeWorkspaceTitle(activeWorkspaceName); const shouldForceTreeCollapsed = panelDirection === "horizontal" && !activeVersion; const isWorkspaceActionDisabled = isSubmitting || isCreatingWorkspace || isDeletingWorkspace; const canDeleteActiveWorkspace = Boolean(activeWorkspaceId) && !hasPendingTasks && !isWorkspaceActionDisabled && !isLoadingWorkspaceList; const isKittycadConfigured = kittycadProviderStatus?.configured !== false; const isOfficialModelingAvailable = generationProvider === "kittycad-cad" && isKittycadConfigured; const isSubmitDisabled = !activeWorkspaceId || isLoadingWorkspaceList || !prompt.trim() || isSubmitting || (generationProvider === "kittycad-cad" && !isKittycadConfigured); const modelingPreviewImageUrl = !activePreviewVersion?.modelUrl && modelingSnapshotUrl ? modelingSnapshotUrl : activePreviewVersion?.previewImageUrl || null; const serializedWorkbenchSnapshot = serializeWorkbenchState({ prompt, generationProvider, modelVersion, viewerSettings, messages, versions, activeVersionId, }); useEffect(() => { if (!activeVersionId) { return; } setExpandedVersionDetails((current) => current[activeVersionId] ? current : { ...current, [activeVersionId]: true, } ); }, [activeVersionId]); useEffect(() => { if (!activeVersion) { setCompareVersionId(null); return; } const compareCandidateIds = new Set( compareCandidateVersions.map((version) => version.id) ); setCompareVersionId((current) => { if (current && compareCandidateIds.has(current)) { return current; } return compareCandidateVersions[0]?.id ?? null; }); }, [activeVersion, compareCandidateVersions]); useEffect(() => { if (!compareVersion) { setShowOnlyChangedCodeRows(false); } }, [compareVersion]); useEffect(() => { if (!canEditActiveKcl || !activeCodeContent) { setKclDraft(""); setKclEditPrompt(""); return; } setKclDraft(activeCodeContent); setKclEditPrompt(""); }, [activeCodeContent, canEditActiveKcl]); useEffect(() => { if (!hasHydratedFromStorage || !activeWorkspaceId) { return; } setModelingError(null); setModelingSnapshotUrl(null); setModelingPlaneId(null); setModelingSketchId(null); setModelingSolidId(null); setModelingSelectedEntityIds([]); setModelingLogLines([]); if (!modelingSessionIdRef.current) { setModelingConnectionState("disconnected"); return; } const previousSessionId = modelingSessionIdRef.current; modelingSessionIdRef.current = null; setModelingSessionId(null); setModelingConnectionState("disconnected"); swallowAsync( aiCadGenerationService.closeModelingSession(previousSessionId) ); }, [activeWorkspaceId, hasHydratedFromStorage]); useEffect(() => { const codeUrlsToFetch = Array.from( new Set( [activeCodeUrl, compareCodeUrl].filter( (url): url is string => typeof url === "string" && url.trim().length > 0 ) ) ).filter((url) => !cachedCodeByUrl[url] && !loadingCodePreviewByUrl[url]); if (codeUrlsToFetch.length === 0) { return; } let cancelled = false; setLoadingCodePreviewByUrl((current) => ({ ...current, ...Object.fromEntries(codeUrlsToFetch.map((url) => [url, true])), })); setCodePreviewErrorByUrl((current) => ({ ...current, ...Object.fromEntries(codeUrlsToFetch.map((url) => [url, null])), })); swallowAsync( (async () => { await Promise.all( codeUrlsToFetch.map(async (url) => { try { const response = await fetch(url, { cache: "no-store" }); if (!response.ok) { throw new Error(`加载失败(HTTP ${response.status})`); } const code = await response.text(); if (cancelled) { return; } setCachedCodeByUrl((current) => ({ ...current, [url]: code, })); setCodePreviewErrorByUrl((current) => ({ ...current, [url]: null, })); } catch (error) { if (cancelled) { return; } setCodePreviewErrorByUrl((current) => ({ ...current, [url]: error instanceof Error ? error.message : "KCL 代码加载失败", })); } finally { if (!cancelled) { setLoadingCodePreviewByUrl((current) => ({ ...current, [url]: false, })); } } }) ); })() ); return () => { cancelled = true; }; }, [activeCodeUrl, cachedCodeByUrl, compareCodeUrl, loadingCodePreviewByUrl]); useEffect(() => { if (!hasHydratedFromStorage) { return; } if (!serializedWorkbenchSnapshot) { return; } if (isApplyingExternalWorkspaceRef.current) { isApplyingExternalWorkspaceRef.current = false; return; } if (activeWorkspaceId && !hasHydratedServerWorkspace) { hasLocalEditsBeforeServerHydrationRef.current[activeWorkspaceId] = true; } }, [ activeWorkspaceId, hasHydratedFromStorage, hasHydratedServerWorkspace, serializedWorkbenchSnapshot, ]); useEffect(() => { if ( !activeWorkspaceId || !hasHydratedFromStorage || typeof window === "undefined" ) { return; } if ( !hasHydratedServerWorkspace && !hasLocalEditsBeforeServerHydrationRef.current[activeWorkspaceId] ) { return; } const savedAt = Date.now(); const workbenchPayload = JSON.parse( serializedWorkbenchSnapshot ) as PersistedAiCadWorkbenchState; const snapshot = { ...workbenchPayload, savedAt, }; const serializedWorkspaceSyncSnapshot = serializeWorkspaceSyncState({ name: activeWorkspace?.name, state: workbenchPayload, }); window.localStorage.setItem( getWorkbenchStorageKey(activeWorkspaceId), JSON.stringify(snapshot) ); localSavedAtRef.current[activeWorkspaceId] = savedAt; if (!hasHydratedServerWorkspace) { return; } if ( serializedWorkspaceSyncSnapshot === lastServerSavedSnapshotRef.current[activeWorkspaceId] ) { setWorkspaceSyncState((current) => current === "error" ? current : "synced" ); return; } const workspaceId = activeWorkspaceId; setWorkspaceSyncState("syncing"); const timer = window.setTimeout(() => { swallowAsync( (async () => { const result = await aiCadGenerationService.saveWorkspace( workspaceId, { name: activeWorkspace?.name, prompt: workbenchPayload.prompt, generationProvider: workbenchPayload.generationProvider, modelVersion: workbenchPayload.modelVersion, viewerSettings: workbenchPayload.viewerSettings as Record< string, unknown >, messages: workbenchPayload.messages as Record[], versions: workbenchPayload.versions as Record[], activeVersionId: workbenchPayload.activeVersionId, } ); if (result.success) { lastServerSavedSnapshotRef.current[workspaceId] = serializedWorkspaceSyncSnapshot; if (result.data?.summary) { setWorkspaces((current) => sortWorkspaceList( current.map((workspace) => workspace.id === workspaceId ? result.data?.summary || workspace : workspace ) ) ); } else { setWorkspaces((current) => current.map((workspace) => workspace.id === workspaceId ? { ...workspace, name: result.data?.name || workspace.name, updatedAt: result.data?.updatedAt || workspace.updatedAt, } : workspace ) ); } if (activeWorkspaceIdRef.current === workspaceId) { setWorkspaceSyncState("synced"); setWorkspaceSyncError(null); } return; } if (activeWorkspaceIdRef.current === workspaceId) { setWorkspaceSyncState("error"); setWorkspaceSyncError(result.error || "云端同步失败"); } })() ); }, 1200); return () => { window.clearTimeout(timer); }; }, [ activeWorkspace?.name, activeWorkspaceId, hasHydratedFromStorage, hasHydratedServerWorkspace, serializedWorkbenchSnapshot, ]); useEffect(() => { if (!activeWorkspaceId) { return; } setWorkspaces((current) => current.map((workspace) => workspace.id === activeWorkspaceId ? buildWorkspaceSummaryFromWorkbench(workspace, { prompt, generationProvider, versions, }) : workspace ) ); }, [activeWorkspaceId, generationProvider, prompt, versions]); useEffect(() => { if (!activeVersion) { setActiveResultTab("preview"); return; } if (!activePreviewVersion && activeCodeUrl) { setActiveResultTab("code"); return; } setActiveResultTab("preview"); }, [activeCodeUrl, activePreviewVersion, activeVersion]); useEffect(() => { if (!codeContextKey) { return; } setCodeActionError(null); }, [codeContextKey]); useEffect(() => { let cancelled = false; const loadProviderStatus = async () => { setIsLoadingKittycadProviderStatus(true); const result = await aiCadGenerationService.getProviderStatus(); if (cancelled) { return; } if (result.success && result.data) { setKittycadProviderStatus(result.data); } else { setKittycadProviderStatus({ configured: false, source: "none", }); } setIsLoadingKittycadProviderStatus(false); }; swallowAsync(loadProviderStatus()); return () => { cancelled = true; }; }, []); useEffect(() => { if (!hasPendingTasks) { return; } let cancelled = false; const syncPendingTasks = async () => { const pendingVersions = versionsRef.current.filter( (version) => !FINAL_TASK_STATUS.has(version.status) ); if (pendingVersions.length === 0) { return; } const responses = await Promise.all( pendingVersions.map(async (version) => ({ versionId: version.id, taskId: version.taskId, assistantMessageId: version.assistantMessageId, versionNumber: version.versionNumber, result: version.provider === "kittycad-cad" ? await aiCadGenerationService.getTask(version.taskId) : await model3DGenerationService.getTask(version.taskId), })) ); if (cancelled) { return; } for (const response of responses) { if (!response.result.success || !response.result.data) { continue; } const payload = response.result.data; setVersions((current) => current.map((version) => version.id === response.versionId ? { ...version, status: payload.status ?? version.status, progress: typeof payload.progress === "number" ? payload.progress : version.progress, modelUrl: payload.modelUrl === undefined ? version.modelUrl : payload.modelUrl, previewImageUrl: payload.previewImageUrl === undefined ? version.previewImageUrl : payload.previewImageUrl, codeUrl: "codeUrl" in payload ? payload.codeUrl || null : version.codeUrl, errorMessage: payload.errorMessage === undefined ? version.errorMessage : payload.errorMessage, conversationId: "conversationId" in payload ? payload.conversationId || null : version.conversationId, downloadUrls: "downloadUrls" in payload ? payload.downloadUrls || null : version.downloadUrls, modelVersion: "modelVersion" in payload && typeof payload.modelVersion === "string" && payload.modelVersion.trim() ? payload.modelVersion : version.modelVersion, } : version ) ); setMessages((current) => current.map((message) => message.id === response.assistantMessageId ? { ...message, state: payload.status === "success" ? "success" : payload.status === "failed" || payload.status === "banned" || payload.status === "cancelled" || payload.status === "expired" ? "failed" : payload.status === "running" ? "running" : "queued", content: buildAssistantMessage( response.versionNumber, payload.status ?? "unknown", payload.progress, payload.errorMessage ), } : message ) ); } }; swallowAsync(syncPendingTasks()); const timer = window.setInterval(() => { swallowAsync(syncPendingTasks()); }, 2500); return () => { cancelled = true; window.clearInterval(timer); }; }, [hasPendingTasks]); function updateActiveWorkspaceName(nextName: string) { if (!activeWorkspaceId) { return; } const normalizedName = normalizeWorkspaceTitle(nextName); setWorkspaces((current) => current.map((workspace) => workspace.id === activeWorkspaceId ? { ...workspace, name: normalizedName } : workspace ) ); } function setVersionFeedback( versionId: string, feedback: VersionFeedback | null ) { setVersions((current) => current.map((version) => version.id === versionId ? { ...version, feedback, } : version ) ); } function toggleVersionDetails(versionId: string, open: boolean) { setExpandedVersionDetails((current) => ({ ...current, [versionId]: open, })); } async function handleCopyCodeContent(code: string) { if ( typeof window === "undefined" || !navigator.clipboard || typeof navigator.clipboard.writeText !== "function" ) { setCodeActionError("当前环境不支持复制代码"); return; } try { await navigator.clipboard.writeText(code); setCodeActionError(null); } catch (error) { setCodeActionError( error instanceof Error ? error.message : "复制 KCL 代码失败" ); } } function handleFocusVersionForIteration(version: GenerationVersion) { setActiveVersionId(version.id); if (conversationPanelRef.current) { conversationPanelRef.current.expand(); } if (!canIterateKittyCadVersion(version)) { return; } setPrompt((current) => current.trim() ? current : buildStructuredPromptTemplate( `保留:V${version.versionNumber} 的主体比例和主要装配关系`, version ) ); } function appendModelingLog(message: string) { const timestamp = formatClockTime(Date.now()); setModelingLogLines((current) => [`${timestamp} · ${message}`, ...current].slice(0, 8) ); } async function ensureModelingSession() { if (modelingSessionId) { return modelingSessionId; } setModelingConnectionState("connecting"); setModelingError(null); const result = await aiCadGenerationService.createModelingSession(); if (!result.success || !result.data?.sessionId) { setModelingConnectionState("error"); const message = result.error || "创建官方建模会话失败"; setModelingError(message); throw new Error(message); } setModelingSessionId(result.data.sessionId); setModelingConnectionState("connected"); appendModelingLog("已连接官方 modeling 会话"); return result.data.sessionId; } async function handleDisconnectModelingSession() { if (!modelingSessionId) { return; } const closingSessionId = modelingSessionId; setModelingConnectionState("disconnected"); setModelingSessionId(null); setModelingPlaneId(null); setModelingSketchId(null); setModelingSolidId(null); setModelingSelectedEntityIds([]); setModelingSnapshotUrl(null); const result = await aiCadGenerationService.closeModelingSession(closingSessionId); if (!result.success) { setModelingConnectionState("error"); setModelingError(result.error || "关闭官方建模会话失败"); return; } appendModelingLog("已关闭官方 modeling 会话"); } async function sendOfficialModelingCommand(params: { captureSnapshot?: boolean; cmd: Record; cmdId?: string; }) { const sessionId = await ensureModelingSession(); const result = await aiCadGenerationService.sendModelingCommand({ captureSnapshot: params.captureSnapshot, cmd: params.cmd, cmdId: params.cmdId, sessionId, snapshotFormat: "png", }); if (!result.success || !result.data) { setModelingConnectionState("error"); const message = result.error || "官方建模命令执行失败"; setModelingError(message); throw new Error(message); } setModelingConnectionState("connected"); if (result.data.snapshotUrl) { setModelingSnapshotUrl(result.data.snapshotUrl); } if (result.data.snapshotError) { setModelingError(result.data.snapshotError); } return result.data; } async function runModelingAction(label: string, task: () => Promise) { if (isModelingBusy || !isOfficialModelingAvailable) { return; } setIsModelingBusy(true); setModelingError(null); try { await task(); appendModelingLog(label); } catch (error) { const message = error instanceof Error ? error.message : "官方建模命令执行失败"; setModelingConnectionState((current) => current === "connecting" ? "error" : current ); setModelingError(message); appendModelingLog(`${label}失败:${message}`); } finally { setIsModelingBusy(false); } } async function handleCreateRectangleSketch() { await runModelingAction("已创建矩形草图", async () => { const width = parsePositiveNumberInput(modelingWidth, "草图宽度"); const height = parsePositiveNumberInput(modelingHeight, "草图高度"); const planeId = createId(); const sketchId = createId(); await sendOfficialModelingCommand({ cmd: { clobber: true, origin: { x: 0, y: 0, z: 0 }, size: Math.max(width, height) * 2, type: "make_plane", x_axis: { x: 1, y: 0, z: 0 }, y_axis: { x: 0, y: 1, z: 0 }, }, cmdId: planeId, }); await sendOfficialModelingCommand({ cmd: { adjust_camera: true, animated: false, entity_id: planeId, ortho: true, type: "enable_sketch_mode", }, }); await sendOfficialModelingCommand({ cmd: { tool: "sketch_line", type: "set_tool", }, }); await sendOfficialModelingCommand({ cmd: { type: "start_path" }, cmdId: sketchId, }); await sendOfficialModelingCommand({ cmd: { path: sketchId, to: { x: -width / 2, y: -height / 2, z: 0 }, type: "move_path_pen", }, }); const corners = [ { x: width / 2, y: -height / 2, z: 0 }, { x: width / 2, y: height / 2, z: 0 }, { x: -width / 2, y: height / 2, z: 0 }, ]; for (const corner of corners) { await sendOfficialModelingCommand({ cmd: { path: sketchId, segment: { end: corner, relative: false, type: "line", }, type: "extend_path", }, }); } await sendOfficialModelingCommand({ cmd: { path_id: sketchId, type: "close_path", }, }); await sendOfficialModelingCommand({ captureSnapshot: true, cmd: { padding: 0.12, type: "view_isometric", }, }); setModelingPlaneId(planeId); setModelingSketchId(sketchId); setModelingSolidId(null); setModelingSelectedEntityIds([]); }); } async function handleExtrudeSketch() { await runModelingAction("已拉伸当前草图", async () => { if (!modelingSketchId) { throw new Error("请先通过官方草图工具创建一个矩形草图"); } const distance = parsePositiveNumberInput(modelingDepth, "拉伸深度"); const solidId = createId(); await sendOfficialModelingCommand({ cmd: { distance, target: modelingSketchId, type: "extrude", }, cmdId: solidId, }); await sendOfficialModelingCommand({ cmd: { entities: [solidId], type: "select_replace", }, }); await sendOfficialModelingCommand({ captureSnapshot: true, cmd: { padding: 0.08, type: "view_isometric", }, }); setModelingSolidId(solidId); setModelingSelectedEntityIds([solidId]); }); } async function handleSelectCurrentBody() { await runModelingAction("已同步当前实体选择", async () => { if (!modelingSolidId) { throw new Error("当前还没有可选择的实体,请先拉伸出一个实体"); } await sendOfficialModelingCommand({ cmd: { entities: [modelingSolidId], type: "select_replace", }, }); const result = await sendOfficialModelingCommand({ cmd: { type: "select_get", }, }); const responseData = getModelingResponseData(result); const entityIds = Array.isArray(responseData?.entity_ids) ? responseData.entity_ids .map((item) => String(item || "").trim()) .filter(Boolean) : []; setModelingSelectedEntityIds(entityIds); }); } async function handleCutSelectedEdges() { await runModelingAction( modelingCutType === "fillet" ? "已执行圆角切边" : "已执行倒角切边", async () => { if (!modelingSolidId) { throw new Error("请先拉伸出一个实体,再执行圆角或倒角"); } const size = parsePositiveNumberInput( modelingCutSize, modelingCutType === "fillet" ? "圆角半径" : "倒角距离" ); const edgeIndices = parseEdgeIndexList(modelingEdgeIndices); const edgeIds: string[] = []; for (const edgeIndex of edgeIndices) { const response = await sendOfficialModelingCommand({ cmd: { edge_index: edgeIndex, object_id: modelingSolidId, type: "solid3d_get_edge_uuid", }, }); const responseData = getModelingResponseData(response); const edgeId = String(responseData?.edge_id || "").trim(); if (!edgeId) { throw new Error(`读取第 ${edgeIndex} 条边的 UUID 失败`); } edgeIds.push(edgeId); } await sendOfficialModelingCommand({ captureSnapshot: true, cmd: { cut_type: modelingCutType === "fillet" ? { fillet: { radius: size } } : { chamfer: { distance: size, swap: false } }, edge_ids: edgeIds, extra_face_ids: edgeIds.length > 1 ? edgeIds.slice(1).map(() => createId()) : [], object_id: modelingSolidId, strategy: "automatic", tolerance: 0.01, type: "solid3d_cut_edges", }, }); } ); } async function handleApplyKclEdit() { if ( !activeWorkspaceId || !activeVersion || activeVersion.provider !== "kittycad-cad" || !canEditActiveKcl || isApplyingKclEdit || isSubmitting ) { return; } const normalizedSourceCode = kclDraft.trim(); if (!normalizedSourceCode) { setCodeActionError("KCL 代码不能为空"); return; } const editPrompt = kclEditPrompt.trim() || `应用手动编辑后的 KCL,并基于 V${activeVersion.versionNumber} 重新求解。`; const nextVersionNumber = versions.length + 1; const userMessageId = createId(); const assistantMessageId = createId(); setCodeActionError(null); setIsApplyingKclEdit(true); setMessages((current) => [ ...current, { content: editPrompt, createdAt: Date.now(), id: userMessageId, role: "user", }, { content: `收到,这轮会直接使用你编辑后的 KCL 代码提交官方 iteration,并把结果记为 V${nextVersionNumber}。`, createdAt: Date.now(), id: assistantMessageId, role: "assistant", state: "thinking", }, ]); try { const result = await aiCadGenerationService.createTask({ iterationSourceTaskId: activeVersion.taskId, outputFormat: "glb", prompt: editPrompt, sourceCode: kclDraft, sourceRanges: [], }); if (!result.success || !result.data?.taskId) { throw new Error(result.error || "提交 KCL iteration 失败"); } const versionId = createId(); const createdConversationId = "conversationId" in result.data ? readNullableText(result.data.conversationId) : null; const createdModelVersion = "modelVersion" in result.data ? readNullableText(result.data.modelVersion) : null; const nextVersion: GenerationVersion = { assistantMessageId, conversationId: createdConversationId, createdAt: Date.now(), generationMode: "iterate", id: versionId, modelVersion: createdModelVersion || "KCL Iteration", parentVersionId: activeVersion.id, progress: null, prompt: editPrompt, provider: "kittycad-cad", status: result.data.status || "queued", taskId: result.data.taskId, versionNumber: nextVersionNumber, }; setVersions((current) => [nextVersion, ...current]); setActiveVersionId(versionId); setMessages((current) => current.map((message) => message.id === assistantMessageId ? { ...message, content: buildAssistantMessage( nextVersionNumber, nextVersion.status, null, null ), state: nextVersion.status === "running" ? "running" : nextVersion.status === "success" ? "success" : "queued", versionId, } : message ) ); setKclEditPrompt(""); } catch (error) { const message = error instanceof Error ? error.message : "提交 KCL iteration 失败"; setCodeActionError(message); setMessages((current) => current.map((messageItem) => messageItem.id === assistantMessageId ? { ...messageItem, content: `KCL 重算失败了:${message}。你可以检查语法后重试,或者补一句更明确的修改意图。`, state: "failed", } : messageItem ) ); } finally { setIsApplyingKclEdit(false); } } async function handleSubmit() { const trimmedPrompt = prompt.trim(); if ( !activeWorkspaceId || !trimmedPrompt || isSubmitting || (generationProvider === "kittycad-cad" && !isKittycadConfigured) ) { return; } const nextVersionNumber = versions.length + 1; const userMessageId = createId(); const assistantMessageId = createId(); const nextWorkspaceTitle = versions.length === 0 && isDefaultWorkspaceTitle(activeWorkspaceName) ? buildWorkspaceTitleFromPrompt(trimmedPrompt) : null; if (nextWorkspaceTitle) { updateActiveWorkspaceName(nextWorkspaceTitle); } setPrompt(""); setIsSubmitting(true); setMessages((current) => [ ...current, { id: userMessageId, role: "user", createdAt: Date.now(), content: trimmedPrompt, }, { id: assistantMessageId, role: "assistant", createdAt: Date.now(), state: "thinking", content: iterationBaseVersion ? `收到,这一轮我会基于 V${iterationBaseVersion.versionNumber} 继续修改,先提交连续迭代任务。` : `收到,这一轮我会把它推进成 V${nextVersionNumber},先开始提交生成任务。`, }, ]); try { const result = generationProvider === "kittycad-cad" ? await aiCadGenerationService.createTask({ prompt: trimmedPrompt, outputFormat: "glb", iterationSourceTaskId: iterationBaseVersion?.taskId, }) : await model3DGenerationService.createTask({ taskType: "text_to_model", prompt: trimmedPrompt, modelVersion, }); if (!result.success || !result.data?.taskId) { throw new Error(result.error || "创建 3D 任务失败"); } const versionId = createId(); const createdGenerationMode = generationProvider === "kittycad-cad" && "generationMode" in result.data && result.data.generationMode === "iterate" ? "iterate" : "create"; const createdModelVersion = generationProvider === "kittycad-cad" ? readNullableText( "modelVersion" in result.data ? result.data.modelVersion : null ) || (createdGenerationMode === "iterate" ? "KCL Iteration" : "GLB / STEP / KCL") : modelVersion; const createdConversationId = readNullableText( "conversationId" in result.data ? result.data.conversationId : null ); const nextVersion: GenerationVersion = { id: versionId, versionNumber: nextVersionNumber, provider: generationProvider, taskId: result.data.taskId, generationMode: createdGenerationMode, parentVersionId: createdGenerationMode === "iterate" ? iterationBaseVersion?.id || null : null, prompt: trimmedPrompt, modelVersion: createdModelVersion, createdAt: Date.now(), status: result.data.status || "queued", progress: null, conversationId: createdConversationId, assistantMessageId, }; setVersions((current) => [nextVersion, ...current]); setActiveVersionId(versionId); setMessages((current) => current.map((message) => message.id === assistantMessageId ? { ...message, versionId, state: nextVersion.status === "running" ? "running" : nextVersion.status === "success" ? "success" : "queued", content: buildAssistantMessage( nextVersionNumber, nextVersion.status, null, null ), } : message ) ); } catch (error) { setMessages((current) => current.map((message) => message.id === assistantMessageId ? { ...message, state: "failed", content: `这轮提交失败了:${error instanceof Error ? error.message : "未知错误"}。你可以稍后重试,或者换个更明确的结构描述。`, } : message ) ); } finally { setIsSubmitting(false); } } async function handleCreateWorkspace() { if (isWorkspaceActionDisabled) { return; } setIsCreatingWorkspace(true); setWorkspaceOperationError(null); try { const result = await aiCadGenerationService.createWorkspace(); if (!result.success || !result.data) { throw new Error(result.error || "创建工作台失败"); } const createdWorkspace = result.data; setWorkspaces((current) => sortWorkspaceList([createdWorkspace, ...current]) ); setActiveWorkspaceId(createdWorkspace.id); } catch (error) { setWorkspaceOperationError( error instanceof Error ? error.message : "创建工作台失败" ); } finally { setIsCreatingWorkspace(false); } } async function handleDeleteWorkspace() { if (!activeWorkspaceId || !canDeleteActiveWorkspace) { return; } const confirmed = window.confirm( `确认删除“${activeWorkspaceName}”吗?本地与云端的工作台快照都会清除,但不会撤销已经生成过的外部任务。` ); if (!confirmed) { return; } setIsDeletingWorkspace(true); setWorkspaceOperationError(null); try { const result = await aiCadGenerationService.deleteWorkspace(activeWorkspaceId); if (!result.success) { throw new Error(result.error || "删除工作台失败"); } removePersistedWorkbench(activeWorkspaceId); delete localSavedAtRef.current[activeWorkspaceId]; delete lastServerSavedSnapshotRef.current[activeWorkspaceId]; delete hasLocalEditsBeforeServerHydrationRef.current[activeWorkspaceId]; const remainingWorkspaces = workspaces.filter( (workspace) => workspace.id !== activeWorkspaceId ); if (remainingWorkspaces.length > 0) { const nextWorkspaces = sortWorkspaceList(remainingWorkspaces); setWorkspaces(nextWorkspaces); setActiveWorkspaceId(nextWorkspaces[0]?.id || null); return; } const createdResult = await aiCadGenerationService.createWorkspace(); if (!createdResult.success || !createdResult.data) { setWorkspaces([]); setActiveWorkspaceId(null); throw new Error(createdResult.error || "重建默认工作台失败"); } setWorkspaces([createdResult.data]); setActiveWorkspaceId(createdResult.data.id); } catch (error) { setWorkspaceOperationError( error instanceof Error ? error.message : "删除工作台失败" ); } finally { setIsDeletingWorkspace(false); } } function handleResetWorkbench() { const nextGenerationProvider: CadGenerationProvider = kittycadProviderStatus?.configured === false ? "tripo-3d" : "kittycad-cad"; if (activeWorkspaceId) { removePersistedWorkbench(activeWorkspaceId); } setPrompt(""); setGenerationProvider(nextGenerationProvider); setModelVersion(MODEL_VERSION_OPTIONS[0]); setViewerSettings(DEFAULT_VIEWER_SETTINGS); setMessages(createWelcomeMessages()); setVersions([]); setActiveVersionId(null); setKclDraft(""); setKclEditPrompt(""); setModelingError(null); setModelingSnapshotUrl(null); setModelingPlaneId(null); setModelingSketchId(null); setModelingSolidId(null); setModelingSelectedEntityIds([]); setModelingLogLines([]); if (modelingSessionIdRef.current) { const previousSessionId = modelingSessionIdRef.current; modelingSessionIdRef.current = null; setModelingSessionId(null); setModelingConnectionState("disconnected"); swallowAsync( aiCadGenerationService.closeModelingSession(previousSessionId) ); } } function handleViewerCameraPresetChange( preset: Model3DViewerSettings["cameraPreset"] ) { setViewerSettings((current) => ({ ...current, autoRotate: false, cameraPreset: preset, })); } return (
返回首页 { historyPanelRef.current?.expand(); setActiveLeftPanelView("project"); }} shortLabel="Proj" /> { historyPanelRef.current?.expand(); setActiveLeftPanelView("tree"); }} shortLabel="Tree" /> { resultPanelRef.current?.expand(); setActiveResultTab("preview"); }} shortLabel="3D" /> { resultPanelRef.current?.expand(); setActiveResultTab("code"); }} shortLabel="KCL" /> conversationPanelRef.current?.expand()} shortLabel="AI" />
{primaryDownloadEntry ? ( Export ) : ( )}
{activeVersion ? `Focus V${activeVersion.versionNumber}` : "Ready for first pass"} {generationProvider === "kittycad-cad" ? ( ) : null}
{workspaceSyncState === "synced" ? "Synced" : "Syncing"} {versions.length} versions {completedCount} done {hasPendingTasks ? `${pendingTaskCount} task active` : "Workspace stable"}
{generationProvider === "kittycad-cad" ? "Zoo CAD" : "Tripo3D"} {viewerSettings.envPreset} {activeVersion ? ( V{activeVersion.versionNumber} ) : null}
{(workspaceSyncError || workspaceOperationError) && (
{workspaceSyncError ? (

云端同步提示:{workspaceSyncError}

) : null} {workspaceOperationError ? (

工作台提示:{workspaceOperationError}

) : null}
)}
setIsConversationPanelCollapsed(true)} onExpand={() => setIsConversationPanelCollapsed(false)} order={3} ref={conversationPanelRef} > {isConversationPanelCollapsed ? ( conversationPanelRef.current?.expand()} title="AI-CAD Agent" /> ) : (
AI-CAD Agent
{activeVersion || messages.length > 1 ? (
{messages.map((message) => { const linkedVersion = message.versionId ? findVersionById(versions, message.versionId) : null; return (
{message.role === "user" ? ( 你 ) : ( AI-CAD )} {formatClockTime(message.createdAt)} {message.role !== "user" && message.state ? ( {getMessageStateLabel( message.state )} ) : null}
{message.content}
{linkedVersion ? (
{canIterateKittyCadVersion( linkedVersion ) ? ( ) : null}
) : null}
); })}
) : (
9 seconds ago
Try requesting a model, ask engineering questions, or let's explore ideas.
)}