ai-chatbot/components/ai-cad/ai-cad-workbench.tsx
Your Name ed23cd3f4a
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
tweak: widen ai-cad chat pane
2026-04-07 00:55:05 +08:00

6896 lines
281 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,
CheckCircle2,
ChevronDown,
ChevronRight,
Copy,
Download,
FileCode2,
FolderOpen,
GitBranch,
Grid3X3,
History,
House,
Loader2,
MousePointer2,
Orbit,
Pencil,
Plug,
Plus,
RotateCcw,
Send,
Settings2,
ThumbsDown,
ThumbsUp,
Trash2,
WandSparkles,
XCircle,
} from "lucide-react";
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import {
type ImperativePanelHandle,
Panel,
PanelGroup,
PanelResizeHandle,
} from "react-resizable-panels";
import {
Model3DViewer,
type Model3DViewerSettings,
} from "@/components/canvas/model3d/Model3DViewer";
import {
CodeBlock,
CodeBlockCopyButton,
} from "@/components/elements/code-block";
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/elements/reasoning";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import {
type AiCadModelingCommandPayload,
type AiCadProviderStatus,
type AiCadWorkspaceSummary,
aiCadGenerationService,
} from "@/services/ai-cad";
import {
type Model3DTaskStatus,
model3DGenerationService,
} from "@/services/canvas";
type CadGenerationProvider = "kittycad-cad" | "tripo-3d";
type ChatMessageState =
| "thinking"
| "queued"
| "running"
| "success"
| "failed";
type VersionFeedback = "positive" | "negative";
type ChatMessage = {
id: string;
role: "assistant" | "user";
content: string;
createdAt: number;
state?: ChatMessageState;
versionId?: string;
};
type GenerationVersion = {
id: string;
versionNumber: number;
provider: CadGenerationProvider;
taskId: string;
generationMode?: "create" | "iterate";
parentVersionId?: string | null;
prompt: string;
modelVersion: string;
createdAt: number;
status: Model3DTaskStatus;
progress: number | null;
modelUrl?: string | null;
previewImageUrl?: string | null;
codeUrl?: string | null;
errorMessage?: string | null;
conversationId?: string | null;
downloadUrls?: Record<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 ModelingConnectionState =
| "connected"
| "connecting"
| "disconnected"
| "error";
type ModelingCutType = "chamfer" | "fillet";
type WorkspaceListItem = AiCadWorkspaceSummary;
const MODEL_VERSION_OPTIONS = [
"Turbo-v1.0-20250506",
"v3.1-20260211",
"v3.0-20250812",
"v2.5-20250123",
] as const;
const VIEWER_ENV_PRESETS = [
"studio",
"city",
"sunset",
"dawn",
"night",
"warehouse",
"forest",
"apartment",
"park",
"lobby",
] as const;
const SUGGESTED_PROMPTS = [
"设计一把极简风铝合金折叠椅,强调轻量和可堆叠结构",
"做一个桌面级模块化收纳盒,带磁吸翻盖和可替换分隔槽",
"生成一款人体工学游戏鼠标外壳,右手握持,侧裙有蜂窝纹理",
"创建一个便携咖啡杯杯盖结构,带旋转式开合口和防漏密封圈",
] as const;
const FINAL_TASK_STATUS = new Set<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 parsePositiveNumberInput(value: string, label: string) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`${label} 需要是一个大于 0 的数字`);
}
return parsed;
}
function parseEdgeIndexList(value: string) {
const items = value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (items.length === 0) {
throw new Error("请至少填写一个边索引,例如 0,1,2,3");
}
const parsed = items.map((item) => Number(item));
if (
parsed.some(
(item) => !Number.isInteger(item) || !Number.isFinite(item) || item < 0
)
) {
throw new Error("边索引必须是 0 开始的整数,例如 0,1,2,3");
}
return [...new Set(parsed)];
}
function getModelingResponseData(
payload?: AiCadModelingCommandPayload | null
): Record<string, unknown> | null {
return payload?.responseData &&
typeof payload.responseData === "object" &&
!Array.isArray(payload.responseData)
? (payload.responseData as Record<string, unknown>)
: 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=vertical]:h-px data-[panel-group-direction=horizontal]:w-px data-[panel-group-direction=horizontal]:cursor-col-resize data-[panel-group-direction=vertical]:cursor-row-resize",
className
)}
>
<div className="bg-[#e9e3d7] transition group-hover:bg-[#d7cfbf] data-[panel-group-direction=horizontal]:h-full data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=horizontal]:w-px data-[panel-group-direction=vertical]:w-full group-data-[resize-handle-state=drag]:bg-[#bdb4a3]" />
</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 border border-[#ece7db] bg-white text-[#221f18]",
direction === "horizontal"
? "flex-col items-center justify-center gap-3 px-2 py-4"
: "items-center justify-between gap-4 px-3 py-2"
)}
>
<div
className={cn(
"flex items-center gap-2",
direction === "horizontal" && "flex-col text-center"
)}
>
<Icon className="h-4 w-4 text-[#6a6558]" />
<div className="font-medium text-[#221f18] text-xs">{title}</div>
</div>
<Button
className="gap-2 rounded-md border-[#ddd7ca] bg-white text-[#221f18] transition-all duration-150 ease-out hover:bg-[#f6f2ea]"
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]"
)}
/>
<button
className={cn(
"flex h-10 w-10 items-center justify-center rounded-md border transition-all duration-150 ease-out",
active
? "border-[#b7dcc5] bg-[#eefcf4] text-[#173225]"
: "border-transparent bg-transparent text-[#6f695d] hover:border-[#e4dece] hover:bg-[#faf7ef] hover:text-[#221f18]"
)}
onClick={onClick}
title={label}
type="button"
>
<Icon className="h-4 w-4" />
<span className="sr-only">{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-1.5 border border-[#e6e0d3] bg-white px-1.5 py-1",
className
)}
>
<span className="pl-0.5 text-[#8a8272] text-[10px] uppercase tracking-[0.18em]">
{label}
</span>
<Separator className="h-4 bg-[#e7e1d4]" orientation="vertical" />
<div className="flex flex-wrap items-center gap-1">{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 [kclDraft, setKclDraft] = useState("");
const [kclEditPrompt, setKclEditPrompt] = useState("");
const [isApplyingKclEdit, setIsApplyingKclEdit] = useState(false);
const [modelingSessionId, setModelingSessionId] = useState<string | null>(
null
);
const [modelingConnectionState, setModelingConnectionState] =
useState<ModelingConnectionState>("disconnected");
const [modelingError, setModelingError] = useState<string | null>(null);
const [modelingSnapshotUrl, setModelingSnapshotUrl] = useState<string | null>(
null
);
const [modelingPlaneId, setModelingPlaneId] = useState<string | null>(null);
const [modelingSketchId, setModelingSketchId] = useState<string | null>(null);
const [modelingSolidId, setModelingSolidId] = useState<string | null>(null);
const [modelingSelectedEntityIds, setModelingSelectedEntityIds] = useState<
string[]
>([]);
const [modelingLogLines, setModelingLogLines] = useState<string[]>([]);
const [modelingWidth, setModelingWidth] = useState("120");
const [modelingHeight, setModelingHeight] = useState("60");
const [modelingDepth, setModelingDepth] = useState("25");
const [modelingCutSize, setModelingCutSize] = useState("2");
const [modelingEdgeIndices, setModelingEdgeIndices] = useState("0,1,2,3");
const [modelingCutType, setModelingCutType] =
useState<ModelingCutType>("fillet");
const [isModelingBusy, setIsModelingBusy] = useState(false);
const [isModelingToolsDialogOpen, setIsModelingToolsDialogOpen] =
useState(false);
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 modelingSessionIdRef = 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(() => {
modelingSessionIdRef.current = modelingSessionId;
}, [modelingSessionId]);
useEffect(() => {
writePersistedActiveWorkspaceId(activeWorkspaceId);
}, [activeWorkspaceId]);
useEffect(
() => () => {
if (!modelingSessionIdRef.current) {
return;
}
swallowAsync(
aiCadGenerationService.closeModelingSession(
modelingSessionIdRef.current
)
);
},
[]
);
useEffect(() => {
const hasActiveVersion = Boolean(activeVersionId || versions.length > 0);
if (
panelDirection !== "horizontal" ||
isLoadingWorkspaceList ||
!hasHydratedFromStorage ||
!historyPanelRef.current
) {
return;
}
if (!hasActiveVersion) {
hasAutoCollapsedExplorerRef.current = true;
historyPanelRef.current.collapse();
setIsHistoryPanelCollapsed(true);
} else if (versions.length > 0 && hasAutoCollapsedExplorerRef.current) {
hasAutoCollapsedExplorerRef.current = false;
}
}, [
activeVersionId,
hasHydratedFromStorage,
isLoadingWorkspaceList,
panelDirection,
versions.length,
]);
useEffect(() => {
if (!activeWorkspaceId) {
setHasHydratedFromStorage(true);
setHasHydratedServerWorkspace(true);
return;
}
const fallbackProvider: CadGenerationProvider =
kittycadProviderStatus?.configured === false
? "tripo-3d"
: "kittycad-cad";
const applyWorkspaceSnapshot = (snapshot: PersistedAiCadWorkbenchState) => {
isApplyingExternalWorkspaceRef.current = true;
setPrompt(snapshot.prompt);
setGenerationProvider(snapshot.generationProvider);
setModelVersion(snapshot.modelVersion);
setViewerSettings(snapshot.viewerSettings);
setMessages(snapshot.messages);
setVersions(snapshot.versions);
setActiveVersionId(snapshot.activeVersionId);
};
const persistedWorkbench = readPersistedWorkbench(activeWorkspaceId);
localSavedAtRef.current[activeWorkspaceId] =
persistedWorkbench?.savedAt || 0;
hasLocalEditsBeforeServerHydrationRef.current[activeWorkspaceId] = false;
setHasHydratedFromStorage(false);
setHasHydratedServerWorkspace(false);
setWorkspaceSyncState("loading");
setWorkspaceSyncError(null);
setCodeActionError(null);
if (persistedWorkbench) {
applyWorkspaceSnapshot(persistedWorkbench);
} else {
applyWorkspaceSnapshot(createDefaultWorkbenchState(fallbackProvider));
}
setHasHydratedFromStorage(true);
let cancelled = false;
const loadServerWorkspace = async () => {
const result =
await aiCadGenerationService.getWorkspace(activeWorkspaceId);
if (cancelled) {
return;
}
if (result.success && result.data) {
const serverSnapshot = normalizePersistedWorkbenchState(result.data);
const serverUpdatedAt = result.data.updatedAt
? new Date(result.data.updatedAt).getTime()
: 0;
if (serverSnapshot) {
lastServerSavedSnapshotRef.current[activeWorkspaceId] =
serializeWorkspaceSyncState({
name: result.data?.name,
state: serverSnapshot,
});
setWorkspaces((current) =>
current.map((workspace) =>
workspace.id === activeWorkspaceId
? buildWorkspaceSummaryFromWorkbench(
{
...workspace,
name: result.data?.name || workspace.name,
updatedAt: result.data?.updatedAt || workspace.updatedAt,
},
{
prompt: serverSnapshot.prompt,
generationProvider: serverSnapshot.generationProvider,
versions: serverSnapshot.versions,
}
)
: workspace
)
);
if (
!hasLocalEditsBeforeServerHydrationRef.current[activeWorkspaceId] &&
(!persistedWorkbench ||
serverUpdatedAt >
(localSavedAtRef.current[activeWorkspaceId] || 0))
) {
localSavedAtRef.current[activeWorkspaceId] = serverUpdatedAt;
applyWorkspaceSnapshot({
...serverSnapshot,
savedAt: serverUpdatedAt,
});
}
}
setWorkspaceSyncState("synced");
setWorkspaceSyncError(null);
} else if (result.success) {
setWorkspaceSyncState("synced");
setWorkspaceSyncError(null);
} else {
setWorkspaceSyncState("error");
setWorkspaceSyncError(result.error || "读取云端工作台失败");
}
setHasHydratedServerWorkspace(true);
};
swallowAsync(loadServerWorkspace());
return () => {
cancelled = true;
};
}, [activeWorkspaceId, kittycadProviderStatus?.configured]);
const activeVersion =
versions.find((version) => version.id === activeVersionId) ??
versions[0] ??
null;
const activeParentVersion = findVersionById(
versions,
activeVersion?.parentVersionId
);
const activePreviewVersion = getPreviewVersion(activeVersion, versions);
const previewSourceVersion =
activePreviewVersion &&
activeVersion &&
activePreviewVersion.id !== activeVersion.id
? activePreviewVersion
: null;
const iterationBaseVersion =
generationProvider === "kittycad-cad" &&
canIterateKittyCadVersion(activeVersion)
? activeVersion
: null;
const activeDownloadEntries = buildDownloadEntries(activeVersion);
const primaryDownloadEntry = activeDownloadEntries[0] ?? null;
const activeCodeEntry =
activeDownloadEntries.find((entry) => entry.extension === "kcl") ?? null;
const activeCodeUrl = activeVersion?.codeUrl || activeCodeEntry?.url || null;
const activeCodeContent = activeCodeUrl
? cachedCodeByUrl[activeCodeUrl]
: null;
const isLoadingActiveCodePreview = activeCodeUrl
? Boolean(loadingCodePreviewByUrl[activeCodeUrl])
: false;
const activeCodePreviewError = activeCodeUrl
? codePreviewErrorByUrl[activeCodeUrl] || null
: null;
const canEditActiveKcl = Boolean(
activeVersion &&
activeVersion.provider === "kittycad-cad" &&
activeCodeUrl &&
activeCodeContent
);
const activeExecutionSummary = activeVersion
? buildVersionExecutionSummary(activeVersion, {
parentVersion: activeParentVersion,
downloadEntries: activeDownloadEntries,
previewSourceVersion,
})
: null;
const activeVersionStructuredDirectives = activeVersion
? extractStructuredPromptDirectives(activeVersion.prompt)
: [];
const versionCompareNotes = buildVersionCompareNotes({
activeVersion,
parentVersion: activeParentVersion,
previewSourceVersion,
});
const versionChildrenMap = buildVersionChildrenMap(versions);
const activeVersionLineage = buildVersionLineage(activeVersion, versions);
const activeVersionLineageIds = new Set(
activeVersionLineage.map((version) => version.id)
);
const compareCandidateVersions = buildCompareCandidateVersions(
activeVersion,
versions
);
const compareVersion =
compareCandidateVersions.find(
(version) => version.id === compareVersionId
) ?? null;
const comparePreviewVersion = getPreviewVersion(compareVersion, versions);
const comparePreviewSourceVersion =
comparePreviewVersion &&
compareVersion &&
comparePreviewVersion.id !== compareVersion.id
? comparePreviewVersion
: null;
const compareDownloadEntries = buildDownloadEntries(compareVersion);
const compareCodeEntry =
compareDownloadEntries.find((entry) => entry.extension === "kcl") ?? null;
const compareCodeUrl =
compareVersion?.codeUrl || compareCodeEntry?.url || null;
const compareCodeContent = compareCodeUrl
? cachedCodeByUrl[compareCodeUrl]
: null;
const isLoadingCompareCodePreview = compareCodeUrl
? Boolean(loadingCodePreviewByUrl[compareCodeUrl])
: false;
const compareCodePreviewError = compareCodeUrl
? codePreviewErrorByUrl[compareCodeUrl] || null
: null;
const compareStructuredDirectives = compareVersion
? extractStructuredPromptDirectives(compareVersion.prompt)
: [];
const versionDiffHighlights = buildVersionDiffHighlights({
activeVersion,
compareVersion,
comparePreviewSourceVersion,
previewSourceVersion,
});
const rootVersionCount = versions.filter(
(version) => !version.parentVersionId
).length;
const branchPointCount = versions.filter(
(version) => (versionChildrenMap.get(version.id)?.length ?? 0) > 1
).length;
const derivedVariantCount = versions.filter((version) =>
Boolean(version.parentVersionId)
).length;
const activeCodeLineCount = activeCodeContent
? activeCodeContent.split(NEWLINE_SPLIT_RE).length
: 0;
const compareCodeLineCount = compareCodeContent
? compareCodeContent.split(NEWLINE_SPLIT_RE).length
: 0;
const codeDiffResult = useMemo(
() => buildCodeDiffRows(compareCodeContent || "", activeCodeContent || ""),
[activeCodeContent, compareCodeContent]
);
const visibleCodeDiffRows = useMemo(
() =>
showOnlyChangedCodeRows
? codeDiffResult.rows.filter((row) => row.kind !== "equal")
: codeDiffResult.rows,
[codeDiffResult.rows, showOnlyChangedCodeRows]
);
const codeDiffInsights = useMemo(
() => buildCodeDiffInsights(codeDiffResult.rows),
[codeDiffResult.rows]
);
const semanticCodeDiffInsights = useMemo(
() => buildSemanticDiffInsights(codeDiffResult.rows),
[codeDiffResult.rows]
);
const activeVersionSemanticInsights = useMemo(
() => buildVersionSemanticInsights(activeVersion),
[activeVersion]
);
const versionSemanticInsightMap = useMemo(
() =>
new Map(
versions.map((version) => [
version.id,
buildVersionSemanticInsights(version),
])
),
[versions]
);
const semanticFocusInsights =
semanticCodeDiffInsights.length > 0
? semanticCodeDiffInsights
: activeVersionSemanticInsights;
const _followUpSuggestions = buildFollowUpSuggestions({
activeVersion,
activeParentVersion,
previewSourceVersion,
semanticFocus: semanticFocusInsights,
});
const codeContextKey = `${activeVersion?.id || "none"}:${compareVersion?.id || "none"}`;
const hasPendingTasks = versions.some(
(version) => !FINAL_TASK_STATUS.has(version.status)
);
const pendingTaskCount = versions.filter(
(version) => !FINAL_TASK_STATUS.has(version.status)
).length;
const completedCount = versions.filter(
(version) => version.status === "success"
).length;
const activeWorkspace =
workspaces.find((workspace) => workspace.id === activeWorkspaceId) ?? null;
const activeWorkspaceName = activeWorkspace?.name || "AI-CAD 工作台";
const normalizedWorkspaceName = normalizeWorkspaceTitle(activeWorkspaceName);
const shouldForceTreeCollapsed =
panelDirection === "horizontal" && !activeVersion;
const isWorkspaceActionDisabled =
isSubmitting || isCreatingWorkspace || isDeletingWorkspace;
const canDeleteActiveWorkspace =
Boolean(activeWorkspaceId) &&
!hasPendingTasks &&
!isWorkspaceActionDisabled &&
!isLoadingWorkspaceList;
const isKittycadConfigured = kittycadProviderStatus?.configured !== false;
const isOfficialModelingAvailable =
generationProvider === "kittycad-cad" && isKittycadConfigured;
const isSubmitDisabled =
!activeWorkspaceId ||
isLoadingWorkspaceList ||
!prompt.trim() ||
isSubmitting ||
(generationProvider === "kittycad-cad" && !isKittycadConfigured);
const modelingPreviewImageUrl =
!activePreviewVersion?.modelUrl && modelingSnapshotUrl
? modelingSnapshotUrl
: activePreviewVersion?.previewImageUrl || null;
const serializedWorkbenchSnapshot = serializeWorkbenchState({
prompt,
generationProvider,
modelVersion,
viewerSettings,
messages,
versions,
activeVersionId,
});
useEffect(() => {
if (!activeVersionId) {
return;
}
setExpandedVersionDetails((current) =>
current[activeVersionId]
? current
: {
...current,
[activeVersionId]: true,
}
);
}, [activeVersionId]);
useEffect(() => {
if (!activeVersion) {
setCompareVersionId(null);
return;
}
const compareCandidateIds = new Set(
compareCandidateVersions.map((version) => version.id)
);
setCompareVersionId((current) => {
if (current && compareCandidateIds.has(current)) {
return current;
}
return compareCandidateVersions[0]?.id ?? null;
});
}, [activeVersion, compareCandidateVersions]);
useEffect(() => {
if (!compareVersion) {
setShowOnlyChangedCodeRows(false);
}
}, [compareVersion]);
useEffect(() => {
if (!canEditActiveKcl || !activeCodeContent) {
setKclDraft("");
setKclEditPrompt("");
return;
}
setKclDraft(activeCodeContent);
setKclEditPrompt("");
}, [activeCodeContent, canEditActiveKcl]);
useEffect(() => {
if (!hasHydratedFromStorage || !activeWorkspaceId) {
return;
}
setModelingError(null);
setModelingSnapshotUrl(null);
setModelingPlaneId(null);
setModelingSketchId(null);
setModelingSolidId(null);
setModelingSelectedEntityIds([]);
setModelingLogLines([]);
if (!modelingSessionIdRef.current) {
setModelingConnectionState("disconnected");
return;
}
const previousSessionId = modelingSessionIdRef.current;
modelingSessionIdRef.current = null;
setModelingSessionId(null);
setModelingConnectionState("disconnected");
swallowAsync(
aiCadGenerationService.closeModelingSession(previousSessionId)
);
}, [activeWorkspaceId, hasHydratedFromStorage]);
useEffect(() => {
const codeUrlsToFetch = Array.from(
new Set(
[activeCodeUrl, compareCodeUrl].filter(
(url): url is string =>
typeof url === "string" && url.trim().length > 0
)
)
).filter((url) => !cachedCodeByUrl[url] && !loadingCodePreviewByUrl[url]);
if (codeUrlsToFetch.length === 0) {
return;
}
let cancelled = false;
setLoadingCodePreviewByUrl((current) => ({
...current,
...Object.fromEntries(codeUrlsToFetch.map((url) => [url, true])),
}));
setCodePreviewErrorByUrl((current) => ({
...current,
...Object.fromEntries(codeUrlsToFetch.map((url) => [url, null])),
}));
swallowAsync(
(async () => {
await Promise.all(
codeUrlsToFetch.map(async (url) => {
try {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new Error(`加载失败(HTTP ${response.status})`);
}
const code = await response.text();
if (cancelled) {
return;
}
setCachedCodeByUrl((current) => ({
...current,
[url]: code,
}));
setCodePreviewErrorByUrl((current) => ({
...current,
[url]: null,
}));
} catch (error) {
if (cancelled) {
return;
}
setCodePreviewErrorByUrl((current) => ({
...current,
[url]:
error instanceof Error ? error.message : "KCL 代码加载失败",
}));
} finally {
if (!cancelled) {
setLoadingCodePreviewByUrl((current) => ({
...current,
[url]: false,
}));
}
}
})
);
})()
);
return () => {
cancelled = true;
};
}, [activeCodeUrl, cachedCodeByUrl, compareCodeUrl, loadingCodePreviewByUrl]);
useEffect(() => {
if (!hasHydratedFromStorage) {
return;
}
if (!serializedWorkbenchSnapshot) {
return;
}
if (isApplyingExternalWorkspaceRef.current) {
isApplyingExternalWorkspaceRef.current = false;
return;
}
if (activeWorkspaceId && !hasHydratedServerWorkspace) {
hasLocalEditsBeforeServerHydrationRef.current[activeWorkspaceId] = true;
}
}, [
activeWorkspaceId,
hasHydratedFromStorage,
hasHydratedServerWorkspace,
serializedWorkbenchSnapshot,
]);
useEffect(() => {
if (
!activeWorkspaceId ||
!hasHydratedFromStorage ||
typeof window === "undefined"
) {
return;
}
if (
!hasHydratedServerWorkspace &&
!hasLocalEditsBeforeServerHydrationRef.current[activeWorkspaceId]
) {
return;
}
const savedAt = Date.now();
const workbenchPayload = JSON.parse(
serializedWorkbenchSnapshot
) as PersistedAiCadWorkbenchState;
const snapshot = {
...workbenchPayload,
savedAt,
};
const serializedWorkspaceSyncSnapshot = serializeWorkspaceSyncState({
name: activeWorkspace?.name,
state: workbenchPayload,
});
window.localStorage.setItem(
getWorkbenchStorageKey(activeWorkspaceId),
JSON.stringify(snapshot)
);
localSavedAtRef.current[activeWorkspaceId] = savedAt;
if (!hasHydratedServerWorkspace) {
return;
}
if (
serializedWorkspaceSyncSnapshot ===
lastServerSavedSnapshotRef.current[activeWorkspaceId]
) {
setWorkspaceSyncState((current) =>
current === "error" ? current : "synced"
);
return;
}
const workspaceId = activeWorkspaceId;
setWorkspaceSyncState("syncing");
const timer = window.setTimeout(() => {
swallowAsync(
(async () => {
const result = await aiCadGenerationService.saveWorkspace(
workspaceId,
{
name: activeWorkspace?.name,
prompt: workbenchPayload.prompt,
generationProvider: workbenchPayload.generationProvider,
modelVersion: workbenchPayload.modelVersion,
viewerSettings: workbenchPayload.viewerSettings as Record<
string,
unknown
>,
messages: workbenchPayload.messages as Record<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
)
);
}
function appendModelingLog(message: string) {
const timestamp = formatClockTime(Date.now());
setModelingLogLines((current) =>
[`${timestamp} · ${message}`, ...current].slice(0, 8)
);
}
async function ensureModelingSession() {
if (modelingSessionId) {
return modelingSessionId;
}
setModelingConnectionState("connecting");
setModelingError(null);
const result = await aiCadGenerationService.createModelingSession();
if (!result.success || !result.data?.sessionId) {
setModelingConnectionState("error");
const message = result.error || "创建官方建模会话失败";
setModelingError(message);
throw new Error(message);
}
setModelingSessionId(result.data.sessionId);
setModelingConnectionState("connected");
appendModelingLog("已连接官方 modeling 会话");
return result.data.sessionId;
}
async function handleDisconnectModelingSession() {
if (!modelingSessionId) {
return;
}
const closingSessionId = modelingSessionId;
setModelingConnectionState("disconnected");
setModelingSessionId(null);
setModelingPlaneId(null);
setModelingSketchId(null);
setModelingSolidId(null);
setModelingSelectedEntityIds([]);
setModelingSnapshotUrl(null);
const result =
await aiCadGenerationService.closeModelingSession(closingSessionId);
if (!result.success) {
setModelingConnectionState("error");
setModelingError(result.error || "关闭官方建模会话失败");
return;
}
appendModelingLog("已关闭官方 modeling 会话");
}
async function sendOfficialModelingCommand(params: {
captureSnapshot?: boolean;
cmd: Record<string, unknown>;
cmdId?: string;
}) {
const sessionId = await ensureModelingSession();
const result = await aiCadGenerationService.sendModelingCommand({
captureSnapshot: params.captureSnapshot,
cmd: params.cmd,
cmdId: params.cmdId,
sessionId,
snapshotFormat: "png",
});
if (!result.success || !result.data) {
setModelingConnectionState("error");
const message = result.error || "官方建模命令执行失败";
setModelingError(message);
throw new Error(message);
}
setModelingConnectionState("connected");
if (result.data.snapshotUrl) {
setModelingSnapshotUrl(result.data.snapshotUrl);
}
if (result.data.snapshotError) {
setModelingError(result.data.snapshotError);
}
return result.data;
}
async function runModelingAction(label: string, task: () => Promise<void>) {
if (isModelingBusy || !isOfficialModelingAvailable) {
return;
}
setIsModelingBusy(true);
setModelingError(null);
try {
await task();
appendModelingLog(label);
} catch (error) {
const message =
error instanceof Error ? error.message : "官方建模命令执行失败";
setModelingConnectionState((current) =>
current === "connecting" ? "error" : current
);
setModelingError(message);
appendModelingLog(`${label}失败:${message}`);
} finally {
setIsModelingBusy(false);
}
}
async function handleCreateRectangleSketch() {
await runModelingAction("已创建矩形草图", async () => {
const width = parsePositiveNumberInput(modelingWidth, "草图宽度");
const height = parsePositiveNumberInput(modelingHeight, "草图高度");
const planeId = createId();
const sketchId = createId();
await sendOfficialModelingCommand({
cmd: {
clobber: true,
origin: { x: 0, y: 0, z: 0 },
size: Math.max(width, height) * 2,
type: "make_plane",
x_axis: { x: 1, y: 0, z: 0 },
y_axis: { x: 0, y: 1, z: 0 },
},
cmdId: planeId,
});
await sendOfficialModelingCommand({
cmd: {
adjust_camera: true,
animated: false,
entity_id: planeId,
ortho: true,
type: "enable_sketch_mode",
},
});
await sendOfficialModelingCommand({
cmd: {
tool: "sketch_line",
type: "set_tool",
},
});
await sendOfficialModelingCommand({
cmd: { type: "start_path" },
cmdId: sketchId,
});
await sendOfficialModelingCommand({
cmd: {
path: sketchId,
to: { x: -width / 2, y: -height / 2, z: 0 },
type: "move_path_pen",
},
});
const corners = [
{ x: width / 2, y: -height / 2, z: 0 },
{ x: width / 2, y: height / 2, z: 0 },
{ x: -width / 2, y: height / 2, z: 0 },
];
for (const corner of corners) {
await sendOfficialModelingCommand({
cmd: {
path: sketchId,
segment: {
end: corner,
relative: false,
type: "line",
},
type: "extend_path",
},
});
}
await sendOfficialModelingCommand({
cmd: {
path_id: sketchId,
type: "close_path",
},
});
await sendOfficialModelingCommand({
captureSnapshot: true,
cmd: {
padding: 0.12,
type: "view_isometric",
},
});
setModelingPlaneId(planeId);
setModelingSketchId(sketchId);
setModelingSolidId(null);
setModelingSelectedEntityIds([]);
});
}
async function handleExtrudeSketch() {
await runModelingAction("已拉伸当前草图", async () => {
if (!modelingSketchId) {
throw new Error("请先通过官方草图工具创建一个矩形草图");
}
const distance = parsePositiveNumberInput(modelingDepth, "拉伸深度");
const solidId = createId();
await sendOfficialModelingCommand({
cmd: {
distance,
target: modelingSketchId,
type: "extrude",
},
cmdId: solidId,
});
await sendOfficialModelingCommand({
cmd: {
entities: [solidId],
type: "select_replace",
},
});
await sendOfficialModelingCommand({
captureSnapshot: true,
cmd: {
padding: 0.08,
type: "view_isometric",
},
});
setModelingSolidId(solidId);
setModelingSelectedEntityIds([solidId]);
});
}
async function handleSelectCurrentBody() {
await runModelingAction("已同步当前实体选择", async () => {
if (!modelingSolidId) {
throw new Error("当前还没有可选择的实体,请先拉伸出一个实体");
}
await sendOfficialModelingCommand({
cmd: {
entities: [modelingSolidId],
type: "select_replace",
},
});
const result = await sendOfficialModelingCommand({
cmd: {
type: "select_get",
},
});
const responseData = getModelingResponseData(result);
const entityIds = Array.isArray(responseData?.entity_ids)
? responseData.entity_ids
.map((item) => String(item || "").trim())
.filter(Boolean)
: [];
setModelingSelectedEntityIds(entityIds);
});
}
async function handleCutSelectedEdges() {
await runModelingAction(
modelingCutType === "fillet" ? "已执行圆角切边" : "已执行倒角切边",
async () => {
if (!modelingSolidId) {
throw new Error("请先拉伸出一个实体,再执行圆角或倒角");
}
const size = parsePositiveNumberInput(
modelingCutSize,
modelingCutType === "fillet" ? "圆角半径" : "倒角距离"
);
const edgeIndices = parseEdgeIndexList(modelingEdgeIndices);
const edgeIds: string[] = [];
for (const edgeIndex of edgeIndices) {
const response = await sendOfficialModelingCommand({
cmd: {
edge_index: edgeIndex,
object_id: modelingSolidId,
type: "solid3d_get_edge_uuid",
},
});
const responseData = getModelingResponseData(response);
const edgeId = String(responseData?.edge_id || "").trim();
if (!edgeId) {
throw new Error(`读取第 ${edgeIndex} 条边的 UUID 失败`);
}
edgeIds.push(edgeId);
}
await sendOfficialModelingCommand({
captureSnapshot: true,
cmd: {
cut_type:
modelingCutType === "fillet"
? { fillet: { radius: size } }
: { chamfer: { distance: size, swap: false } },
edge_ids: edgeIds,
extra_face_ids:
edgeIds.length > 1 ? edgeIds.slice(1).map(() => createId()) : [],
object_id: modelingSolidId,
strategy: "automatic",
tolerance: 0.01,
type: "solid3d_cut_edges",
},
});
}
);
}
async function handleApplyKclEdit() {
if (
!activeWorkspaceId ||
!activeVersion ||
activeVersion.provider !== "kittycad-cad" ||
!canEditActiveKcl ||
isApplyingKclEdit ||
isSubmitting
) {
return;
}
const normalizedSourceCode = kclDraft.trim();
if (!normalizedSourceCode) {
setCodeActionError("KCL 代码不能为空");
return;
}
const editPrompt =
kclEditPrompt.trim() ||
`应用手动编辑后的 KCL,并基于 V${activeVersion.versionNumber} 重新求解。`;
const nextVersionNumber = versions.length + 1;
const userMessageId = createId();
const assistantMessageId = createId();
setCodeActionError(null);
setIsApplyingKclEdit(true);
setMessages((current) => [
...current,
{
content: editPrompt,
createdAt: Date.now(),
id: userMessageId,
role: "user",
},
{
content: `收到,这轮会直接使用你编辑后的 KCL 代码提交官方 iteration,并把结果记为 V${nextVersionNumber}。`,
createdAt: Date.now(),
id: assistantMessageId,
role: "assistant",
state: "thinking",
},
]);
try {
const result = await aiCadGenerationService.createTask({
iterationSourceTaskId: activeVersion.taskId,
outputFormat: "glb",
prompt: editPrompt,
sourceCode: kclDraft,
sourceRanges: [],
});
if (!result.success || !result.data?.taskId) {
throw new Error(result.error || "提交 KCL iteration 失败");
}
const versionId = createId();
const createdConversationId =
"conversationId" in result.data
? readNullableText(result.data.conversationId)
: null;
const createdModelVersion =
"modelVersion" in result.data
? readNullableText(result.data.modelVersion)
: null;
const nextVersion: GenerationVersion = {
assistantMessageId,
conversationId: createdConversationId,
createdAt: Date.now(),
generationMode: "iterate",
id: versionId,
modelVersion: createdModelVersion || "KCL Iteration",
parentVersionId: activeVersion.id,
progress: null,
prompt: editPrompt,
provider: "kittycad-cad",
status: result.data.status || "queued",
taskId: result.data.taskId,
versionNumber: nextVersionNumber,
};
setVersions((current) => [nextVersion, ...current]);
setActiveVersionId(versionId);
setMessages((current) =>
current.map((message) =>
message.id === assistantMessageId
? {
...message,
content: buildAssistantMessage(
nextVersionNumber,
nextVersion.status,
null,
null
),
state:
nextVersion.status === "running"
? "running"
: nextVersion.status === "success"
? "success"
: "queued",
versionId,
}
: message
)
);
setKclEditPrompt("");
} catch (error) {
const message =
error instanceof Error ? error.message : "提交 KCL iteration 失败";
setCodeActionError(message);
setMessages((current) =>
current.map((messageItem) =>
messageItem.id === assistantMessageId
? {
...messageItem,
content: `KCL 重算失败了:${message}。你可以检查语法后重试,或者补一句更明确的修改意图。`,
state: "failed",
}
: messageItem
)
);
} finally {
setIsApplyingKclEdit(false);
}
}
async function handleSubmit() {
const trimmedPrompt = prompt.trim();
if (
!activeWorkspaceId ||
!trimmedPrompt ||
isSubmitting ||
(generationProvider === "kittycad-cad" && !isKittycadConfigured)
) {
return;
}
const nextVersionNumber = versions.length + 1;
const userMessageId = createId();
const assistantMessageId = createId();
const nextWorkspaceTitle =
versions.length === 0 && isDefaultWorkspaceTitle(activeWorkspaceName)
? buildWorkspaceTitleFromPrompt(trimmedPrompt)
: null;
if (nextWorkspaceTitle) {
updateActiveWorkspaceName(nextWorkspaceTitle);
}
setPrompt("");
setIsSubmitting(true);
setMessages((current) => [
...current,
{
id: userMessageId,
role: "user",
createdAt: Date.now(),
content: trimmedPrompt,
},
{
id: assistantMessageId,
role: "assistant",
createdAt: Date.now(),
state: "thinking",
content: iterationBaseVersion
? `收到,这一轮我会基于 V${iterationBaseVersion.versionNumber} 继续修改,先提交连续迭代任务。`
: `收到,这一轮我会把它推进成 V${nextVersionNumber},先开始提交生成任务。`,
},
]);
try {
const result =
generationProvider === "kittycad-cad"
? await aiCadGenerationService.createTask({
prompt: trimmedPrompt,
outputFormat: "glb",
iterationSourceTaskId: iterationBaseVersion?.taskId,
})
: await model3DGenerationService.createTask({
taskType: "text_to_model",
prompt: trimmedPrompt,
modelVersion,
});
if (!result.success || !result.data?.taskId) {
throw new Error(result.error || "创建 3D 任务失败");
}
const versionId = createId();
const createdGenerationMode =
generationProvider === "kittycad-cad" &&
"generationMode" in result.data &&
result.data.generationMode === "iterate"
? "iterate"
: "create";
const createdModelVersion =
generationProvider === "kittycad-cad"
? readNullableText(
"modelVersion" in result.data ? result.data.modelVersion : null
) ||
(createdGenerationMode === "iterate"
? "KCL Iteration"
: "GLB / STEP / KCL")
: modelVersion;
const createdConversationId = readNullableText(
"conversationId" in result.data ? result.data.conversationId : null
);
const nextVersion: GenerationVersion = {
id: versionId,
versionNumber: nextVersionNumber,
provider: generationProvider,
taskId: result.data.taskId,
generationMode: createdGenerationMode,
parentVersionId:
createdGenerationMode === "iterate"
? iterationBaseVersion?.id || null
: null,
prompt: trimmedPrompt,
modelVersion: createdModelVersion,
createdAt: Date.now(),
status: result.data.status || "queued",
progress: null,
conversationId: createdConversationId,
assistantMessageId,
};
setVersions((current) => [nextVersion, ...current]);
setActiveVersionId(versionId);
setMessages((current) =>
current.map((message) =>
message.id === assistantMessageId
? {
...message,
versionId,
state:
nextVersion.status === "running"
? "running"
: nextVersion.status === "success"
? "success"
: "queued",
content: buildAssistantMessage(
nextVersionNumber,
nextVersion.status,
null,
null
),
}
: message
)
);
} catch (error) {
setMessages((current) =>
current.map((message) =>
message.id === assistantMessageId
? {
...message,
state: "failed",
content: `这轮提交失败了:${error instanceof Error ? error.message : "未知错误"}。你可以稍后重试,或者换个更明确的结构描述。`,
}
: message
)
);
} finally {
setIsSubmitting(false);
}
}
async function handleCreateWorkspace() {
if (isWorkspaceActionDisabled) {
return;
}
setIsCreatingWorkspace(true);
setWorkspaceOperationError(null);
try {
const result = await aiCadGenerationService.createWorkspace();
if (!result.success || !result.data) {
throw new Error(result.error || "创建工作台失败");
}
const createdWorkspace = result.data;
setWorkspaces((current) =>
sortWorkspaceList([createdWorkspace, ...current])
);
setActiveWorkspaceId(createdWorkspace.id);
} catch (error) {
setWorkspaceOperationError(
error instanceof Error ? error.message : "创建工作台失败"
);
} finally {
setIsCreatingWorkspace(false);
}
}
async function handleDeleteWorkspace() {
if (!activeWorkspaceId || !canDeleteActiveWorkspace) {
return;
}
const confirmed = window.confirm(
`确认删除“${activeWorkspaceName}”吗?本地与云端的工作台快照都会清除,但不会撤销已经生成过的外部任务。`
);
if (!confirmed) {
return;
}
setIsDeletingWorkspace(true);
setWorkspaceOperationError(null);
try {
const result =
await aiCadGenerationService.deleteWorkspace(activeWorkspaceId);
if (!result.success) {
throw new Error(result.error || "删除工作台失败");
}
removePersistedWorkbench(activeWorkspaceId);
delete localSavedAtRef.current[activeWorkspaceId];
delete lastServerSavedSnapshotRef.current[activeWorkspaceId];
delete hasLocalEditsBeforeServerHydrationRef.current[activeWorkspaceId];
const remainingWorkspaces = workspaces.filter(
(workspace) => workspace.id !== activeWorkspaceId
);
if (remainingWorkspaces.length > 0) {
const nextWorkspaces = sortWorkspaceList(remainingWorkspaces);
setWorkspaces(nextWorkspaces);
setActiveWorkspaceId(nextWorkspaces[0]?.id || null);
return;
}
const createdResult = await aiCadGenerationService.createWorkspace();
if (!createdResult.success || !createdResult.data) {
setWorkspaces([]);
setActiveWorkspaceId(null);
throw new Error(createdResult.error || "重建默认工作台失败");
}
setWorkspaces([createdResult.data]);
setActiveWorkspaceId(createdResult.data.id);
} catch (error) {
setWorkspaceOperationError(
error instanceof Error ? error.message : "删除工作台失败"
);
} finally {
setIsDeletingWorkspace(false);
}
}
function handleResetWorkbench() {
const nextGenerationProvider: CadGenerationProvider =
kittycadProviderStatus?.configured === false
? "tripo-3d"
: "kittycad-cad";
if (activeWorkspaceId) {
removePersistedWorkbench(activeWorkspaceId);
}
setPrompt("");
setGenerationProvider(nextGenerationProvider);
setModelVersion(MODEL_VERSION_OPTIONS[0]);
setViewerSettings(DEFAULT_VIEWER_SETTINGS);
setMessages(createWelcomeMessages());
setVersions([]);
setActiveVersionId(null);
setKclDraft("");
setKclEditPrompt("");
setModelingError(null);
setModelingSnapshotUrl(null);
setModelingPlaneId(null);
setModelingSketchId(null);
setModelingSolidId(null);
setModelingSelectedEntityIds([]);
setModelingLogLines([]);
if (modelingSessionIdRef.current) {
const previousSessionId = modelingSessionIdRef.current;
modelingSessionIdRef.current = null;
setModelingSessionId(null);
setModelingConnectionState("disconnected");
swallowAsync(
aiCadGenerationService.closeModelingSession(previousSessionId)
);
}
}
return (
<div className="flex h-svh flex-col overflow-hidden bg-[#f8f7f3] text-[#201c16]">
<div className="min-h-0 flex-1 overflow-hidden">
<div className="flex h-full min-h-0">
<div className="flex w-14 shrink-0 flex-col items-center justify-between border-[#e6e0d3] border-r bg-white px-1.5 py-3">
<div className="flex flex-col items-center gap-2">
<a
className="flex h-10 w-10 items-center justify-center rounded-md border border-[#e4dece] bg-white text-[#6f695d] transition-all duration-150 ease-out hover:bg-[#faf7ef] hover:text-[#221f18]"
href="/"
title="返回首页"
>
<House className="h-4 w-4" />
<span className="sr-only">返回首页</span>
</a>
<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 h-10 w-10 items-center justify-center rounded-md border border-[#e4dece] bg-white text-[#6f695d] transition-all duration-150 ease-out hover:bg-[#faf7ef] hover:text-[#221f18]"
href={primaryDownloadEntry.url}
rel="noreferrer"
target="_blank"
title="导出当前版本"
>
<Download className="h-4 w-4" />
<span className="sr-only">Export</span>
</a>
) : (
<button
className="flex h-10 w-10 cursor-not-allowed items-center justify-center rounded-md border border-[#ece7dc] bg-white text-[#b8b0a2]"
disabled
title="暂无可导出文件"
type="button"
>
<Download className="h-4 w-4" />
<span className="sr-only">Export</span>
</button>
)}
<button
className="flex h-10 w-10 items-center justify-center rounded-md border border-[#e4dece] bg-white text-[#6f695d] transition-all duration-150 ease-out hover:bg-[#faf7ef] hover:text-[#221f18]"
onClick={handleResetWorkbench}
title="重置当前工作台"
type="button"
>
<RotateCcw className="h-4 w-4" />
<span className="sr-only">Reset</span>
</button>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="border-[#e6e0d3] border-b bg-white px-3 py-1">
<div className="flex items-center justify-between gap-2 overflow-x-auto">
<div className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
<Badge className="rounded-full border-[#e2dccf] bg-white px-2 py-0.5 text-[#5f594d] text-[10px]">
{activeVersion
? `Focus V${activeVersion.versionNumber}`
: "Ready for first pass"}
</Badge>
{generationProvider === "kittycad-cad" ? (
<AiCadToolbarGroup className="shrink-0" label="Tools">
<button
className="inline-flex h-7 items-center gap-1.5 rounded-[7px] border border-[#ddd7ca] bg-white px-2.5 text-[#5f594d] text-[12px] transition-all duration-150 ease-out hover:bg-[#faf7ef] disabled:cursor-not-allowed disabled:opacity-50"
disabled={
isModelingBusy || !isOfficialModelingAvailable
}
onClick={() =>
swallowAsync(handleCreateRectangleSketch())
}
type="button"
>
<Pencil className="h-3.5 w-3.5" />
Start Sketch
</button>
<button
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-[7px] border transition-all duration-150 ease-out disabled:cursor-not-allowed disabled:opacity-50",
modelingConnectionState === "connected"
? "border-[#bfd9c9] bg-[#ecfbf2] text-[#173225]"
: "border-[#ddd7ca] bg-white text-[#5f594d] hover:bg-[#faf7ef]"
)}
disabled={
isModelingBusy || !isOfficialModelingAvailable
}
onClick={() =>
swallowAsync(
modelingSessionId
? handleDisconnectModelingSession()
: ensureModelingSession()
)
}
title={
modelingSessionId
? "断开官方 modeling 会话"
: "连接官方 modeling 会话"
}
type="button"
>
<Plug className="h-3.5 w-3.5" />
</button>
<button
className="inline-flex h-7 w-7 items-center justify-center rounded-[7px] border border-[#ddd7ca] bg-white text-[#5f594d] transition-all duration-150 ease-out hover:bg-[#faf7ef] disabled:cursor-not-allowed disabled:opacity-50"
disabled={
isModelingBusy ||
!isOfficialModelingAvailable ||
!modelingSketchId
}
onClick={() => swallowAsync(handleExtrudeSketch())}
title="拉伸当前草图"
type="button"
>
<Box className="h-3.5 w-3.5" />
</button>
<button
className="inline-flex h-7 w-7 items-center justify-center rounded-[7px] border border-[#ddd7ca] bg-white text-[#5f594d] transition-all duration-150 ease-out hover:bg-[#faf7ef] disabled:cursor-not-allowed disabled:opacity-50"
disabled={
isModelingBusy ||
!isOfficialModelingAvailable ||
!modelingSolidId
}
onClick={() => swallowAsync(handleSelectCurrentBody())}
title="选择当前实体"
type="button"
>
<MousePointer2 className="h-3.5 w-3.5" />
</button>
<button
className="inline-flex h-7 w-7 items-center justify-center rounded-[7px] border border-[#ddd7ca] bg-white text-[#5f594d] transition-all duration-150 ease-out hover:bg-[#faf7ef] disabled:cursor-not-allowed disabled:opacity-50"
disabled={
isModelingBusy ||
!isOfficialModelingAvailable ||
!modelingSolidId
}
onClick={() => swallowAsync(handleCutSelectedEdges())}
title={
modelingCutType === "fillet"
? "对选中边执行圆角"
: "对选中边执行倒角"
}
type="button"
>
<WandSparkles className="h-3.5 w-3.5" />
</button>
<button
className="inline-flex h-7 w-7 items-center justify-center rounded-[7px] border border-[#ddd7ca] bg-white text-[#5f594d] transition-all duration-150 ease-out hover:bg-[#faf7ef] disabled:cursor-not-allowed disabled:opacity-50"
disabled={!isOfficialModelingAvailable}
onClick={() => setIsModelingToolsDialogOpen(true)}
title="打开官方建模工具参数"
type="button"
>
<Settings2 className="h-3.5 w-3.5" />
</button>
</AiCadToolbarGroup>
) : null}
<AiCadToolbarGroup className="shrink-0" label="Mode">
<button
className={cn(
"inline-flex h-7 items-center gap-1.5 rounded-[7px] border px-2 text-[12px] transition-all duration-150 ease-out",
activeResultTab === "preview"
? "border-[#bfd9c9] bg-[#ecfbf2] text-[#173225]"
: "border-[#ddd7ca] bg-white text-[#5f594d] hover:bg-[#faf7ef]"
)}
onClick={() => {
resultPanelRef.current?.expand();
setActiveResultTab("preview");
}}
type="button"
>
<Orbit className="h-4 w-4" />
3D Preview
</button>
<button
className={cn(
"inline-flex h-7 items-center gap-1.5 rounded-[7px] border px-2 text-[12px] transition-all duration-150 ease-out",
activeResultTab === "code"
? "border-[#bfd9c9] bg-[#ecfbf2] text-[#173225]"
: "border-[#ddd7ca] bg-white text-[#5f594d] hover:bg-[#faf7ef]"
)}
onClick={() => {
resultPanelRef.current?.expand();
setActiveResultTab("code");
}}
type="button"
>
<FileCode2 className="h-4 w-4" />
KCL
</button>
</AiCadToolbarGroup>
<AiCadToolbarGroup className="shrink-0" label="Display">
<button
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-[7px] border transition-all duration-150 ease-out",
viewerSettings.showGrid
? "border-[#bfd9c9] bg-[#ecfbf2] text-[#173225]"
: "border-[#ddd7ca] bg-white text-[#5f594d] hover:bg-[#faf7ef]"
)}
onClick={() =>
setViewerSettings((current) => ({
...current,
showGrid: !current.showGrid,
}))
}
title="切换网格"
type="button"
>
<Grid3X3 className="h-4 w-4" />
</button>
<button
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-[7px] border transition-all duration-150 ease-out",
viewerSettings.autoRotate
? "border-[#bfd9c9] bg-[#ecfbf2] text-[#173225]"
: "border-[#ddd7ca] bg-white text-[#5f594d] hover:bg-[#faf7ef]"
)}
onClick={() =>
setViewerSettings((current) => ({
...current,
autoRotate: !current.autoRotate,
}))
}
title="切换自动旋转"
type="button"
>
<RotateCcw className="h-4 w-4" />
</button>
<button
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-[7px] border transition-all duration-150 ease-out",
viewerSettings.wireframe
? "border-[#bfd9c9] bg-[#ecfbf2] text-[#173225]"
: "border-[#ddd7ca] bg-white text-[#5f594d] hover:bg-[#faf7ef]"
)}
onClick={() =>
setViewerSettings((current) => ({
...current,
wireframe: !current.wireframe,
}))
}
title="切换线框模式"
type="button"
>
<Box className="h-4 w-4" />
</button>
</AiCadToolbarGroup>
</div>
<div className="flex shrink-0 items-center gap-2 whitespace-nowrap">
<div className="flex items-center gap-2.5 text-[#7f7768] text-[10px]">
<span
className={cn(
"inline-flex items-center gap-1.5",
workspaceSyncState === "synced"
? "text-emerald-700"
: "text-[#7f7768]"
)}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
workspaceSyncState === "synced"
? "bg-emerald-500"
: "bg-amber-400"
)}
/>
{workspaceSyncState === "synced" ? "Synced" : "Syncing"}
</span>
<span>{versions.length} versions</span>
<span>{completedCount} done</span>
<span>
{hasPendingTasks
? `${pendingTaskCount} task active`
: "Workspace stable"}
</span>
</div>
<AiCadToolbarGroup className="shrink-0" label="Session">
<Badge className="rounded-md border-[#ddd7ca] bg-white px-1.5 py-0.5 text-[#5f594d] text-[10px]">
{generationProvider === "kittycad-cad"
? "Zoo CAD"
: "Tripo3D"}
</Badge>
<Badge className="rounded-md border-[#ddd7ca] bg-white px-1.5 py-0.5 text-[#5f594d] text-[10px]">
{viewerSettings.envPreset}
</Badge>
{activeVersion ? (
<Badge className="rounded-md border-[#ddd7ca] bg-white px-1.5 py-0.5 text-[#5f594d] text-[10px]">
V{activeVersion.versionNumber}
</Badge>
) : null}
</AiCadToolbarGroup>
</div>
</div>
</div>
{(workspaceSyncError || workspaceOperationError) && (
<div className="border-[#e6e0d3] border-b bg-[#fffaf0] px-4 py-2">
<div className="flex flex-wrap gap-3 text-xs leading-5">
{workspaceSyncError ? (
<p className="text-rose-700">
云端同步提示:{workspaceSyncError}
</p>
) : null}
{workspaceOperationError ? (
<p className="text-amber-700">
工作台提示:{workspaceOperationError}
</p>
) : null}
</div>
</div>
)}
<div className="min-h-0 flex-1 overflow-hidden bg-white">
<PanelGroup
autoSaveId={`ai-cad-panel-layout:v2:${panelDirection}`}
className="flex h-full min-h-0"
direction={panelDirection}
>
<Panel
className="order-5 min-h-0"
collapsedSize={panelDirection === "horizontal" ? 8 : 12}
collapsible
defaultSize={panelDirection === "horizontal" ? 39 : 29}
minSize={panelDirection === "horizontal" ? 32 : 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"
/>
) : (
<div className="flex h-full min-h-0 flex-col border-[#e6e0d3] border-l bg-white text-[#201c16]">
<div className="flex h-12 items-center justify-between border-[#e6e0d3] border-b px-4">
<div className="flex items-center gap-2 font-medium text-[15px]">
<BrainCircuit className="h-4 w-4 text-[#6f695d]" />
AI-CAD Agent
</div>
<button
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[#6f695d] transition hover:bg-[#f6f2ea] hover:text-[#201c16]"
onClick={() =>
conversationPanelRef.current?.collapse()
}
type="button"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="flex min-h-full flex-col justify-end px-5 py-5">
{activeVersion || messages.length > 1 ? (
<div className="space-y-4">
{messages.map((message) => {
const linkedVersion = message.versionId
? findVersionById(versions, message.versionId)
: null;
return (
<div
className={cn(
"flex",
message.role === "user"
? "justify-end"
: "justify-start"
)}
key={message.id}
>
<div
className={cn(
"max-w-[86%]",
message.role === "user"
? "rounded-[8px] border border-[#e5dfd2] bg-white px-4 py-3"
: "rounded-[10px] bg-[#f3f2ee] px-4 py-4"
)}
>
<div className="mb-2 flex items-center gap-2 text-[#8a8272] text-[11px]">
{message.role === "user" ? (
<span>你</span>
) : (
<span>AI-CAD</span>
)}
<span>
{formatClockTime(message.createdAt)}
</span>
{message.role !== "user" &&
message.state ? (
<span className="text-[#6e685d]">
{getMessageStateLabel(
message.state
)}
</span>
) : null}
</div>
<div className="text-[15px] leading-7">
{message.content}
</div>
{linkedVersion ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
className="inline-flex items-center gap-1 rounded-md border border-[#ddd7ca] bg-white px-2.5 py-1 text-[#5f594d] text-xs transition hover:bg-[#faf7ef] hover:text-[#201c16]"
onClick={() =>
setActiveVersionId(
linkedVersion.id
)
}
type="button"
>
V{linkedVersion.versionNumber}
</button>
{canIterateKittyCadVersion(
linkedVersion
) ? (
<button
className="inline-flex items-center gap-1 rounded-md border border-[#ddd7ca] bg-white px-2.5 py-1 text-[#5f594d] text-xs transition hover:bg-[#faf7ef] hover:text-[#201c16]"
onClick={() =>
handleFocusVersionForIteration(
linkedVersion
)
}
type="button"
>
继续修改
</button>
) : null}
</div>
) : null}
</div>
</div>
);
})}
</div>
) : (
<div className="space-y-5">
<div className="text-center text-[#7f7768] text-[14px]">
9 seconds ago
</div>
<div className="flex items-start gap-3">
<div className="mt-1 flex h-8 w-8 items-center justify-center rounded-md bg-[#31df86] text-[#102315]">
<Bot className="h-4 w-4" />
</div>
<div className="max-w-[92%] rounded-[10px] bg-[#f3f2ee] px-5 py-4 text-[#3d382f] text-[15px] leading-7">
Try requesting a model, ask engineering
questions, or let's explore ideas.
</div>
</div>
</div>
)}
</div>
</ScrollArea>
<div className="border-[#e6e0d3] border-t bg-white px-4 py-4">
<div className="rounded-[8px] border border-[#d7d0c1] bg-white">
<Textarea
className="min-h-[120px] resize-none border-0 bg-transparent px-4 py-4 text-[#201c16] text-[15px] 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 gear with 10 teeth and use sensible defaults for everything else..."
value={prompt}
/>
<div className="flex flex-wrap items-center justify-between gap-3 border-[#ece6d9] border-t px-4 py-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-[136px] rounded-[8px] border-[#ddd7ca] bg-white text-[#201c16]">
<SelectValue placeholder="选择生成引擎" />
</SelectTrigger>
<SelectContent>
<SelectItem value="kittycad-cad">
Zoo CAD
</SelectItem>
<SelectItem value="tripo-3d">
Tripo3D
</SelectItem>
</SelectContent>
</Select>
{generationProvider === "kittycad-cad" ? (
<Button
className="h-10 w-10 rounded-[8px] border-[#ddd7ca] bg-white p-0 text-[#5f594d] hover:bg-[#f4f1e8]"
onClick={() =>
setIsModelingToolsDialogOpen(true)
}
size="icon"
title="打开官方建模工具"
type="button"
variant="outline"
>
<Settings2 className="h-4 w-4" />
</Button>
) : null}
</div>
<Button
className="h-10 w-10 rounded-[8px] 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>
</div>
<div className="mt-2 flex items-center justify-between text-[#9a9385] text-xs">
<span>
{generationProvider === "kittycad-cad"
? "Zoo CAD"
: "Tripo3D"}
</span>
<span>AI-CAD 可能出错,请始终核对结果。</span>
</div>
</div>
</div>
)}
</Panel>
<AiCadPanelResizeHandle className="order-4" />
<Panel
className="order-1 min-h-0"
collapsedSize={panelDirection === "horizontal" ? 0 : 12}
collapsible
defaultSize={panelDirection === "horizontal" ? 14 : 18}
minSize={panelDirection === "horizontal" ? 14 : 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-none border-0 border-[#e6e0d3] border-r bg-white text-[#201c16] shadow-none">
<CardHeader className="border-[#e6e0d3] border-b bg-white 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-none border-0 border-[#e6e0d3] border-r bg-white text-[#201c16] shadow-none">
<CardHeader className="border-[#e6e0d3] border-b bg-white 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={panelDirection === "horizontal" ? 47 : 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="建模画布"
/>
) : (
<div className="flex h-full min-h-0 flex-col bg-white text-[#201c16]">
<div className="flex h-12 items-center justify-between border-[#e6e0d3] border-b px-4">
<div className="flex min-w-0 items-center gap-2.5">
<Orbit className="h-4 w-4 text-[#6f695d]" />
<div className="font-medium text-[15px]">Canvas</div>
{activeVersion ? (
<Badge className="rounded-md border-[#ddd7ca] bg-white text-[#5f594d]">
V{activeVersion.versionNumber}
</Badge>
) : null}
</div>
<div className="flex items-center gap-2">
<div className="text-[#8b8375] text-[11px]">
{activeVersion
? "Preview-first canvas"
: "Ready for prompt"}
</div>
</div>
</div>
<div 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 文件,这里会自动切换预览。"
: modelingSnapshotUrl
? "当前显示的是官方 modeling 会话快照;生成真实 3D 文件后,这里会自动切回模型预览。"
: "生成完成后,这里会显示当前版本的 3D 预览"
}
modelUrl={
activePreviewVersion?.modelUrl
}
previewImageUrl={
modelingPreviewImageUrl
}
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">
{canEditActiveKcl ? (
<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>
<div className="flex items-center gap-2 font-medium text-[#201c16] text-sm">
<FileCode2 className="h-4 w-4 text-[#6f695d]" />
KCL 编辑 + 官方重算
</div>
<p className="mt-1 text-[#7f7768] text-xs leading-5">
这里会直接把你编辑后的 KCL
提交到官方{" "}
<code className="rounded bg-[#f8f5ee] px-1 py-0.5 text-[11px]">
text-to-cad/iteration
</code>
,不会走本地伪造求解。
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
className="rounded-full border-[#ddd7ca] bg-white text-[#201c16] hover:bg-[#f4f1e8]"
disabled={
isApplyingKclEdit ||
!activeCodeContent
}
onClick={() => {
setKclDraft(
activeCodeContent || ""
);
setKclEditPrompt("");
setCodeActionError(null);
}}
size="sm"
type="button"
variant="outline"
>
还原当前版本
</Button>
<Button
className="rounded-full bg-[#31df86] text-[#102315] hover:bg-[#2ed57f]"
disabled={
isApplyingKclEdit ||
!kclDraft.trim()
}
onClick={() =>
swallowAsync(
handleApplyKclEdit()
)
}
size="sm"
type="button"
>
{isApplyingKclEdit ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
应用 KCL 并重算
</Button>
</div>
</div>
<div className="mt-3 grid gap-3">
<Input
className="h-9 border-[#ddd7ca] bg-white text-sm"
onChange={(event) =>
setKclEditPrompt(
event.target.value
)
}
placeholder="可选:补一句这轮修改意图,例如“保持外轮廓不变,只把倒角统一为 2mm”"
value={kclEditPrompt}
/>
<Textarea
className="min-h-[220px] resize-y border-[#ddd7ca] bg-[#fbfaf6] font-mono text-[12px] leading-6"
onChange={(event) =>
setKclDraft(event.target.value)
}
spellCheck={false}
value={kclDraft}
/>
</div>
</div>
) : null}
{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-white">
<div className="relative min-h-0 flex-1 overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(180deg,#ffffff_0%,#fcfaf5_100%)]" />
<div className="-translate-x-1/2 absolute top-[12%] left-1/2 h-[72%] w-px bg-[#ddd6c9]" />
<div className="-translate-y-1/2 absolute top-1/2 left-[8%] h-px w-[74%] bg-[#ddd6c9]" />
<div className="-translate-x-1/2 absolute top-[22%] left-1/2 h-[56%] w-[42%] rounded-[999px] border border-[#efe8db]" />
<div className="absolute right-8 bottom-8 flex h-[92px] w-[92px] items-center justify-center rounded-full border border-[#ddd7ca] bg-[#f6f3eb] text-[#7f7768] text-[11px] shadow-sm">
<div className="text-center leading-4">
<div>Top</div>
<div>Front</div>
<div>Right</div>
</div>
</div>
<div className="absolute inset-x-0 bottom-10 flex justify-center px-6">
<div className="text-center text-[#948d7f] text-[13px]">
从右侧输入设计意图,模型会直接出现在画布中。
</div>
</div>
</div>
</div>
)}
</div>
</div>
)}
</Panel>
</PanelGroup>
</div>
<div className="border-[#e6e0d3] border-t bg-white px-4 py-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-4 text-[#7f7768] text-xs">
<span>{getProviderLabel(generationProvider)}</span>
<span>
{activeVersion
? `V${activeVersion.versionNumber} · ${getStatusLabel(activeVersion.status)}`
: "Ready for first pass"}
</span>
<span>
{hasPendingTasks
? `${pendingTaskCount} active task`
: "Workspace stable"}
</span>
</div>
<div className="flex flex-wrap items-center gap-4 text-[#7f7768] text-xs">
<span>No selection</span>
<span>mm</span>
</div>
</div>
</div>
<Dialog
onOpenChange={setIsModelingToolsDialogOpen}
open={isModelingToolsDialogOpen}
>
<DialogContent className="max-w-2xl border-[#ddd7ca] bg-[#fbfaf6] p-0 text-[#201c16]">
<DialogHeader className="border-[#e6e0d3] border-b px-6 py-5">
<DialogTitle className="flex items-center gap-2 text-[18px]">
<WandSparkles className="h-4 w-4 text-[#6f695d]" />
官方建模工具
</DialogTitle>
<DialogDescription className="text-[#7f7768] text-sm leading-6">
这里的动作直接调用 Zoo 官方{" "}
<code className="rounded bg-white px-1 py-0.5 text-[#5f594d] text-[11px]">
ws/modeling/commands
</code>
。主界面保持画布优先,这些参数收在弹层里。
</DialogDescription>
</DialogHeader>
<div className="space-y-4 px-6 py-5">
<div className="flex flex-wrap items-center gap-2">
<Badge
className={cn(
"rounded-full px-2 py-0.5 text-[10px]",
modelingConnectionState === "connected"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: modelingConnectionState === "error"
? "border-rose-200 bg-rose-50 text-rose-700"
: "border-[#ddd7ca] bg-white text-[#5f594d]"
)}
>
{modelingConnectionState}
</Badge>
<Badge className="rounded-full border-[#ddd7ca] bg-white px-2 py-0.5 text-[#5f594d] text-[10px]">
{modelingSessionId ? "session live" : "session idle"}
</Badge>
<Badge className="rounded-full border-[#ddd7ca] bg-white px-2 py-0.5 text-[#5f594d] text-[10px]">
{modelingCutType === "fillet" ? "fillet" : "chamfer"}
</Badge>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<Input
className="h-10 border-[#ddd7ca] bg-white text-sm"
onChange={(event) => setModelingWidth(event.target.value)}
placeholder="宽度"
value={modelingWidth}
/>
<Input
className="h-10 border-[#ddd7ca] bg-white text-sm"
onChange={(event) =>
setModelingHeight(event.target.value)
}
placeholder="高度"
value={modelingHeight}
/>
<Input
className="h-10 border-[#ddd7ca] bg-white text-sm"
onChange={(event) => setModelingDepth(event.target.value)}
placeholder="拉伸深度"
value={modelingDepth}
/>
</div>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_140px_140px]">
<Input
className="h-10 border-[#ddd7ca] bg-white text-sm"
onChange={(event) =>
setModelingEdgeIndices(event.target.value)
}
placeholder="边索引,例如 0,1,2,3"
value={modelingEdgeIndices}
/>
<Select
onValueChange={(value) =>
setModelingCutType(value as ModelingCutType)
}
value={modelingCutType}
>
<SelectTrigger className="h-10 border-[#ddd7ca] bg-white text-sm">
<SelectValue placeholder="切边类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="fillet">Fillet</SelectItem>
<SelectItem value="chamfer">Chamfer</SelectItem>
</SelectContent>
</Select>
<Input
className="h-10 border-[#ddd7ca] bg-white text-sm"
onChange={(event) =>
setModelingCutSize(event.target.value)
}
placeholder="半径 / 距离"
value={modelingCutSize}
/>
</div>
<div className="flex flex-wrap gap-2">
<Button
className="rounded-full border-[#ddd7ca] bg-white text-[#201c16] hover:bg-[#f4f1e8]"
disabled={isModelingBusy || !isOfficialModelingAvailable}
onClick={() =>
swallowAsync(
modelingSessionId
? handleDisconnectModelingSession()
: ensureModelingSession()
)
}
size="sm"
type="button"
variant="outline"
>
{modelingSessionId ? "断开会话" : "连接会话"}
</Button>
<Button
className="rounded-full border-[#ddd7ca] bg-white text-[#201c16] hover:bg-[#f4f1e8]"
disabled={isModelingBusy || !isOfficialModelingAvailable}
onClick={() =>
swallowAsync(handleCreateRectangleSketch())
}
size="sm"
type="button"
variant="outline"
>
草图矩形
</Button>
<Button
className="rounded-full border-[#ddd7ca] bg-white text-[#201c16] hover:bg-[#f4f1e8]"
disabled={
isModelingBusy ||
!isOfficialModelingAvailable ||
!modelingSketchId
}
onClick={() => swallowAsync(handleExtrudeSketch())}
size="sm"
type="button"
variant="outline"
>
拉伸
</Button>
<Button
className="rounded-full border-[#ddd7ca] bg-white text-[#201c16] hover:bg-[#f4f1e8]"
disabled={
isModelingBusy ||
!isOfficialModelingAvailable ||
!modelingSolidId
}
onClick={() => swallowAsync(handleSelectCurrentBody())}
size="sm"
type="button"
variant="outline"
>
选择当前实体
</Button>
<Button
className="rounded-full border-[#ddd7ca] bg-white text-[#201c16] hover:bg-[#f4f1e8]"
disabled={
isModelingBusy ||
!isOfficialModelingAvailable ||
!modelingSolidId
}
onClick={() => swallowAsync(handleCutSelectedEdges())}
size="sm"
type="button"
variant="outline"
>
{modelingCutType === "fillet" ? "执行圆角" : "执行倒角"}
</Button>
</div>
<div className="rounded-2xl border border-[#e3ddcf] bg-white px-4 py-3 text-[#7f7768] text-xs leading-6">
<p>
当前平面:
<span className="font-mono text-[#5f594d]">
{truncateIdentifier(modelingPlaneId, 16)}
</span>
</p>
<p>
当前草图:
<span className="font-mono text-[#5f594d]">
{truncateIdentifier(modelingSketchId, 16)}
</span>
,当前实体:
<span className="font-mono text-[#5f594d]">
{truncateIdentifier(modelingSolidId, 16)}
</span>
</p>
<p>
当前选择:
<span className="font-mono text-[#5f594d]">
{modelingSelectedEntityIds.length > 0
? modelingSelectedEntityIds
.map((item) => truncateIdentifier(item, 10))
.join(", ")
: "未选择"}
</span>
</p>
</div>
{modelingError ? (
<div className="rounded-[10px] border border-rose-200 bg-rose-50 px-4 py-3 text-rose-700 text-sm leading-6">
{modelingError}
</div>
) : null}
{modelingLogLines.length > 0 ? (
<div className="rounded-[10px] border border-[#e3ddcf] bg-white px-4 py-3">
<div className="text-[#8a8272] text-[11px] uppercase tracking-[0.12em]">
session log
</div>
<div className="mt-2 space-y-1 text-[#5f594d] text-xs leading-5">
{modelingLogLines.slice(0, 4).map((line) => (
<p key={line}>{line}</p>
))}
</div>
</div>
) : null}
</div>
</DialogContent>
</Dialog>
</div>
</div>
</div>
</div>
);
}