ai-chatbot/components/ai-cad/ai-cad-workbench.tsx
Your Name 12bc8b1668
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
refactor: streamline AI-CAD into single-screen workspace
2026-04-06 21:15:41 +08:00

6135 lines
256 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import {
ArrowUpRight,
Bot,
Box,
BrainCircuit,
Camera,
CheckCircle2,
ChevronDown,
ChevronLeft,
ChevronRight,
Copy,
Download,
FileCode2,
FolderOpen,
GitBranch,
Grid3X3,
History,
Loader2,
MoreHorizontal,
Orbit,
Paperclip,
Plus,
RotateCcw,
Send,
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 { 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 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<string, string> | 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 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<Model3DTaskStatus>([
"success",
"failed",
"banned",
"expired",
"cancelled",
"unknown",
]);
const DEFAULT_VIEWER_SETTINGS: Model3DViewerSettings = {
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<string, SemanticDiffCategory> = {
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<unknown>) {
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 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<typeof buildDownloadEntries>;
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<string, GenerationVersion[]>();
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<string>();
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<string>([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 (
<div className="flex min-h-[360px] flex-col overflow-hidden rounded-[22px] border border-[#ddd7ca] bg-white">
<div className="flex flex-wrap items-center justify-between gap-3 border-[#e6e0d3] border-b bg-[#f8f5ee] px-4 py-3">
<div>
<div className="font-medium text-[#201c16] text-sm">{title}</div>
<p className="mt-1 text-[#7f7768] text-xs">{subtitle}</p>
</div>
<div className="flex flex-wrap gap-2">
{code ? (
<Button
className="gap-2 rounded-full border-[#ddd7ca] bg-white text-[#201c16] hover:bg-[#f4f1e8]"
onClick={() => onCopy?.(code)}
size="sm"
type="button"
variant="outline"
>
<Copy className="h-4 w-4" />
复制代码
</Button>
) : null}
{openUrl ? (
<Button
asChild
className="gap-2 rounded-full border-[#ddd7ca] bg-white text-[#201c16] hover:bg-[#f4f1e8]"
size="sm"
type="button"
variant="outline"
>
<a href={openUrl} rel="noreferrer" target="_blank">
<ArrowUpRight className="h-4 w-4" />
打开代码
</a>
</Button>
) : null}
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="p-4">
{loading ? (
<div className="flex min-h-[280px] items-center justify-center rounded-2xl border border-[#ddd7ca] bg-[#f8f5ee] text-[#7f7768]">
<div className="flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
正在加载代码差异...
</div>
</div>
) : error ? (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-5 text-rose-700 text-sm leading-6">
{error}
</div>
) : rows.length === 0 ? (
<div className="flex min-h-[280px] items-center justify-center rounded-2xl border border-[#ddd7ca] border-dashed bg-[#f8f5ee] px-5 py-10 text-center text-[#7f7768] text-sm leading-6">
{rowsEmptyMessage || "当前过滤条件下没有可显示的差异行。"}
</div>
) : code ? (
<div className="overflow-hidden rounded-2xl border border-[#ddd7ca] bg-[#fbfaf5]">
{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 (
<div
className={cn(
"grid grid-cols-[56px_minmax(0,1fr)] gap-3 border-[#ece6da] border-b px-3 py-1.5 font-mono text-[13px] leading-6 last:border-b-0",
getCodeDiffRowClass(row.kind, side)
)}
key={`${side}:${row.kind}:${lineNumber ?? "placeholder"}:${index}`}
>
<div className="select-none text-right text-[#b0a798]">
{lineNumber ?? ""}
</div>
<pre
className={cn(
"whitespace-pre-wrap break-words text-[#201c16]",
isPlaceholder && "text-[#c8c1b5] italic"
)}
>
{isPlaceholder ? " " : lineText || " "}
</pre>
</div>
);
})}
</div>
) : (
<div className="flex min-h-[280px] items-center justify-center rounded-2xl border border-[#ddd7ca] border-dashed bg-[#f8f5ee] px-5 py-10 text-center text-[#7f7768] text-sm leading-6">
{emptyMessage}
</div>
)}
</div>
</ScrollArea>
</div>
);
}
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<string>();
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<string, unknown> {
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,
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<string, string> | 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 (
<PanelResizeHandle
className={cn(
"group relative flex shrink-0 items-center justify-center bg-transparent outline-none data-[panel-group-direction=horizontal]:mx-1.5 data-[panel-group-direction=vertical]:my-1.5 data-[panel-group-direction=vertical]:h-2 data-[panel-group-direction=horizontal]:w-2 data-[panel-group-direction=horizontal]:cursor-col-resize data-[panel-group-direction=vertical]:cursor-row-resize",
className
)}
>
<div className="rounded-full bg-[#d5cfc2] transition group-hover:bg-[#beb6a6] data-[panel-group-direction=horizontal]:h-16 data-[panel-group-direction=vertical]:h-[2px] data-[panel-group-direction=horizontal]:w-[2px] data-[panel-group-direction=vertical]:w-16 group-data-[resize-handle-state=drag]:bg-[#8f8573]" />
</PanelResizeHandle>
);
}
function AiCadCollapsedPanel({
direction,
icon: Icon,
onExpand,
title,
}: {
direction: "horizontal" | "vertical";
icon: typeof BrainCircuit;
onExpand: () => void;
title: string;
}) {
return (
<div className="h-full min-h-0">
<div
className={cn(
"flex h-full rounded-[18px] border border-[#d9d2c4] bg-[#fbfaf5] text-[#221f18] shadow-[0_1px_0_rgba(255,255,255,0.9)]",
direction === "horizontal"
? "flex-col items-center justify-center gap-3 px-3 py-5"
: "items-center justify-between gap-4 px-4 py-3"
)}
>
<div
className={cn(
"flex items-center gap-2",
direction === "horizontal" && "flex-col text-center"
)}
>
<Icon className="h-5 w-5 text-[#6a6558]" />
<div className="font-medium text-[#221f18] text-sm">{title}</div>
</div>
<Button
className="gap-2 rounded-xl border-[#d9d2c4] bg-white text-[#221f18] transition-all duration-150 ease-out hover:bg-[#f2eee6]"
onClick={onExpand}
size="sm"
type="button"
variant="outline"
>
<ArrowUpRight className="h-4 w-4" />
展开
</Button>
</div>
</div>
);
}
function AiCadRailButton({
active,
icon: Icon,
label,
onClick,
shortLabel,
}: {
active: boolean;
icon: typeof BrainCircuit;
label: string;
onClick: () => void;
shortLabel: string;
}) {
return (
<div className="relative">
<span
className={cn(
"-left-2 -translate-y-1/2 absolute top-1/2 h-6 w-0.5 rounded-full bg-transparent transition-all duration-150 ease-out",
active && "bg-[#31df86] shadow-[0_0_8px_rgba(49,223,134,0.45)]"
)}
/>
<button
className={cn(
"flex w-11 flex-col items-center gap-1 rounded-2xl border px-0 py-2.5 transition-all duration-150 ease-out",
active
? "border-[#b7dcc5] bg-[#ecfbf2] text-[#173225] shadow-[0_10px_24px_rgba(49,223,134,0.08)]"
: "border-[#ddd7ca] bg-white/88 text-[#6f695d] hover:border-[#cfc7b6] hover:bg-white hover:text-[#221f18]"
)}
onClick={onClick}
title={label}
type="button"
>
<Icon className="h-4 w-4" />
<span className="font-medium text-[9px] uppercase leading-none tracking-[0.18em]">
{shortLabel}
</span>
</button>
</div>
);
}
function AiCadToolbarGroup({
children,
className,
label,
}: {
children: ReactNode;
className?: string;
label: string;
}) {
return (
<div
className={cn(
"flex flex-wrap items-center gap-2 rounded-xl border border-[#ddd6c8] bg-white px-2.5 py-2 shadow-[0_1px_0_rgba(255,255,255,0.9)]",
className
)}
>
<span className="pl-1 text-[#8a8272] text-[10px] uppercase tracking-[0.18em]">
{label}
</span>
<Separator className="h-5 bg-[#e7e1d4]" orientation="vertical" />
<div className="flex flex-wrap items-center gap-1.5">{children}</div>
</div>
);
}
function AiCadMessageActionButton({
icon: Icon,
label,
onClick,
}: {
icon: typeof BrainCircuit;
label: string;
onClick: () => void;
}) {
return (
<button
className="inline-flex items-center gap-1.5 rounded-xl border border-[#ddd7ca] bg-white px-2.5 py-1 text-[#5f594d] text-[10px] transition-all duration-150 ease-out hover:border-[#cfc7b6] hover:bg-[#f6f2ea] hover:text-[#221f18]"
onClick={onClick}
type="button"
>
<Icon className="h-3 w-3" />
{label}
</button>
);
}
function AiCadStatusChip({
label,
tone = "neutral",
value,
}: {
label: string;
tone?: "neutral" | "pending" | "success" | "warning";
value: string;
}) {
return (
<div
className={cn(
"flex items-center gap-2 rounded-full border px-3 py-1.5",
tone === "success" && "border-emerald-200 bg-emerald-50",
tone === "pending" && "border-sky-200 bg-sky-50",
tone === "warning" && "border-amber-200 bg-amber-50",
tone === "neutral" && "border-[#ddd7ca] bg-white"
)}
>
<span className="text-[#8b8477] text-[10px] uppercase tracking-[0.18em]">
{label}
</span>
<span
className={cn(
"font-medium text-[11px]",
tone === "success" && "text-emerald-700",
tone === "pending" && "text-sky-700",
tone === "warning" && "text-amber-700",
tone === "neutral" && "text-[#2a261f]"
)}
>
{value}
</span>
</div>
);
}
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<CadGenerationProvider>("kittycad-cad");
const [modelVersion, setModelVersion] = useState<string>(
MODEL_VERSION_OPTIONS[0]
);
const [kittycadProviderStatus, setKittycadProviderStatus] =
useState<AiCadProviderStatus | null>(null);
const [, setIsLoadingKittycadProviderStatus] = useState(true);
const [viewerSettings, setViewerSettings] = useState<Model3DViewerSettings>(
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<string | null>(null);
const [showOnlyChangedCodeRows, setShowOnlyChangedCodeRows] = useState(false);
const [cachedCodeByUrl, setCachedCodeByUrl] = useState<
Record<string, string>
>({});
const [loadingCodePreviewByUrl, setLoadingCodePreviewByUrl] = useState<
Record<string, boolean>
>({});
const [codePreviewErrorByUrl, setCodePreviewErrorByUrl] = useState<
Record<string, string | null>
>({});
const [codeActionError, setCodeActionError] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>(
createWelcomeMessages
);
const [versions, setVersions] = useState<GenerationVersion[]>([]);
const [activeVersionId, setActiveVersionId] = useState<string | null>(null);
const [expandedVersionDetails, setExpandedVersionDetails] = useState<
Record<string, boolean>
>({});
const [workspaces, setWorkspaces] = useState<WorkspaceListItem[]>([]);
const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(
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<string | null>(
null
);
const versionsRef = useRef<GenerationVersion[]>(versions);
const activeWorkspaceIdRef = useRef<string | null>(null);
const conversationPanelRef = useRef<ImperativePanelHandle | null>(null);
const historyPanelRef = useRef<ImperativePanelHandle | null>(null);
const resultPanelRef = useRef<ImperativePanelHandle | null>(null);
const localSavedAtRef = useRef<Record<string, number>>({});
const lastServerSavedSnapshotRef = useRef<Record<string, string | null>>({});
const hasAutoCollapsedExplorerRef = useRef(false);
const hasLocalEditsBeforeServerHydrationRef = useRef<Record<string, boolean>>(
{}
);
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(() => {
writePersistedActiveWorkspaceId(activeWorkspaceId);
}, [activeWorkspaceId]);
useEffect(() => {
if (
hasAutoCollapsedExplorerRef.current ||
panelDirection !== "horizontal" ||
isLoadingWorkspaceList ||
!hasHydratedFromStorage ||
versions.length > 0 ||
!historyPanelRef.current
) {
return;
}
hasAutoCollapsedExplorerRef.current = true;
historyPanelRef.current.collapse();
setIsHistoryPanelCollapsed(true);
}, [
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 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 =
!activeVersion && activeLeftPanelView === "tree";
const isWorkspaceActionDisabled =
isSubmitting || isCreatingWorkspace || isDeletingWorkspace;
const canDeleteActiveWorkspace =
Boolean(activeWorkspaceId) &&
!hasPendingTasks &&
!isWorkspaceActionDisabled &&
!isLoadingWorkspaceList;
const isKittycadConfigured = kittycadProviderStatus?.configured !== false;
const isSubmitDisabled =
!activeWorkspaceId ||
isLoadingWorkspaceList ||
!prompt.trim() ||
isSubmitting ||
(generationProvider === "kittycad-cad" && !isKittycadConfigured);
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(() => {
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<string, unknown>[],
versions: workbenchPayload.versions as Record<string, unknown>[],
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
)
);
}
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);
}
return (
<div className="flex min-h-svh flex-col overflow-hidden bg-[#f2efe7] text-[#201c16]">
<div className="border-[#ddd7ca] border-b bg-[#faf8f2]">
<div className="flex flex-wrap items-center gap-3 px-4 py-3">
<a
className="inline-flex items-center gap-2 rounded-xl border border-[#d8d2c4] bg-white px-3 py-1.5 text-[#423d33] text-[13px] transition-all duration-150 ease-out hover:bg-[#f4f1e8]"
href="/"
>
<ChevronLeft className="h-4 w-4" />
返回首页
</a>
<div className="flex items-center gap-2 rounded-xl border border-[#d8d2c4] bg-white px-2 py-1.5">
<div className="flex h-7 w-9 items-center justify-center rounded-md bg-[#42ef95] font-semibold text-[#0f2318] text-[11px]">
AI
</div>
<span className="font-medium text-[#201c16] text-[13px]">
AI-CAD Studio
</span>
</div>
<button
className="flex min-w-[240px] flex-1 items-center gap-2 rounded-xl border border-[#d8d2c4] bg-white px-4 py-2 text-left text-[#3e392f]"
onClick={() => setActiveLeftPanelView("project")}
type="button"
>
<span className="truncate">
{normalizedWorkspaceName} / main.kcl
</span>
<ChevronDown className="ml-auto h-4 w-4 text-[#827b6d]" />
</button>
<button
className="inline-flex items-center gap-2 rounded-xl border border-[#d8d2c4] bg-white px-3 py-1.5 text-[#423d33] text-[13px] transition-all duration-150 ease-out hover:bg-[#f4f1e8]"
type="button"
>
<Grid3X3 className="h-4 w-4" />
Commands
</button>
<button
className="hidden items-center gap-2 rounded-xl border border-[#d8d2c4] bg-white px-3 py-1.5 text-[#423d33] text-[13px] transition-all duration-150 ease-out hover:bg-[#f4f1e8] lg:inline-flex"
type="button"
>
<ArrowUpRight className="h-4 w-4" />
Share
</button>
<div className="flex h-8 w-8 items-center justify-center rounded-full border border-[#d8d2c4] bg-[#7a4d2a] text-sm text-white">
AI
</div>
</div>
{(workspaceSyncError || workspaceOperationError) && (
<div className="border-[#e6e0d3] border-t px-4 py-2.5">
<div className="flex flex-wrap gap-3 text-sm leading-6">
{workspaceSyncError ? (
<p className="text-rose-700">
云端同步提示:{workspaceSyncError}
</p>
) : null}
{workspaceOperationError ? (
<p className="text-amber-700">
工作台提示:{workspaceOperationError}
</p>
) : null}
</div>
</div>
)}
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<div className="flex h-full min-h-0">
<div className="flex w-16 shrink-0 flex-col items-center justify-between border-[#ddd7ca] border-r bg-[#f8f5ee] px-2 py-3">
<div className="flex flex-col items-center gap-2">
<AiCadRailButton
active={
!isHistoryPanelCollapsed && activeLeftPanelView === "project"
}
icon={FolderOpen}
label="Project Explorer"
onClick={() => {
historyPanelRef.current?.expand();
setActiveLeftPanelView("project");
}}
shortLabel="Proj"
/>
<AiCadRailButton
active={
!isHistoryPanelCollapsed && activeLeftPanelView === "tree"
}
icon={History}
label="Feature Tree"
onClick={() => {
historyPanelRef.current?.expand();
setActiveLeftPanelView("tree");
}}
shortLabel="Tree"
/>
<AiCadRailButton
active={
!isResultPanelCollapsed && activeResultTab === "preview"
}
icon={Orbit}
label="3D Preview"
onClick={() => {
resultPanelRef.current?.expand();
setActiveResultTab("preview");
}}
shortLabel="3D"
/>
<AiCadRailButton
active={!isResultPanelCollapsed && activeResultTab === "code"}
icon={FileCode2}
label="KCL View"
onClick={() => {
resultPanelRef.current?.expand();
setActiveResultTab("code");
}}
shortLabel="KCL"
/>
<AiCadRailButton
active={!isConversationPanelCollapsed}
icon={BrainCircuit}
label="AI-CAD Agent"
onClick={() => conversationPanelRef.current?.expand()}
shortLabel="AI"
/>
</div>
<div className="flex flex-col items-center gap-2">
{primaryDownloadEntry ? (
<a
className="flex w-11 flex-col items-center gap-1 rounded-2xl border border-[#ddd7ca] bg-white px-0 py-2.5 text-[#6f695d] transition-all duration-150 ease-out hover:border-[#cfc7b6] hover:bg-[#f6f2ea] hover:text-[#221f18]"
href={primaryDownloadEntry.url}
rel="noreferrer"
target="_blank"
title="导出当前版本"
>
<Download className="h-4 w-4" />
<span className="font-medium text-[9px] uppercase leading-none tracking-[0.18em]">
Export
</span>
</a>
) : (
<button
className="flex w-11 cursor-not-allowed flex-col items-center gap-1 rounded-2xl border border-[#ddd7ca] bg-white px-0 py-2.5 text-[#b8b0a2]"
disabled
title="暂无可导出文件"
type="button"
>
<Download className="h-4 w-4" />
<span className="font-medium text-[9px] uppercase leading-none tracking-[0.18em]">
Export
</span>
</button>
)}
<button
className="flex w-11 flex-col items-center gap-1 rounded-2xl border border-[#ddd7ca] bg-white px-0 py-2.5 text-[#6f695d] transition-all duration-150 ease-out hover:border-[#cfc7b6] hover:bg-[#f6f2ea] hover:text-[#221f18]"
onClick={handleResetWorkbench}
title="重置当前工作台"
type="button"
>
<RotateCcw className="h-4 w-4" />
<span className="font-medium text-[9px] uppercase leading-none tracking-[0.18em]">
Reset
</span>
</button>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="border-[#ddd7ca] border-b bg-[#fdfbf7] px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2" />
<div className="flex flex-wrap items-center gap-2 text-[#7f7768] text-[11px]">
<span>
{activeVersion
? `Focus V${activeVersion.versionNumber}`
: "Ready for first pass"}
</span>
<span>{versions.length} versions</span>
<span>
{hasPendingTasks
? `${pendingTaskCount} task active`
: "Workspace stable"}
</span>
</div>
</div>
<div className="mt-2.5 flex flex-wrap items-start gap-2">
<AiCadToolbarGroup label="Mode">
<button
className={cn(
"inline-flex items-center gap-2 rounded-xl border px-3 py-1.5 text-[13px] transition-all duration-150 ease-out",
activeResultTab === "preview"
? "border-[#bfd9c9] bg-[#ecfbf2] text-[#173225]"
: "border-[#ddd7ca] bg-white text-[#5f594d] hover:bg-[#f4f1e8]"
)}
onClick={() => {
resultPanelRef.current?.expand();
setActiveResultTab("preview");
}}
type="button"
>
<Orbit className="h-4 w-4" />
3D Preview
</button>
<button
className={cn(
"inline-flex items-center gap-2 rounded-xl border px-3 py-1.5 text-[13px] transition-all duration-150 ease-out",
activeResultTab === "code"
? "border-[#bfd9c9] bg-[#ecfbf2] text-[#173225]"
: "border-[#ddd7ca] bg-white text-[#5f594d] hover:bg-[#f4f1e8]"
)}
onClick={() => {
resultPanelRef.current?.expand();
setActiveResultTab("code");
}}
type="button"
>
<FileCode2 className="h-4 w-4" />
KCL
</button>
</AiCadToolbarGroup>
<AiCadToolbarGroup label="Display">
<button
className={cn(
"inline-flex h-8 w-8 items-center justify-center rounded-xl border transition-all duration-150 ease-out",
viewerSettings.showGrid
? "border-[#bfd9c9] bg-[#ecfbf2] text-[#173225]"
: "border-[#ddd7ca] bg-white text-[#5f594d] hover:bg-[#f4f1e8]"
)}
onClick={() =>
setViewerSettings((current) => ({
...current,
showGrid: !current.showGrid,
}))
}
title="切换网格"
type="button"
>
<Grid3X3 className="h-4 w-4" />
</button>
<button
className={cn(
"inline-flex h-8 w-8 items-center justify-center rounded-xl border transition-all duration-150 ease-out",
viewerSettings.autoRotate
? "border-[#bfd9c9] bg-[#ecfbf2] text-[#173225]"
: "border-[#ddd7ca] bg-white text-[#5f594d] hover:bg-[#f4f1e8]"
)}
onClick={() =>
setViewerSettings((current) => ({
...current,
autoRotate: !current.autoRotate,
}))
}
title="切换自动旋转"
type="button"
>
<RotateCcw className="h-4 w-4" />
</button>
<button
className={cn(
"inline-flex h-8 w-8 items-center justify-center rounded-xl border transition-all duration-150 ease-out",
viewerSettings.wireframe
? "border-[#bfd9c9] bg-[#ecfbf2] text-[#173225]"
: "border-[#ddd7ca] bg-white text-[#5f594d] hover:bg-[#f4f1e8]"
)}
onClick={() =>
setViewerSettings((current) => ({
...current,
wireframe: !current.wireframe,
}))
}
title="切换线框模式"
type="button"
>
<Box className="h-4 w-4" />
</button>
</AiCadToolbarGroup>
<AiCadToolbarGroup className="lg:ml-auto" label="Session">
<Badge className="rounded-md border-[#ddd7ca] bg-white text-[#5f594d]">
{generationProvider === "kittycad-cad"
? "KittyCAD / Zoo CAD"
: "Tripo3D"}
</Badge>
<Badge className="rounded-md border-[#ddd7ca] bg-white text-[#5f594d]">
{viewerSettings.envPreset}
</Badge>
{activeVersion ? (
<Badge className="rounded-md border-[#ddd7ca] bg-white text-[#5f594d]">
V{activeVersion.versionNumber}
</Badge>
) : null}
</AiCadToolbarGroup>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto bg-[#f2efe7] p-3">
<PanelGroup
autoSaveId={`ai-cad-panel-layout:${panelDirection}`}
className="flex h-full min-h-[780px]"
direction={panelDirection}
>
<Panel
className="order-5 min-h-0"
collapsedSize={panelDirection === "horizontal" ? 11 : 12}
collapsible
defaultSize={29}
minSize={panelDirection === "horizontal" ? 24 : 22}
onCollapse={() => setIsConversationPanelCollapsed(true)}
onExpand={() => setIsConversationPanelCollapsed(false)}
order={3}
ref={conversationPanelRef}
>
{isConversationPanelCollapsed ? (
<AiCadCollapsedPanel
direction={panelDirection}
icon={BrainCircuit}
onExpand={() => conversationPanelRef.current?.expand()}
title="AI-CAD Agent"
/>
) : (
<Card className="flex h-full min-h-0 flex-col rounded-[22px] border-[#ddd7ca] bg-[#fbfaf5] text-[#201c16] shadow-none">
<CardHeader className="space-y-2.5 border-[#e6e0d3] border-b bg-[#f8f5ee] pb-2.5">
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="flex items-center gap-2 text-[15px]">
<BrainCircuit className="h-4 w-4 text-[#6f695d]" />
AI-CAD Agent
</CardTitle>
<CardDescription className="mt-1 text-[#7f7768] text-[12px]">
Continuous design thread for the active version.
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge className="rounded-[8px] border-[#ddd7ca] bg-white text-[#5f594d] text-[11px]">
Agent
</Badge>
<button
className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-[#ddd7ca] bg-white text-[#6f695d] transition-all duration-150 ease-out hover:bg-[#f4f1e8] hover:text-[#201c16]"
type="button"
>
<MoreHorizontal className="h-4 w-4" />
</button>
<button
className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-[#ddd7ca] bg-white text-[#6f695d] transition-all duration-150 ease-out hover:bg-[#f4f1e8] hover:text-[#201c16]"
onClick={() =>
conversationPanelRef.current?.collapse()
}
type="button"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
{activeVersion ? (
<div className="rounded-2xl border border-[#ddd7ca] bg-white px-3 py-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<Badge className="rounded-[8px] border-[#ddd7ca] bg-[#f7f3ea] text-[#5f594d] text-[11px]">
Session Context
</Badge>
<Badge className="rounded-[8px] border-[#ddd7ca] bg-[#f7f3ea] text-[#5f594d] text-[11px]">
Focus V{activeVersion.versionNumber}
</Badge>
</div>
<div className="flex flex-wrap items-center gap-2 text-[#7f7768] text-[11px]">
<span>
{getProviderLabel(activeVersion.provider)}
</span>
<span>
{getStatusLabel(activeVersion.status)}
</span>
<span>
{activeVersion.generationMode === "iterate"
? "Iterative"
: "Initial"}
</span>
</div>
</div>
<p className="mt-1.5 text-[#6d665a] text-[12px] leading-5">
{activeVersion.generationMode === "iterate"
? `当前已经沿着 V${activeVersion.versionNumber} 进入连续修改模式。`
: `当前焦点在 V${activeVersion.versionNumber}。`}
</p>
</div>
) : null}
</CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col p-0">
<ScrollArea className="min-h-0 flex-1 px-3.5 py-3.5">
{activeVersion || messages.length > 1 ? (
<div className="flex min-h-full flex-col justify-end gap-3">
{messages.map((message) => {
const linkedVersion = message.versionId
? findVersionById(versions, message.versionId)
: null;
return (
<div
className={cn(
"rounded-2xl border px-3 py-2.5 shadow-none transition-all duration-150 ease-out",
message.role === "user"
? "ml-auto max-w-[84%] border-[#ddd7ca] bg-white text-[#201c16]"
: "w-full border-[#e2ddd1] bg-[#f7f5ef] text-[#201c16]"
)}
key={message.id}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-[#8a8272] text-[10px] uppercase tracking-[0.18em]">
{message.role === "user" ? (
<>
<WandSparkles className="h-3.5 w-3.5" />
你
</>
) : (
<>
<Bot className="h-3.5 w-3.5" />
AI-CAD Agent
</>
)}
<span>
{formatClockTime(message.createdAt)}
</span>
</div>
{message.role !== "user" &&
message.state ? (
<Badge className="rounded-[8px] border-[#ddd7ca] bg-white text-[#5f594d] text-[10px] uppercase tracking-[0.16em]">
{getMessageStateLabel(message.state)}
</Badge>
) : null}
</div>
{linkedVersion ? (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<Badge className="border-[#ddd7ca] bg-white text-[#5f594d]">
V{linkedVersion.versionNumber}
</Badge>
<Badge
className={cn(
"border px-2 py-0 font-medium text-[11px]",
getStatusBadgeClass(
linkedVersion.status
)
)}
>
{getStatusLabel(linkedVersion.status)}
</Badge>
</div>
) : null}
<div className="mt-2 text-[13px] leading-[1.65]">
{message.content}
</div>
{linkedVersion ? (
<div className="mt-3 flex flex-wrap items-center gap-1.5 border-[#e6e0d3] border-t pt-2.5">
<AiCadMessageActionButton
icon={ArrowUpRight}
label={`聚焦 V${linkedVersion.versionNumber}`}
onClick={() =>
setActiveVersionId(linkedVersion.id)
}
/>
{canIterateKittyCadVersion(
linkedVersion
) ? (
<AiCadMessageActionButton
icon={GitBranch}
label="继续修改"
onClick={() =>
handleFocusVersionForIteration(
linkedVersion
)
}
/>
) : null}
</div>
) : null}
</div>
);
})}
</div>
) : (
<div className="flex min-h-full flex-col justify-end">
<div className="rounded-2xl border border-[#e2ddd1] bg-[#f7f5ef] px-5 py-4 text-[#4d473d]">
<p className="text-[15px] leading-7">
Try requesting a model, ask engineering
questions, or let's explore ideas.
</p>
</div>
</div>
)}
</ScrollArea>
<div className="border-[#e6e0d3] border-t bg-[#f8f5ee] p-3.5">
{activeVersion ? (
<div className="mb-2.5 flex flex-wrap items-center gap-2">
<Badge className="border-[#ddd7ca] bg-white text-[#5f594d]">
生成引擎
</Badge>
<Select
onValueChange={(value) =>
setGenerationProvider(
value as CadGenerationProvider
)
}
value={generationProvider}
>
<SelectTrigger className="h-9 w-[220px] border-[#ddd7ca] bg-white text-[#201c16]">
<SelectValue placeholder="选择生成引擎" />
</SelectTrigger>
<SelectContent>
<SelectItem value="kittycad-cad">
KittyCAD / Zoo CAD
</SelectItem>
<SelectItem value="tripo-3d">
Tripo3D(当前回退)
</SelectItem>
</SelectContent>
</Select>
{generationProvider === "tripo-3d" ? (
<>
<Badge className="border-[#ddd7ca] bg-white text-[#5f594d]">
Tripo 模型版本
</Badge>
<Select
onValueChange={setModelVersion}
value={modelVersion}
>
<SelectTrigger className="h-9 w-[220px] border-[#ddd7ca] bg-white text-[#201c16]">
<SelectValue placeholder="选择模型版本" />
</SelectTrigger>
<SelectContent>
{MODEL_VERSION_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</>
) : (
<Badge className="border-emerald-200 bg-emerald-50 text-emerald-700">
输出 GLB / STEP / KCL
</Badge>
)}
</div>
) : null}
{activeVersion ? (
<div className="mb-2.5 rounded-2xl border border-[#ddd7ca] bg-white px-3.5 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-[#8a8272] text-xs uppercase tracking-[0.16em]">
Suggested Next Edits
</div>
{semanticFocusInsights.length > 0 ? (
<div className="flex flex-wrap gap-2">
{semanticFocusInsights.map((insight) => (
<Badge
className="rounded-md border-[#ddd7ca] bg-[#f7f3ea] text-[#5f594d]"
key={insight.key}
>
{insight.shortLabel}
</Badge>
))}
</div>
) : null}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{followUpSuggestions.slice(0, 3).map((item) => (
<button
className="rounded-xl border border-[#ddd7ca] bg-[#f7f3ea] px-3 py-1.5 text-left text-[#5f594d] text-xs transition-all duration-150 ease-out hover:bg-[#efe9de]"
key={item}
onClick={() => setPrompt(item)}
type="button"
>
{item}
</button>
))}
</div>
</div>
) : null}
<div className="rounded-2xl border border-[#ddd7ca] bg-white p-3 shadow-[0_1px_0_rgba(255,255,255,0.9)]">
<Textarea
className="min-h-[128px] resize-none border-0 bg-transparent px-0 py-0 text-[#201c16] placeholder:text-[#9a9385] focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(event) =>
setPrompt(event.target.value)
}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
swallowAsync(handleSubmit());
}
}}
placeholder="Create a tabletop 25 mm thick with 1 mm chamfers..."
value={prompt}
/>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 border-[#e6e0d3] border-t pt-3">
<div className="flex flex-wrap items-center gap-2">
<Select
onValueChange={(value) =>
setGenerationProvider(
value as CadGenerationProvider
)
}
value={generationProvider}
>
<SelectTrigger className="h-10 min-w-[148px] rounded-xl border-[#ddd7ca] bg-white text-[#201c16]">
<SelectValue placeholder="选择生成引擎" />
</SelectTrigger>
<SelectContent>
<SelectItem value="kittycad-cad">
Fast
</SelectItem>
<SelectItem value="tripo-3d">
Tripo3D
</SelectItem>
</SelectContent>
</Select>
<Button
className="h-8 w-8 rounded-xl border-[#ddd7ca] bg-white text-[#5f594d] hover:bg-[#f4f1e8] hover:text-[#201c16]"
disabled
size="icon"
type="button"
variant="outline"
>
<Paperclip className="h-4 w-4" />
</Button>
<Button
className="h-8 w-8 rounded-xl border-[#ddd7ca] bg-white text-[#5f594d] hover:bg-[#f4f1e8] hover:text-[#201c16]"
disabled
size="icon"
type="button"
variant="outline"
>
<Camera className="h-4 w-4" />
</Button>
</div>
<Button
className="h-10 w-10 rounded-xl border border-[#2ccf78] bg-[#31df86] p-0 text-[#102315] transition-all duration-150 ease-out hover:bg-[#2ed57f]"
disabled={isSubmitDisabled}
onClick={() => swallowAsync(handleSubmit())}
type="button"
>
{isSubmitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
<p className="mt-3 text-[#8a8272] text-xs leading-5">
AI-CAD 可能出错。请始终核对尺寸、约束和几何结果。
</p>
</div>
</div>
</CardContent>
</Card>
)}
</Panel>
<AiCadPanelResizeHandle className="order-4" />
<Panel
className="order-1 min-h-0"
collapsedSize={panelDirection === "horizontal" ? 11 : 12}
collapsible
defaultSize={19}
minSize={panelDirection === "horizontal" ? 16 : 18}
onCollapse={() => setIsHistoryPanelCollapsed(true)}
onExpand={() => setIsHistoryPanelCollapsed(false)}
order={1}
ref={historyPanelRef}
>
{isHistoryPanelCollapsed || shouldForceTreeCollapsed ? (
<AiCadCollapsedPanel
direction={panelDirection}
icon={
activeLeftPanelView === "project" ? FolderOpen : History
}
onExpand={() => historyPanelRef.current?.expand()}
title={
activeLeftPanelView === "project"
? "Project Explorer"
: "Feature Tree"
}
/>
) : activeLeftPanelView === "project" ? (
<Card className="flex h-full min-h-0 flex-col rounded-[22px] border-[#ddd7ca] bg-[#fbfaf5] text-[#201c16] shadow-none">
<CardHeader className="border-[#e6e0d3] border-b bg-[#f8f5ee] pb-2.5">
<CardTitle className="flex items-center gap-2 text-[15px]">
<FolderOpen className="h-4 w-4 text-[#6f695d]" />
Project Explorer
</CardTitle>
<CardDescription className="text-[#7f7768] text-[12px]">
Workspaces, recent prompts, and sync state.
</CardDescription>
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="h-full px-3.5 py-3.5">
<div className="space-y-3">
<div className="rounded-2xl border border-[#ddd7ca] bg-white p-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 font-semibold text-[#201c16] text-sm">
<FolderOpen className="h-4 w-4 text-[#6f695d]" />
Project Summary
</div>
<p className="mt-1 text-[#7f7768] text-xs leading-5">
管理多个 CAD
工作台,并在当前设计线程和项目列表之间快速切换。
</p>
</div>
<Badge className="rounded-[8px] border-[#ddd7ca] bg-[#f7f3ea] text-[#5f594d] text-[11px]">
{workspaces.length} workspace
{workspaces.length === 1 ? "" : "s"}
</Badge>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Badge className="rounded-[8px] border-[#ddd7ca] bg-[#f7f3ea] text-[#5f594d] text-[11px]">
completed {completedCount}
</Badge>
<Badge className="rounded-[8px] border-[#ddd7ca] bg-[#f7f3ea] text-[#5f594d] text-[11px]">
{hasPendingTasks
? `${pendingTaskCount} active task`
: "workspace stable"}
</Badge>
<Badge
className={cn(
"rounded-[8px] text-[11px]",
workspaceSyncState === "synced"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: workspaceSyncState === "error"
? "border-amber-200 bg-amber-50 text-amber-700"
: "border-[#ddd7ca] bg-[#f7f3ea] text-[#5f594d]"
)}
>
{workspaceSyncState === "synced"
? "synced"
: workspaceSyncState === "error"
? "sync issue"
: workspaceSyncState}
</Badge>
</div>
{activeWorkspace ? (
<div className="mt-3 rounded-2xl border border-[#ddd7ca] bg-[#f8f5ee] p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[#8a8272] text-[11px] uppercase tracking-[0.16em]">
Active Workspace
</div>
<div className="mt-1 font-medium text-[#201c16] text-sm">
{normalizedWorkspaceName}
</div>
</div>
<Badge className="rounded-[8px] border-[#bfd9c9] bg-[#ecfbf2] text-[#173225] text-[11px]">
Current
</Badge>
</div>
<p className="mt-2 text-[#5f594d] text-xs leading-5">
{activeWorkspace.latestPrompt ||
"这个工作台还没有开始描述设计意图。"}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<button
className="inline-flex items-center gap-2 rounded-xl border border-[#ddd7ca] bg-white px-3 py-1.5 text-[#5f594d] text-xs transition-all duration-150 ease-out hover:bg-[#f4f1e8] hover:text-[#201c16]"
onClick={() =>
setActiveLeftPanelView("tree")
}
type="button"
>
<History className="h-3.5 w-3.5" />
查看版本树
</button>
<button
className="inline-flex items-center gap-2 rounded-xl border border-[#ddd7ca] bg-white px-3 py-1.5 text-[#5f594d] text-xs transition-all duration-150 ease-out hover:bg-[#f4f1e8] hover:text-[#201c16]"
onClick={handleCreateWorkspace}
type="button"
>
<Plus className="h-3.5 w-3.5" />
新建工作台
</button>
<button
className="inline-flex items-center gap-2 rounded-xl border border-[#ddd7ca] bg-white px-3 py-1.5 text-[#5f594d] text-xs transition-all duration-150 ease-out hover:bg-[#f4f1e8] hover:text-[#201c16]"
disabled={
!activeWorkspaceId ||
isWorkspaceActionDisabled ||
isLoadingWorkspaceList
}
onClick={handleResetWorkbench}
type="button"
>
<RotateCcw className="h-3.5 w-3.5" />
重置
</button>
<button
className="inline-flex items-center gap-2 rounded-xl border border-rose-200 bg-rose-50 px-3 py-1.5 text-rose-700 text-xs transition-all duration-150 ease-out hover:bg-rose-100"
disabled={!canDeleteActiveWorkspace}
onClick={() =>
swallowAsync(handleDeleteWorkspace())
}
type="button"
>
<Trash2 className="h-3.5 w-3.5" />
删除
</button>
</div>
</div>
) : null}
</div>
{isLoadingWorkspaceList ? (
<div className="flex min-h-[220px] items-center justify-center rounded-2xl border border-[#ddd7ca] bg-white text-[#7f7768]">
<div className="flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
正在加载工作台列表...
</div>
</div>
) : null}
{!isLoadingWorkspaceList &&
workspaces.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-2xl border border-[#ddd7ca] border-dashed bg-white px-5 py-12 text-center">
<FolderOpen className="h-10 w-10 text-[#c3bcaf]" />
<p className="mt-4 font-medium text-[#201c16] text-sm">
还没有工作台
</p>
<p className="mt-2 text-[#8a8272] text-xs leading-5">
新建一个工作台后,这里会显示项目列表和最近同步状态。
</p>
</div>
) : null}
{isLoadingWorkspaceList
? null
: workspaces.map((workspace) => {
const isActiveWorkspace =
workspace.id === activeWorkspaceId;
return (
<button
className={cn(
"w-full rounded-2xl border px-3 py-3 text-left transition-all duration-150 ease-out",
isActiveWorkspace
? "border-[#bfd9c9] bg-[#ecfbf2]"
: "border-[#ddd7ca] bg-white hover:bg-[#f4f1e8]"
)}
key={workspace.id}
onClick={() =>
setActiveWorkspaceId(workspace.id)
}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-[#201c16] text-sm">
{normalizeWorkspaceTitle(
workspace.name
)}
</span>
{isActiveWorkspace ? (
<Badge className="rounded-[8px] border-[#bfd9c9] bg-white text-[#173225] text-[10px]">
当前
</Badge>
) : null}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[#8a8272] text-[10px] uppercase tracking-[0.14em]">
<span>
{getProviderLabel(
workspace.generationProvider
)}
</span>
<span>
{workspace.versionCount} versions
</span>
<span>
{workspace.completedCount} done
</span>
{workspace.hasPendingTasks ? (
<span>syncing</span>
) : null}
</div>
<p className="mt-2 line-clamp-2 text-[#5f594d] text-xs leading-5">
{workspace.latestPrompt ||
"还没有开始描述设计意图。"}
</p>
</div>
<div className="flex flex-col items-end gap-2">
<Badge
className={cn(
"rounded-[8px] text-[10px]",
workspace.hasPendingTasks
? "border-amber-200 bg-amber-50 text-amber-700"
: "border-[#ddd7ca] bg-[#f7f3ea] text-[#5f594d]"
)}
>
{workspace.hasPendingTasks
? "进行中"
: "已静止保存"}
</Badge>
<span className="text-[#8a8272] text-[10px]">
{workspace.updatedAt
? formatVersionDetailTime(
new Date(
workspace.updatedAt
).getTime()
)
: "刚创建"}
</span>
</div>
</div>
</button>
);
})}
</div>
</ScrollArea>
</CardContent>
</Card>
) : (
<Card className="flex h-full min-h-0 flex-col rounded-[22px] border-[#ddd7ca] bg-[#fbfaf5] text-[#201c16] shadow-none">
<CardHeader className="border-[#e6e0d3] border-b bg-[#f8f5ee] pb-2.5">
<CardTitle className="flex items-center gap-2 text-[15px]">
<History className="h-4 w-4 text-[#6f695d]" />
Feature Tree
</CardTitle>
<CardDescription className="text-[#7f7768] text-[12px]">
Version chain, variants, and compact inspector.
</CardDescription>
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="h-full px-3.5 py-3.5">
<div className="space-y-2.5">
{versions.length > 0 ? (
<div className="rounded-2xl border border-[#ddd7ca] bg-white p-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 font-semibold text-[#201c16] text-sm">
<GitBranch className="h-4 w-4 text-[#6f695d]" />
Tree Summary
</div>
<p className="mt-1 text-[#7f7768] text-xs leading-5">
当前项目的 root、branch 和 variant 概况。
</p>
</div>
{activeVersion ? (
<Badge className="rounded-[8px] border-[#ddd7ca] bg-[#f7f3ea] text-[#5f594d] text-[11px]">
depth {activeVersionLineage.length}
</Badge>
) : null}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Badge className="rounded-[8px] border-[#ddd7ca] bg-[#f7f3ea] text-[#5f594d] text-[11px]">
roots {rootVersionCount}
</Badge>
<Badge className="rounded-[8px] border-[#ddd7ca] bg-[#f7f3ea] text-[#5f594d] text-[11px]">
branches {branchPointCount}
</Badge>
<Badge className="rounded-[8px] border-[#ddd7ca] bg-[#f7f3ea] text-[#5f594d] text-[11px]">
variants {derivedVariantCount}
</Badge>
</div>
{activeVersionLineage.length > 0 ? (
<div className="mt-2.5">
<div className="mb-2 text-[#8a8272] text-[11px] uppercase tracking-[0.16em]">
active path
</div>
<div className="flex flex-wrap gap-2">
{activeVersionLineage.map(
(lineageVersion) => (
<button
className={cn(
"inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[11px] transition-all duration-150 ease-out",
lineageVersion.id ===
activeVersion?.id
? "border-[#bfd9c9] bg-[#ecfbf2] text-[#173225]"
: "border-[#ddd7ca] bg-white text-[#5f594d] hover:bg-[#f4f1e8] hover:text-[#201c16]"
)}
key={lineageVersion.id}
onClick={() =>
setActiveVersionId(
lineageVersion.id
)
}
type="button"
>
V{lineageVersion.versionNumber}
{lineageVersion.generationMode ===
"iterate"
? " · 变体"
: " · 起点"}
</button>
)
)}
</div>
</div>
) : null}
</div>
) : null}
{versions.length === 0 ? null : null}
{versions.map((version) => {
const isActive = version.id === activeVersion?.id;
const versionDepth = getVersionDepth(
version,
versions
);
const parentVersion = findVersionById(
versions,
version.parentVersionId
);
const childVersions =
versionChildrenMap.get(version.id) ?? [];
const siblingCount = getSiblingVersionCount(
version,
versions
);
const isOnActiveLineage =
activeVersionLineageIds.has(version.id);
const versionDownloadEntries =
buildDownloadEntries(version);
const isVersionFinal = FINAL_TASK_STATUS.has(
version.status
);
const isVersionDetailsOpen =
expandedVersionDetails[version.id] ?? isActive;
const versionFeedback = version.feedback ?? null;
const versionSemanticInsights =
versionSemanticInsightMap.get(version.id) ?? [];
return (
<div
className={cn(
"w-full rounded-2xl border text-left transition-all duration-150 ease-out",
isActive
? "border-[#bfd9c9] bg-[#ecfbf2]"
: "border-[#ddd7ca] bg-white hover:bg-[#f4f1e8]"
)}
key={version.id}
>
<div className="flex items-start gap-2">
<button
className="min-w-0 flex-1 text-left"
onClick={() =>
setActiveVersionId(version.id)
}
type="button"
>
<div
className="flex items-start gap-3 px-3 py-2"
style={{
paddingLeft: `${12 + Math.min(versionDepth, 6) * 12}px`,
}}
>
<div className="relative flex w-4 shrink-0 justify-center self-stretch">
{versionDepth > 0 ? (
<span className="-translate-x-1/2 absolute inset-y-0 left-1/2 w-px bg-[#e7e1d4]" />
) : null}
<span
className={cn(
"mt-1.5 h-2.5 w-2.5 rounded-full",
getStatusDotClass(
version.status,
isActive
)
)}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-[#201c16] text-xs uppercase tracking-[0.16em]">
V{version.versionNumber}
</span>
<span className="text-[#8a8272] text-[11px] uppercase tracking-[0.14em]">
{parentVersion
? "feature"
: "root"}
</span>
{version.generationMode ===
"iterate" ? (
<span className="text-[#8a8272] text-[11px]">
variant
</span>
) : null}
{isOnActiveLineage ? (
<span className="text-[#8a8272] text-[11px]">
active
</span>
) : null}
</div>
<p className="mt-1 line-clamp-1 text-[#5f594d] text-[12px] leading-5">
{version.prompt}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge
className={cn(
"border px-2 py-0 font-medium text-[11px]",
getStatusBadgeClass(
version.status
)
)}
>
{getStatusLabel(version.status)}
</Badge>
{version.status === "running" ||
version.status === "queued" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-sky-600" />
) : version.status ===
"success" ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
) : version.status ===
"failed" ? (
<XCircle className="h-3.5 w-3.5 text-rose-600" />
) : null}
</div>
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-[#8a8272] text-[10px] uppercase tracking-[0.12em]">
<span>
{formatClockTime(
version.createdAt
)}
</span>
<span>
{getProviderLabel(
version.provider
)}
</span>
{parentVersion ? (
<span>
from V
{parentVersion.versionNumber}
</span>
) : (
<span>root</span>
)}
{childVersions.length > 0 ? (
<span>
{childVersions.length} variants
</span>
) : null}
{siblingCount > 1 ? (
<span>
{siblingCount} siblings
</span>
) : null}
</div>
{versionSemanticInsights.length >
0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{versionSemanticInsights
.slice(0, 2)
.map((insight) => (
<Badge
className="rounded-[8px] border-[#ddd7ca] bg-[#f7f3ea] text-[#5f594d] text-[10px]"
key={`${version.id}-${insight.key}`}
>
{insight.shortLabel}
</Badge>
))}
{versionSemanticInsights.length >
2 ? (
<Badge className="rounded-[8px] border-[#ddd7ca] bg-[#f7f3ea] text-[#5f594d] text-[10px]">
+
{versionSemanticInsights.length -
2}
</Badge>
) : null}
</div>
) : null}
</div>
</div>
{typeof version.progress === "number" &&
!isVersionFinal ? (
<div
className="px-3 pb-2"
style={{
paddingLeft: `${24 + Math.min(versionDepth, 6) * 12}px`,
}}
>
<div className="mb-1 flex items-center justify-between text-[#8a8272] text-[11px]">
<span>生成进度</span>
<span>
{Math.round(version.progress)}%
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-[#e7e1d4]">
<div
className="h-full rounded-full bg-gradient-to-r from-[#32d67d] to-[#79e5b0]"
style={{
width: `${Math.max(
6,
Math.min(
100,
Math.round(version.progress)
)
)}%`,
}}
/>
</div>
</div>
) : null}
</button>
<div className="pt-2 pr-2">
<button
aria-label={
isVersionDetailsOpen
? `收起 V${version.versionNumber} 详情`
: `展开 V${version.versionNumber} 详情`
}
className="inline-flex h-6 w-6 items-center justify-center rounded-[8px] border border-[#ddd7ca] bg-white text-[#8a8272] transition-all duration-150 ease-out hover:bg-[#f4f1e8] hover:text-[#201c16]"
onClick={() =>
toggleVersionDetails(
version.id,
!isVersionDetailsOpen
)
}
type="button"
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 transition-transform",
isVersionDetailsOpen && "rotate-90"
)}
/>
</button>
</div>
</div>
<div
className="flex flex-wrap items-center justify-between gap-2 border-[#e6e0d3] border-t px-3 py-2"
hidden={!isActive && !isVersionDetailsOpen}
style={{
paddingLeft: `${24 + Math.min(versionDepth, 6) * 12}px`,
}}
>
<div className="flex flex-wrap items-center gap-2">
<button
className="inline-flex items-center gap-1 rounded-[8px] border border-[#ddd7ca] bg-white px-3 py-1.5 text-[#5f594d] text-xs transition-all duration-150 ease-out hover:bg-[#f4f1e8] hover:text-[#201c16]"
onClick={() =>
handleFocusVersionForIteration(
version
)
}
type="button"
>
<GitBranch className="h-3.5 w-3.5" />
{canIterateKittyCadVersion(version)
? "派生新变体"
: "设为参考版本"}
</button>
<button
className={cn(
"inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-xs transition",
versionFeedback === "positive"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-[#ddd7ca] bg-white text-[#5f594d] hover:bg-emerald-50 hover:text-[#201c16]",
!isVersionFinal &&
"cursor-not-allowed opacity-60"
)}
disabled={!isVersionFinal}
onClick={() =>
setVersionFeedback(
version.id,
versionFeedback === "positive"
? null
: "positive"
)
}
type="button"
>
<ThumbsUp className="h-3.5 w-3.5" />
有帮助
</button>
<button
className={cn(
"inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-xs transition",
versionFeedback === "negative"
? "border-rose-200 bg-rose-50 text-rose-700"
: "border-[#ddd7ca] bg-white text-[#5f594d] hover:bg-rose-50 hover:text-[#201c16]",
!isVersionFinal &&
"cursor-not-allowed opacity-60"
)}
disabled={!isVersionFinal}
onClick={() =>
setVersionFeedback(
version.id,
versionFeedback === "negative"
? null
: "negative"
)
}
type="button"
>
<ThumbsDown className="h-3.5 w-3.5" />
不理想
</button>
</div>
<Collapsible
onOpenChange={(open) =>
toggleVersionDetails(version.id, open)
}
open={isVersionDetailsOpen}
>
<button
className="inline-flex items-center gap-1 rounded-[8px] border border-[#ddd7ca] bg-white px-3 py-1.5 text-[#5f594d] text-xs transition-all duration-150 ease-out hover:bg-[#f4f1e8] hover:text-[#201c16]"
onClick={() =>
toggleVersionDetails(
version.id,
!isVersionDetailsOpen
)
}
type="button"
>
Inspector
<ChevronDown
className={cn(
"h-3.5 w-3.5 transition-transform",
isVersionDetailsOpen && "rotate-180"
)}
/>
</button>
<CollapsibleContent className="pt-3">
<div className="grid gap-2 sm:grid-cols-2">
<div className="rounded-[8px] border border-[#ddd7ca] bg-[#f8f5ee] px-3 py-3">
<div className="text-[#8a8272] text-[11px] uppercase tracking-[0.16em]">
执行方式
</div>
<div className="mt-2 text-[#201c16] text-sm">
{version.generationMode ===
"iterate"
? "基于现有版本连续修改"
: "从自然语言创建新版本"}
</div>
</div>
<div className="rounded-[8px] border border-[#ddd7ca] bg-[#f8f5ee] px-3 py-3">
<div className="text-[#8a8272] text-[11px] uppercase tracking-[0.16em]">
参考来源
</div>
<div className="mt-2 text-[#201c16] text-sm">
{parentVersion
? `V${parentVersion.versionNumber}`
: "首轮创建"}
</div>
</div>
<div className="rounded-[8px] border border-[#ddd7ca] bg-[#f8f5ee] px-3 py-3">
<div className="text-[#8a8272] text-[11px] uppercase tracking-[0.16em]">
派生关系
</div>
<div className="mt-2 text-[#201c16] text-sm">
{childVersions.length > 0
? `已向后派生 ${childVersions.length} 个变体`
: "当前还没有后续派生版本"}
</div>
</div>
<div className="rounded-[8px] border border-[#ddd7ca] bg-[#f8f5ee] px-3 py-3">
<div className="text-[#8a8272] text-[11px] uppercase tracking-[0.16em]">
输出文件
</div>
<div className="mt-2 text-[#201c16] text-sm">
{versionDownloadEntries.length > 0
? versionDownloadEntries
.slice(0, 4)
.map((entry) => entry.label)
.join(" / ")
: "等待服务端返回"}
</div>
</div>
<div className="rounded-[8px] border border-[#ddd7ca] bg-[#f8f5ee] px-3 py-3">
<div className="text-[#8a8272] text-[11px] uppercase tracking-[0.16em]">
生成时间
</div>
<div className="mt-2 text-[#201c16] text-sm">
{formatVersionDetailTime(
version.createdAt
)}
</div>
</div>
<div className="rounded-[8px] border border-[#ddd7ca] bg-[#f8f5ee] px-3 py-3 sm:col-span-2">
<div className="text-[#8a8272] text-[11px] uppercase tracking-[0.16em]">
设计语义
</div>
{versionSemanticInsights.length >
0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{versionSemanticInsights.map(
(insight) => (
<Badge
className="border-sky-200 bg-sky-50 text-sky-700"
key={`${version.id}-${insight.key}-detail`}
>
{insight.label}
</Badge>
)
)}
</div>
) : (
<div className="mt-2 text-[#6d665a] text-sm">
当前还没有识别到明显的结构关注点。
</div>
)}
</div>
</div>
<div className="mt-3 rounded-[8px] border border-[#ddd7ca] bg-[#f8f5ee] px-3 py-3">
<div className="flex flex-wrap items-center gap-3 text-[#8a8272] text-[11px] uppercase tracking-[0.16em]">
<span>
任务{" "}
{truncateIdentifier(
version.taskId
)}
</span>
<span>
会话{" "}
{truncateIdentifier(
version.conversationId
)}
</span>
<span>
{version.codeUrl
? "包含 KCL 代码结果"
: "当前未记录 KCL 文件"}
</span>
</div>
<p className="mt-3 text-[#5f594d] text-sm leading-6">
{version.generationMode ===
"iterate"
? "这轮更接近官方当前的连续对话建模节奏,会沿着上一版结果继续推进,而不是把每次输入都当成全新项目。"
: "这轮是一个新的 CAD 版本起点,适合把初始结构、约束和整体造型一次讲清楚。"}
</p>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
);
})}
</div>
</ScrollArea>
</CardContent>
</Card>
)}
</Panel>
<AiCadPanelResizeHandle className="order-2" />
<Panel
className="order-3 min-h-0"
collapsedSize={panelDirection === "horizontal" ? 11 : 12}
collapsible
defaultSize={52}
minSize={panelDirection === "horizontal" ? 34 : 22}
onCollapse={() => setIsResultPanelCollapsed(true)}
onExpand={() => setIsResultPanelCollapsed(false)}
order={2}
ref={resultPanelRef}
>
{isResultPanelCollapsed ? (
<AiCadCollapsedPanel
direction={panelDirection}
icon={Orbit}
onExpand={() => resultPanelRef.current?.expand()}
title="建模画布"
/>
) : (
<Card className="flex h-full min-h-0 flex-col rounded-[22px] border-[#ddd7ca] bg-[#fbfaf5] text-[#201c16] shadow-none">
<CardHeader
className={cn(
"border-[#e6e0d3] border-b bg-[#f8f5ee] pb-3",
!activeVersion && "px-5 py-2.5"
)}
>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="flex items-center gap-2 text-base">
<Orbit className="h-4 w-4 text-[#6f695d]" />
{activeVersion ? "建模画布" : "Canvas"}
</CardTitle>
<CardDescription className="mt-1 text-[#7f7768]">
{activeVersion
? "把中间区域收成更接近官方的大画布,预览优先,代码与对照放到下层。"
: "The center canvas stays focused on the current model."}
</CardDescription>
</div>
{activeVersion ? (
<Badge className="rounded-md border-[#ddd7ca] bg-white text-[#5f594d]">
V{activeVersion.versionNumber}
</Badge>
) : null}
</div>
</CardHeader>
<CardContent className="min-h-0 flex-1 overflow-hidden p-0">
{activeVersion ? (
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-[#ddd7ca] bg-white px-4 py-3">
<div className="text-[#8a8272] text-xs uppercase tracking-[0.16em]">
视图环境
</div>
<div className="mt-2 font-medium text-[#201c16]">
{viewerSettings.envPreset}
</div>
</div>
<div className="rounded-2xl border border-[#ddd7ca] bg-white px-4 py-3">
<div className="text-[#8a8272] text-xs uppercase tracking-[0.16em]">
结果状态
</div>
<div className="mt-2 font-medium text-[#201c16]">
{activeVersion
? getStatusLabel(activeVersion.status)
: "等待生成"}
</div>
</div>
<div className="rounded-2xl border border-[#ddd7ca] bg-white px-4 py-3">
<div className="text-[#8a8272] text-xs uppercase tracking-[0.16em]">
输出文件
</div>
<div className="mt-2 font-medium text-[#201c16]">
{activeDownloadEntries.length > 0
? activeDownloadEntries
.slice(0, 3)
.map((item) => item.label)
.join(" / ")
: "暂无"}
</div>
</div>
</div>
{previewSourceVersion ? (
<div className="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sky-700 text-sm leading-6">
当前版本先返回了代码结果,右侧 3D 预览暂时沿用
V{previewSourceVersion.versionNumber}{" "}
作为参考。
</div>
) : null}
{activeVersion && activeExecutionSummary ? (
<div className="rounded-2xl border border-[#ddd7ca] bg-white px-4 py-3">
<Reasoning
defaultOpen={
activeVersion.status === "running" ||
activeVersion.status === "queued"
}
isStreaming={
activeVersion.status === "running" ||
activeVersion.status === "queued"
}
>
<ReasoningTrigger className="w-full justify-between text-[#5f594d] hover:text-[#201c16]">
<span className="flex items-center gap-2">
<BrainCircuit className="h-4 w-4 text-[#6f695d]" />
执行摘要
</span>
</ReasoningTrigger>
<ReasoningContent className="text-[#5f594d]">
{activeExecutionSummary}
</ReasoningContent>
</Reasoning>
</div>
) : null}
<Tabs
className="w-full"
onValueChange={(value) =>
setActiveResultTab(
value as "preview" | "code"
)
}
value={activeResultTab}
>
<TabsList className="grid w-full grid-cols-2 border border-[#ddd7ca] bg-white p-1">
<TabsTrigger
className="gap-2 text-[#5f594d] data-[state=active]:bg-[#ecfbf2] data-[state=active]:text-[#173225]"
value="preview"
>
<Orbit className="h-4 w-4" />
3D 预览
</TabsTrigger>
<TabsTrigger
className="gap-2 text-[#5f594d] data-[state=active]:bg-[#ecfbf2] data-[state=active]:text-[#173225]"
disabled={!activeCodeUrl}
value="code"
>
<FileCode2 className="h-4 w-4" />
KCL 代码
</TabsTrigger>
</TabsList>
<TabsContent
className="mt-4 min-h-[360px]"
value="preview"
>
<div className="space-y-4">
{compareCandidateVersions.length > 0 ? (
<div className="rounded-2xl border border-[#ddd7ca] bg-white px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2 font-medium text-[#201c16] text-sm">
<GitBranch className="h-4 w-4 text-[#6f695d]" />
双版本对照
</div>
<p className="mt-1 text-[#7f7768] text-xs leading-5">
优先推荐父版本、同层变体和当前链路上的近邻版本,方便直接比对这轮修改有没有偏离。
</p>
</div>
{compareVersion ? (
<Badge className="border-[#ddd7ca] bg-[#f7f3ea] text-[#5f594d]">
当前对照 V
{compareVersion.versionNumber}
</Badge>
) : null}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{compareCandidateVersions
.slice(0, 8)
.map((version) => (
<button
className={cn(
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition",
version.id ===
compareVersion?.id
? "border-[#bfd9c9] bg-[#ecfbf2] text-[#173225]"
: "border-[#ddd7ca] bg-white text-[#5f594d] hover:bg-[#f4f1e8] hover:text-[#201c16]"
)}
key={`candidate-${version.id}`}
onClick={() =>
setCompareVersionId(
version.id
)
}
type="button"
>
V{version.versionNumber}
{version.parentVersionId ===
activeVersion?.id
? " · 子变体"
: version.id ===
activeParentVersion?.id
? " · 父版本"
: version.parentVersionId ===
activeVersion?.parentVersionId &&
version.id !==
activeVersion?.id
? " · 同层"
: ""}
</button>
))}
</div>
</div>
) : null}
<div
className={cn(
"grid gap-4",
compareVersion && "xl:grid-cols-2"
)}
>
<div className="overflow-hidden rounded-[22px] border border-[#ddd7ca] bg-white">
<div className="flex items-center justify-between border-[#e6e0d3] border-b bg-[#f8f5ee] px-4 py-3">
<div>
<div className="font-medium text-[#201c16] text-sm">
{activeVersion
? `当前版本 V${activeVersion.versionNumber}`
: "当前版本"}
</div>
<p className="mt-1 text-[#7f7768] text-xs">
当前工作焦点
</p>
</div>
{activeVersion ? (
<Badge className="rounded-md border-[#ddd7ca] bg-white text-[#5f594d]">
{getStatusLabel(
activeVersion.status
)}
</Badge>
) : null}
</div>
<div className="min-h-[360px] bg-[#efeadf]">
<Model3DViewer
emptyStateTitle={
activeVersion?.codeUrl
? "当前版本已生成 KCL,可先切到代码视图查看;如果后续返回 3D 文件,这里会自动切换预览。"
: "生成完成后,这里会显示当前版本的 3D 预览"
}
modelUrl={
activePreviewVersion?.modelUrl
}
previewImageUrl={
activePreviewVersion?.previewImageUrl
}
settings={viewerSettings}
/>
</div>
</div>
{compareVersion ? (
<div className="overflow-hidden rounded-[22px] border border-[#ddd7ca] bg-white">
<div className="flex items-center justify-between border-[#e6e0d3] border-b bg-[#f8f5ee] px-4 py-3">
<div>
<div className="font-medium text-[#201c16] text-sm">
对照版本 V
{compareVersion.versionNumber}
</div>
<p className="mt-1 text-[#7f7768] text-xs">
{compareVersion.parentVersionId ===
activeVersion?.id
? "这是当前版本向后派生出的子变体。"
: compareVersion.id ===
activeParentVersion?.id
? "这是当前版本直接沿用的父版本。"
: "这是与当前版本最相关的对照节点。"}
</p>
</div>
<div className="flex items-center gap-2">
<Badge
className={cn(
"border px-2 py-0 font-medium text-[11px]",
getStatusBadgeClass(
compareVersion.status
)
)}
>
{getStatusLabel(
compareVersion.status
)}
</Badge>
<button
className="inline-flex items-center gap-1 rounded-md border border-[#ddd7ca] bg-white px-3 py-1.5 text-[#5f594d] text-xs transition hover:bg-[#f4f1e8] hover:text-[#201c16]"
onClick={() =>
setActiveVersionId(
compareVersion.id
)
}
type="button"
>
查看该版本
</button>
</div>
</div>
<div className="min-h-[360px] bg-[#efeadf]">
<Model3DViewer
emptyStateTitle="这个对照版本当前还没有可预览的 3D 结果。"
modelUrl={
comparePreviewVersion?.modelUrl
}
previewImageUrl={
comparePreviewVersion?.previewImageUrl
}
settings={viewerSettings}
/>
</div>
</div>
) : null}
</div>
{versionDiffHighlights.length > 0 ? (
<div className="grid gap-2">
{versionDiffHighlights.map(
(highlight) => (
<div
className="flex items-start gap-2 rounded-2xl border border-[#ddd7ca] bg-white px-3 py-2.5"
key={highlight}
>
<CheckCircle2 className="mt-0.5 h-4 w-4 flex-none text-emerald-600" />
<p className="text-[#5f594d] text-sm leading-6">
{highlight}
</p>
</div>
)
)}
</div>
) : null}
</div>
</TabsContent>
<TabsContent
className="mt-4 min-h-[360px]"
value="code"
>
<div className="space-y-4">
{compareVersion ? (
<div className="rounded-2xl border border-[#ddd7ca] bg-white px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 font-medium text-[#201c16] text-sm">
<FileCode2 className="h-4 w-4 text-[#6f695d]" />
KCL 双版本对照
</div>
<button
className={cn(
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition",
showOnlyChangedCodeRows
? "border-[#bfd9c9] bg-[#ecfbf2] text-[#173225]"
: "border-[#ddd7ca] bg-white text-[#5f594d] hover:bg-[#f4f1e8] hover:text-[#201c16]"
)}
onClick={() =>
setShowOnlyChangedCodeRows(
(current) => !current
)
}
type="button"
>
{showOnlyChangedCodeRows
? "显示全部"
: "只看变更"}
</button>
</div>
<p className="mt-2 text-[#7f7768] text-xs leading-5">
这里会并排显示当前版本和对照版本的代码结果,方便检查这轮连续迭代到底改动了哪些约束和结构表达。
</p>
</div>
) : null}
{compareVersion ? (
<div className="flex flex-wrap gap-2">
<Badge className="border-emerald-200 bg-emerald-50 text-emerald-700">
新增 {codeDiffResult.summary.added}
</Badge>
<Badge className="border-rose-200 bg-rose-50 text-rose-700">
删除 {codeDiffResult.summary.removed}
</Badge>
<Badge className="border-amber-200 bg-amber-50 text-amber-700">
修改 {codeDiffResult.summary.changed}
</Badge>
<Badge className="border-[#ddd7ca] bg-white text-[#5f594d]">
未变{" "}
{codeDiffResult.summary.unchanged}
</Badge>
</div>
) : null}
{compareVersion &&
semanticCodeDiffInsights.length > 0 ? (
<div className="rounded-2xl border border-[#ddd7ca] bg-white px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 font-medium text-[#201c16] text-sm">
<BrainCircuit className="h-4 w-4 text-[#6f695d]" />
设计语义摘要
</div>
<div className="flex flex-wrap gap-2">
{semanticCodeDiffInsights.map(
(insight) => (
<Badge
className="border-sky-200 bg-sky-50 text-sky-700"
key={insight.key}
>
{insight.shortLabel}{" "}
{insight.count}
</Badge>
)
)}
</div>
</div>
<p className="mt-2 text-[#7f7768] text-xs leading-5">
这层摘要会优先把 KCL
里的差异翻译成更接近设计语言的改动方向,方便先判断本轮是不是在改你关心的结构。
</p>
<div className="mt-4 grid gap-3 xl:grid-cols-2">
{semanticCodeDiffInsights.map(
(insight) => (
<div
className="rounded-2xl border border-[#ddd7ca] bg-[#f8f5ee] px-4 py-3"
key={insight.key}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-medium text-[#201c16] text-sm">
{insight.label}
</div>
<p className="mt-1 text-[#7f7768] text-xs">
{insight.detail}
</p>
</div>
<Badge className="border-[#ddd7ca] bg-white text-[#5f594d]">
命中 {insight.count}
</Badge>
</div>
{insight.examples.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{insight.examples.map(
(example) => (
<div
className="rounded-full border border-[#ddd7ca] bg-white px-3 py-1 text-[#5f594d] text-[11px]"
key={`${insight.key}-${example}`}
>
{example}
</div>
)
)}
</div>
) : null}
</div>
)
)}
</div>
</div>
) : null}
{compareVersion &&
codeDiffInsights.length > 0 ? (
<div className="grid gap-2">
{codeDiffInsights.map((insight) => (
<div
className="flex items-start gap-2 rounded-2xl border border-[#ddd7ca] bg-white px-3 py-2.5"
key={insight}
>
<CheckCircle2 className="mt-0.5 h-4 w-4 flex-none text-sky-600" />
<p className="text-[#5f594d] text-sm leading-6">
{insight}
</p>
</div>
))}
</div>
) : null}
{codeActionError ? (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-2 text-rose-700 text-xs">
{codeActionError}
</div>
) : null}
<div
className={cn(
"grid gap-4",
compareVersion && "xl:grid-cols-2"
)}
>
{compareVersion ? (
<AiCadCodeDiffPane
code={activeCodeContent}
emptyMessage="当前版本还没有可预览的 KCL 代码。"
error={
activeCodePreviewError
? `当前版本 KCL 暂时无法预览:${activeCodePreviewError}`
: null
}
loading={isLoadingActiveCodePreview}
onCopy={(code) =>
swallowAsync(
handleCopyCodeContent(code)
)
}
openUrl={activeCodeUrl}
rows={visibleCodeDiffRows}
rowsEmptyMessage="当前筛选下,当前版本没有需要展示的差异行。"
side="right"
subtitle={
activeCodeLineCount > 0
? `当前共 ${activeCodeLineCount} 行。`
: "连续迭代返回的代码会优先显示在这里。"
}
title={
activeVersion
? `当前版本 V${activeVersion.versionNumber} KCL`
: "KCL 代码"
}
/>
) : (
<div className="flex min-h-[360px] flex-col overflow-hidden rounded-[22px] border border-[#ddd7ca] bg-white">
<div className="flex flex-wrap items-center justify-between gap-3 border-[#e6e0d3] border-b bg-[#f8f5ee] px-4 py-3">
<div>
<div className="font-medium text-[#201c16] text-sm">
{activeVersion
? `当前版本 V${activeVersion.versionNumber} KCL`
: "KCL 代码"}
</div>
<p className="mt-1 text-[#7f7768] text-xs">
{activeCodeLineCount > 0
? `当前共 ${activeCodeLineCount} 行。`
: "连续迭代返回的代码会优先显示在这里。"}
</p>
</div>
<div className="flex flex-wrap gap-2">
{activeCodeUrl ? (
<Button
asChild
className="gap-2 rounded-full border-[#ddd7ca] bg-white text-[#201c16] hover:bg-[#f4f1e8]"
size="sm"
type="button"
variant="outline"
>
<a
href={activeCodeUrl}
rel="noreferrer"
target="_blank"
>
<ArrowUpRight className="h-4 w-4" />
打开代码
</a>
</Button>
) : null}
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="p-4">
{isLoadingActiveCodePreview ? (
<div className="flex min-h-[280px] items-center justify-center rounded-2xl border border-[#ddd7ca] bg-[#f8f5ee] text-[#7f7768]">
<div className="flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
正在加载当前版本 KCL...
</div>
</div>
) : activeCodePreviewError ? (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-5 text-rose-700 text-sm leading-6">
当前版本 KCL 暂时无法预览:
{activeCodePreviewError}
</div>
) : activeCodeContent ? (
<CodeBlock
className="overflow-hidden rounded-2xl border border-[#ddd7ca] bg-white"
code={activeCodeContent}
language="python"
showLineNumbers
>
<CodeBlockCopyButton
className="h-8 w-8 rounded-full border border-[#ddd7ca] bg-white text-[#5f594d] hover:bg-[#f4f1e8] hover:text-[#201c16]"
onCopy={() =>
setCodeActionError(null)
}
onError={(error) => {
setCodeActionError(
error instanceof Error
? error.message
: "复制 KCL 代码失败"
);
}}
size="icon"
variant="ghost"
/>
</CodeBlock>
) : (
<div className="flex min-h-[280px] items-center justify-center rounded-2xl border border-[#ddd7ca] border-dashed bg-[#f8f5ee] px-5 py-10 text-center text-[#7f7768] text-sm leading-6">
当前版本还没有可预览的 KCL
代码。
</div>
)}
</div>
</ScrollArea>
</div>
)}
{compareVersion ? (
<AiCadCodeDiffPane
code={compareCodeContent}
emptyMessage="对照版本还没有可预览的 KCL 代码。"
error={
compareCodePreviewError
? `对照版本 KCL 暂时无法预览:${compareCodePreviewError}`
: null
}
loading={isLoadingCompareCodePreview}
onCopy={(code) =>
swallowAsync(
handleCopyCodeContent(code)
)
}
openUrl={compareCodeUrl}
rows={visibleCodeDiffRows}
rowsEmptyMessage="当前筛选下,对照版本没有需要展示的差异行。"
side="left"
subtitle={
compareCodeLineCount > 0
? `当前共 ${compareCodeLineCount} 行。`
: "如果这版只生成了 3D 或还没返回代码,这里会保持空白。"
}
title={`对照版本 V${compareVersion.versionNumber} KCL`}
/>
) : null}
</div>
</div>
</TabsContent>
</Tabs>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_280px]">
<div className="rounded-[22px] border border-[#ddd7ca] bg-white p-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="font-semibold text-[#201c16] text-sm">
{activeVersion
? `V${activeVersion.versionNumber} 设计摘要`
: "当前没有激活版本"}
</div>
<p className="mt-2 text-[#5f594d] text-sm leading-6">
{activeVersion
? activeVersion.prompt
: "在左侧输入你的 CAD 需求,工作台会开始生成第一版。"}
</p>
{activeVersion ? (
<div className="mt-3 space-y-1 text-[#7f7768] text-xs">
<p>
当前版本由{" "}
{getProviderLabel(
activeVersion.provider
)}{" "}
生成,
{activeVersion.modelVersion}
</p>
{activeParentVersion ? (
<p>
本轮基于 V
{
activeParentVersion.versionNumber
}{" "}
连续修改。
</p>
) : null}
{previewSourceVersion ? (
<p>
当前显示的 3D 预览来自 V
{
previewSourceVersion.versionNumber
}
。
</p>
) : null}
</div>
) : null}
</div>
{primaryDownloadEntry ? (
<Button
asChild
className="gap-2 rounded-full bg-[#31df86] text-[#102315] hover:bg-[#2ed57f]"
>
<a
href={primaryDownloadEntry.url}
rel="noreferrer"
target="_blank"
>
<Download className="h-4 w-4" />
下载 {primaryDownloadEntry.label}
</a>
</Button>
) : (
<Button
className="gap-2 rounded-full"
disabled
variant="secondary"
>
<Download className="h-4 w-4" />
等待结果
</Button>
)}
</div>
{activeVersion?.errorMessage ? (
<>
<Separator className="my-4 bg-[#e6e0d3]" />
<p className="text-rose-700 text-sm leading-6">
{activeVersion.errorMessage}
</p>
</>
) : null}
{activeDownloadEntries.length > 1 ? (
<>
<Separator className="my-4 bg-[#e6e0d3]" />
<div className="flex flex-wrap gap-2">
{activeDownloadEntries
.slice(0, 6)
.map((entry) => (
<Button
asChild
className="rounded-full border-[#ddd7ca] bg-white text-[#201c16] hover:bg-[#f4f1e8]"
key={entry.key}
size="sm"
variant="outline"
>
<a
href={entry.url}
rel="noreferrer"
target="_blank"
>
{entry.label}
</a>
</Button>
))}
</div>
</>
) : null}
{activeVersion ? (
<>
<Separator className="my-4 bg-[#e6e0d3]" />
<div className="rounded-2xl border border-[#ddd7ca] bg-[#f8f5ee] p-4">
<div className="mb-3 flex items-center gap-2 font-semibold text-[#201c16] text-sm">
<RotateCcw className="h-4 w-4 text-[#6f695d]" />
版本对比
</div>
{activeVersionLineage.length > 0 ? (
<div className="mb-4 flex flex-wrap gap-2">
{activeVersionLineage.map(
(lineageVersion) => (
<button
className={cn(
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition",
lineageVersion.id ===
activeVersion.id
? "border-[#bfd9c9] bg-[#ecfbf2] text-[#173225]"
: "border-[#ddd7ca] bg-white text-[#5f594d] hover:bg-[#f4f1e8] hover:text-[#201c16]"
)}
key={`compare-lineage-${lineageVersion.id}`}
onClick={() =>
setActiveVersionId(
lineageVersion.id
)
}
type="button"
>
V
{lineageVersion.versionNumber}
</button>
)
)}
</div>
) : null}
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-[#ddd7ca] bg-white p-4">
<div className="flex items-center gap-2 text-[#7f7768] text-xs uppercase tracking-[0.16em]">
<History className="h-3.5 w-3.5" />
{compareVersion
? `对照版本 V${compareVersion.versionNumber}`
: "起始版本"}
</div>
<p className="mt-3 font-medium text-[#201c16] text-sm">
{compareVersion
? truncateText(
compareVersion.prompt,
84
)
: "当前版本是这条设计链的起点,还没有可供对照的其他版本。"}
</p>
{compareVersion ? (
<>
<div className="mt-3 flex flex-wrap gap-2">
<Badge className="border-[#ddd7ca] bg-[#f7f3ea] text-[#5f594d]">
{getProviderLabel(
compareVersion.provider
)}
</Badge>
<Badge
className={cn(
"border px-2 py-0 font-medium text-[11px]",
getStatusBadgeClass(
compareVersion.status
)
)}
>
{getStatusLabel(
compareVersion.status
)}
</Badge>
</div>
{compareStructuredDirectives.length >
0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{compareStructuredDirectives
.slice(0, 3)
.map((directive) => (
<Badge
className="border-[#ddd7ca] bg-[#f7f3ea] text-[#5f594d]"
key={`compare-${directive.key}`}
>
{directive.label}
</Badge>
))}
</div>
) : null}
</>
) : null}
</div>
<div className="rounded-2xl border border-[#ddd7ca] bg-white p-4">
<div className="flex items-center gap-2 text-[#7f7768] text-xs uppercase tracking-[0.16em]">
<WandSparkles className="h-3.5 w-3.5" />
当前版本 V
{activeVersion.versionNumber}
</div>
<p className="mt-3 font-medium text-[#201c16] text-sm">
{truncateText(
activeVersion.prompt,
84
)}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Badge className="rounded-md border-[#ddd7ca] bg-[#f7f3ea] text-[#5f594d]">
{getProviderLabel(
activeVersion.provider
)}
</Badge>
<Badge
className={cn(
"border px-2 py-0 font-medium text-[11px]",
getStatusBadgeClass(
activeVersion.status
)
)}
>
{getStatusLabel(
activeVersion.status
)}
</Badge>
{activeVersion.generationMode ===
"iterate" ? (
<Badge className="rounded-md border-[#ddd7ca] bg-[#f7f3ea] text-[#5f594d]">
连续修改
</Badge>
) : null}
</div>
{activeVersionStructuredDirectives.length >
0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{activeVersionStructuredDirectives.map(
(directive) => (
<Badge
className="border-[#ddd7ca] bg-[#f7f3ea] text-[#5f594d]"
key={`active-${directive.key}`}
>
{directive.label}
</Badge>
)
)}
</div>
) : null}
</div>
</div>
{versionCompareNotes.length > 0 ? (
<div className="mt-4 space-y-2">
{versionCompareNotes.map((note) => (
<div
className="flex items-start gap-2 rounded-2xl border border-[#ddd7ca] bg-white px-3 py-2.5"
key={note}
>
<CheckCircle2 className="mt-0.5 h-4 w-4 flex-none text-emerald-600" />
<p className="text-[#5f594d] text-sm leading-6">
{note}
</p>
</div>
))}
</div>
) : null}
</div>
</>
) : null}
</div>
<div className="rounded-[22px] border border-[#ddd7ca] bg-white p-4">
<div className="mb-4 flex items-center gap-2 font-semibold text-[#201c16] text-sm">
<Grid3X3 className="h-4 w-4 text-[#6f695d]" />
查看器设置
</div>
<div className="space-y-4">
<div className="space-y-2">
<div className="text-[#7f7768] text-xs uppercase tracking-[0.16em]">
环境贴图
</div>
<Select
onValueChange={(value) =>
setViewerSettings((current) => ({
...current,
envPreset:
value as Model3DViewerSettings["envPreset"],
}))
}
value={viewerSettings.envPreset}
>
<SelectTrigger className="border-[#ddd7ca] bg-[#fbfaf7] text-[#201c16]">
<SelectValue placeholder="选择环境" />
</SelectTrigger>
<SelectContent>
{VIEWER_ENV_PRESETS.map((preset) => (
<SelectItem
key={preset}
value={preset}
>
{preset}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between rounded-2xl border border-[#ddd7ca] bg-[#f8f5ee] px-3 py-3">
<div>
<div className="text-[#201c16] text-sm">
自动旋转
</div>
<div className="text-[#7f7768] text-xs">
更像产品展示台
</div>
</div>
<Switch
checked={viewerSettings.autoRotate}
onCheckedChange={(checked) =>
setViewerSettings((current) => ({
...current,
autoRotate: checked,
}))
}
/>
</div>
<div className="flex items-center justify-between rounded-2xl border border-[#ddd7ca] bg-[#f8f5ee] px-3 py-3">
<div>
<div className="text-[#201c16] text-sm">
网格地面
</div>
<div className="text-[#7f7768] text-xs">
方便观察尺度
</div>
</div>
<Switch
checked={viewerSettings.showGrid}
onCheckedChange={(checked) =>
setViewerSettings((current) => ({
...current,
showGrid: checked,
}))
}
/>
</div>
<div className="flex items-center justify-between rounded-2xl border border-[#ddd7ca] bg-[#f8f5ee] px-3 py-3">
<div>
<div className="text-[#201c16] text-sm">
线框模式
</div>
<div className="text-[#7f7768] text-xs">
快速看结构面
</div>
</div>
<Switch
checked={viewerSettings.wireframe}
onCheckedChange={(checked) =>
setViewerSettings((current) => ({
...current,
wireframe: checked,
}))
}
/>
</div>
{primaryDownloadEntry ? (
<Button
asChild
className="w-full gap-2 rounded-full border-[#ddd7ca] bg-white text-[#201c16] hover:bg-[#f4f1e8]"
variant="outline"
>
<a
href={primaryDownloadEntry.url}
rel="noreferrer"
target="_blank"
>
<ArrowUpRight className="h-4 w-4" />
新窗口打开结果
</a>
</Button>
) : null}
</div>
</div>
</div>
</div>
</ScrollArea>
) : (
<div className="relative flex h-full min-h-0 flex-col bg-[#fcfbf7]">
<div className="flex items-center justify-between border-[#e6e0d3] border-b bg-white/80 px-5 py-3">
<div className="flex items-center gap-2 text-[#7f7768] text-[11px] uppercase tracking-[0.18em]">
<span>Design Canvas</span>
<span>Ready for first pass</span>
</div>
<div className="flex items-center gap-2">
<button
className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-[#ddd7ca] bg-white text-[#5f594d] transition-all duration-150 ease-out hover:bg-[#f4f1e8] hover:text-[#201c16]"
onClick={() =>
setViewerSettings((current) => ({
...current,
showGrid: !current.showGrid,
}))
}
title="切换网格"
type="button"
>
<Grid3X3 className="h-4 w-4" />
</button>
<button
className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-[#ddd7ca] bg-white text-[#5f594d] transition-all duration-150 ease-out hover:bg-[#f4f1e8] hover:text-[#201c16]"
onClick={() =>
setViewerSettings((current) => ({
...current,
autoRotate: !current.autoRotate,
}))
}
title="切换自动旋转"
type="button"
>
<RotateCcw className="h-4 w-4" />
</button>
</div>
</div>
<div className="relative min-h-0 flex-1 overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.94),rgba(247,243,234,0.88))]" />
<div className="-translate-x-1/2 absolute top-[16%] left-1/2 h-[68%] w-px bg-[#d8d1c5]" />
<div className="-translate-y-1/2 absolute top-1/2 left-[14%] h-px w-[72%] bg-[#d8d1c5]" />
<div className="-translate-x-1/2 absolute top-[22%] left-1/2 h-[56%] w-[42%] rounded-[999px] border border-[#ebe5d9]" />
<div className="absolute right-6 bottom-6 rounded-full border border-[#d9d2c5] bg-white/92 px-4 py-3 shadow-sm">
<div className="grid h-[78px] w-[78px] place-items-center rounded-full border border-[#e6e0d3] bg-[#faf7f1] text-[#7f7768] text-[11px]">
<div className="text-center leading-4">
<div>Top</div>
<div>Front</div>
<div>Right</div>
</div>
</div>
</div>
<div className="absolute inset-x-0 bottom-[18%] flex justify-center px-6">
<div className="rounded-2xl border border-[#e1dace] bg-white/90 px-5 py-3 text-center shadow-sm">
<div className="font-medium text-[#201c16] text-sm">
Start a design to create geometry
</div>
<p className="mt-1 text-[#7f7768] text-xs leading-5">
从右侧直接描述零件、尺寸和约束,画布会在这里保持当前模型。
</p>
</div>
</div>
</div>
</div>
)}
</CardContent>
</Card>
)}
</Panel>
</PanelGroup>
</div>
<div className="border-[#ddd7ca] border-t bg-[#faf8f2] px-3 py-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<AiCadStatusChip
label="Engine"
value={getProviderLabel(generationProvider)}
/>
<AiCadStatusChip
label="Focus"
value={
activeVersion
? `V${activeVersion.versionNumber}`
: "No focus"
}
/>
<AiCadStatusChip
label="State"
tone={
hasPendingTasks
? "pending"
: activeVersion?.status === "success"
? "success"
: "neutral"
}
value={
activeVersion
? getStatusLabel(activeVersion.status)
: "Ready to prompt"
}
/>
<AiCadStatusChip
label="View"
value={
activeResultTab === "preview" ? "3D preview" : "KCL view"
}
/>
<AiCadStatusChip
label="Env"
value={viewerSettings.envPreset}
/>
<AiCadStatusChip
label="Tasks"
tone={hasPendingTasks ? "warning" : "neutral"}
value={
hasPendingTasks
? `${pendingTaskCount} active`
: "Workspace idle"
}
/>
</div>
<div className="flex flex-wrap items-center gap-4 text-[#7f7768] text-xs">
<span>No selection</span>
<span>mm</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}