6275 lines
255 KiB
TypeScript
6275 lines
255 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
AlertCircle,
|
||
ArrowUpRight,
|
||
Bot,
|
||
Box,
|
||
BrainCircuit,
|
||
CheckCircle2,
|
||
ChevronDown,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
ChevronUp,
|
||
Copy,
|
||
Download,
|
||
FileCode2,
|
||
FolderOpen,
|
||
GitBranch,
|
||
Grid3X3,
|
||
History,
|
||
Loader2,
|
||
MoreHorizontal,
|
||
Orbit,
|
||
Plus,
|
||
RotateCcw,
|
||
Send,
|
||
ThumbsDown,
|
||
ThumbsUp,
|
||
Trash2,
|
||
WandSparkles,
|
||
XCircle,
|
||
} from "lucide-react";
|
||
import {
|
||
type KeyboardEvent,
|
||
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 { 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 AiCadProviderStatus,
|
||
type AiCadWorkspaceSummary,
|
||
aiCadGenerationService,
|
||
} from "@/services/ai-cad";
|
||
import {
|
||
type Model3DTaskStatus,
|
||
model3DGenerationService,
|
||
} from "@/services/canvas";
|
||
|
||
type CadGenerationProvider = "kittycad-cad" | "tripo-3d";
|
||
|
||
type ChatMessageState =
|
||
| "thinking"
|
||
| "queued"
|
||
| "running"
|
||
| "success"
|
||
| "failed";
|
||
|
||
type VersionFeedback = "positive" | "negative";
|
||
|
||
type ChatMessage = {
|
||
id: string;
|
||
role: "assistant" | "user";
|
||
content: string;
|
||
createdAt: number;
|
||
state?: ChatMessageState;
|
||
versionId?: string;
|
||
};
|
||
|
||
type GenerationVersion = {
|
||
id: string;
|
||
versionNumber: number;
|
||
provider: CadGenerationProvider;
|
||
taskId: string;
|
||
generationMode?: "create" | "iterate";
|
||
parentVersionId?: string | null;
|
||
prompt: string;
|
||
modelVersion: string;
|
||
createdAt: number;
|
||
status: Model3DTaskStatus;
|
||
progress: number | null;
|
||
modelUrl?: string | null;
|
||
previewImageUrl?: string | null;
|
||
codeUrl?: string | null;
|
||
errorMessage?: string | null;
|
||
conversationId?: string | null;
|
||
downloadUrls?: Record<string, string> | null;
|
||
feedback?: VersionFeedback | null;
|
||
assistantMessageId: string;
|
||
};
|
||
|
||
type PersistedAiCadWorkbenchState = {
|
||
prompt: string;
|
||
generationProvider: CadGenerationProvider;
|
||
modelVersion: string;
|
||
viewerSettings: Model3DViewerSettings;
|
||
messages: ChatMessage[];
|
||
versions: GenerationVersion[];
|
||
activeVersionId: string | null;
|
||
savedAt?: number;
|
||
};
|
||
|
||
type WorkspaceListItem = AiCadWorkspaceSummary;
|
||
|
||
const MODEL_VERSION_OPTIONS = [
|
||
"Turbo-v1.0-20250506",
|
||
"v3.1-20260211",
|
||
"v3.0-20250812",
|
||
"v2.5-20250123",
|
||
] as const;
|
||
|
||
const VIEWER_ENV_PRESETS = [
|
||
"studio",
|
||
"city",
|
||
"sunset",
|
||
"dawn",
|
||
"night",
|
||
"warehouse",
|
||
"forest",
|
||
"apartment",
|
||
"park",
|
||
"lobby",
|
||
] as const;
|
||
|
||
const SUGGESTED_PROMPTS = [
|
||
"设计一把极简风铝合金折叠椅,强调轻量和可堆叠结构",
|
||
"做一个桌面级模块化收纳盒,带磁吸翻盖和可替换分隔槽",
|
||
"生成一款人体工学游戏鼠标外壳,右手握持,侧裙有蜂窝纹理",
|
||
"创建一个便携咖啡杯杯盖结构,带旋转式开合口和防漏密封圈",
|
||
] as const;
|
||
|
||
const FINAL_TASK_STATUS = new Set<Model3DTaskStatus>([
|
||
"success",
|
||
"failed",
|
||
"banned",
|
||
"expired",
|
||
"cancelled",
|
||
"unknown",
|
||
]);
|
||
|
||
const DEFAULT_VIEWER_SETTINGS: Model3DViewerSettings = {
|
||
envPreset: "studio",
|
||
lightIntensity: 1.1,
|
||
autoRotate: true,
|
||
showGrid: true,
|
||
wireframe: false,
|
||
};
|
||
|
||
const DOWNLOAD_ORDER = [
|
||
"glb",
|
||
"step",
|
||
"stp",
|
||
"gltf",
|
||
"kcl",
|
||
"stl",
|
||
"obj",
|
||
"fbx",
|
||
"3mf",
|
||
] as const;
|
||
|
||
const MODEL_TASK_STATUSES: Model3DTaskStatus[] = [
|
||
"queued",
|
||
"running",
|
||
"success",
|
||
"failed",
|
||
"banned",
|
||
"expired",
|
||
"cancelled",
|
||
"unknown",
|
||
];
|
||
|
||
const AI_CAD_WORKBENCH_STORAGE_PREFIX = "ai-cad-workbench:v2";
|
||
const AI_CAD_ACTIVE_WORKSPACE_STORAGE_KEY = "ai-cad-active-workspace-id:v1";
|
||
const NEWLINE_SPLIT_RE = /\r?\n/;
|
||
const DEFAULT_WORKSPACE_TITLE_RE = /^AI-CAD 工作台(?: \d+)?$/;
|
||
const STRUCTURED_PROMPT_DIRECTIVES = [
|
||
{
|
||
key: "keep",
|
||
label: "保留",
|
||
helper: "把不想被改动的主体写清楚。",
|
||
},
|
||
{
|
||
key: "change",
|
||
label: "修改",
|
||
helper: "说明这轮真正要动的结构部位。",
|
||
},
|
||
{
|
||
key: "remove",
|
||
label: "删除",
|
||
helper: "指出这轮要去掉或回退的内容。",
|
||
},
|
||
{
|
||
key: "constraints",
|
||
label: "约束",
|
||
helper: "补充壁厚、公差、工艺或装配要求。",
|
||
},
|
||
{
|
||
key: "material",
|
||
label: "材料",
|
||
helper: "交代材料、表面处理和使用场景。",
|
||
},
|
||
] as const;
|
||
|
||
type StructuredPromptDirectiveKey =
|
||
(typeof STRUCTURED_PROMPT_DIRECTIVES)[number]["key"];
|
||
|
||
type StructuredPromptDirective = {
|
||
key: StructuredPromptDirectiveKey;
|
||
label: string;
|
||
helper: string;
|
||
value: string;
|
||
};
|
||
|
||
type CodeDiffKind = "added" | "changed" | "equal" | "removed";
|
||
|
||
type CodeDiffRow = {
|
||
kind: CodeDiffKind;
|
||
leftLineNumber: number | null;
|
||
leftText: string;
|
||
rightLineNumber: number | null;
|
||
rightText: string;
|
||
};
|
||
|
||
type SemanticDiffCategory = {
|
||
description: string;
|
||
followUpHint: string;
|
||
keywords: RegExp[];
|
||
label: string;
|
||
shortLabel: string;
|
||
};
|
||
|
||
type SemanticDiffInsight = {
|
||
count: number;
|
||
detail: string;
|
||
examples: string[];
|
||
key: string;
|
||
label: string;
|
||
shortLabel: string;
|
||
};
|
||
|
||
const SEMANTIC_DIFF_CATEGORIES: Record<string, SemanticDiffCategory> = {
|
||
thickness: {
|
||
description: "这轮更像是在调整壁厚、抽壳或整体壳体厚度。",
|
||
followUpHint: "下一轮可以明确写出目标壁厚、抽壳方向和哪些区域不能再变薄。",
|
||
keywords: [
|
||
/\bthickness\b/i,
|
||
/\bwall\b/i,
|
||
/\bshell\b/i,
|
||
/壁厚|壳厚|厚度|抽壳/,
|
||
],
|
||
label: "壁厚 / 壳厚",
|
||
shortLabel: "壁厚",
|
||
},
|
||
edgeTreatment: {
|
||
description: "这轮集中在圆角、倒角或边缘过渡处理。",
|
||
followUpHint:
|
||
"下一轮可以把哪些边要保留锐边、哪些边需要统一圆角半径写得更明确。",
|
||
keywords: [
|
||
/\bfillet\b/i,
|
||
/\bchamfer\b/i,
|
||
/\bround(ed|ing)?\b/i,
|
||
/圆角|倒角|边缘过渡|转角/,
|
||
],
|
||
label: "圆角 / 倒角",
|
||
shortLabel: "圆角",
|
||
},
|
||
holeLayout: {
|
||
description: "这轮重点动了孔位、通孔、沉头孔或安装孔表达。",
|
||
followUpHint: "下一轮可以把孔径、孔深、孔间距和安装基准面写清楚。",
|
||
keywords: [
|
||
/\bhole\b/i,
|
||
/\bbore\b/i,
|
||
/\bdrill\b/i,
|
||
/\bcounter(sink|bore)\b/i,
|
||
/\bmount(ing)?\b/i,
|
||
/孔位|孔径|通孔|沉头孔|安装孔|安装点/,
|
||
],
|
||
label: "孔位 / 安装点",
|
||
shortLabel: "孔位",
|
||
},
|
||
dimensions: {
|
||
description: "这轮主要改了尺寸、半径、直径、偏移或间隙参数。",
|
||
followUpHint:
|
||
"下一轮可以补上关键尺寸、间隙、公差范围,避免模型只改了语气没改到数值。",
|
||
keywords: [
|
||
/\blength\b/i,
|
||
/\bwidth\b/i,
|
||
/\bheight\b/i,
|
||
/\bradius\b/i,
|
||
/\bdiameter\b/i,
|
||
/\boffset\b/i,
|
||
/\bclearance\b/i,
|
||
/\bgap\b/i,
|
||
/\btolerance\b/i,
|
||
/尺寸|长宽高|半径|直径|偏移|间隙|公差|厚薄|比例/,
|
||
],
|
||
label: "尺寸 / 公差",
|
||
shortLabel: "尺寸",
|
||
},
|
||
patternLayout: {
|
||
description: "这轮涉及阵列、镜像、间距或布局组织方式。",
|
||
followUpHint: "下一轮可以说明阵列方向、数量、间距,以及是否要镜像或错位。",
|
||
keywords: [
|
||
/\bpattern\b/i,
|
||
/\barray\b/i,
|
||
/\bmirror\b/i,
|
||
/\bspacing\b/i,
|
||
/\brepeat\b/i,
|
||
/\btranslate\b/i,
|
||
/\brotate\b/i,
|
||
/阵列|镜像|间距|排布|布局|重复|旋转分布/,
|
||
],
|
||
label: "阵列 / 布局",
|
||
shortLabel: "阵列",
|
||
},
|
||
profileShape: {
|
||
description: "这轮更像是在改主体成形方式,比如拉伸、旋转或放样轮廓。",
|
||
followUpHint:
|
||
"下一轮可以把主体轮廓、截面变化和成形方式拆开描述,减少形体歧义。",
|
||
keywords: [
|
||
/\bextrude\b/i,
|
||
/\brevolve\b/i,
|
||
/\bloft\b/i,
|
||
/\bsweep\b/i,
|
||
/\bsketch\b/i,
|
||
/\bprofile\b/i,
|
||
/拉伸|旋转|放样|扫描|轮廓|草图|截面|主体形态/,
|
||
],
|
||
label: "轮廓 / 成形",
|
||
shortLabel: "成形",
|
||
},
|
||
materialProcess: {
|
||
description: "这轮补充了材料、表面、制造约束或装配工艺信息。",
|
||
followUpHint:
|
||
"下一轮可以把材料、表面处理、加工方式和装配限制拆成独立要求。",
|
||
keywords: [
|
||
/\bmaterial\b/i,
|
||
/\bfinish\b/i,
|
||
/\banod/i,
|
||
/\bcoating\b/i,
|
||
/\bprint(ed|ing)?\b/i,
|
||
/\bmold(ing)?\b/i,
|
||
/\bassembly\b/i,
|
||
/\bfastener\b/i,
|
||
/材料|表面处理|阳极|喷涂|打印|注塑|装配|工艺|紧固件/,
|
||
],
|
||
label: "材料 / 工艺",
|
||
shortLabel: "工艺",
|
||
},
|
||
};
|
||
|
||
function createId() {
|
||
return crypto.randomUUID();
|
||
}
|
||
|
||
function swallowAsync(task: Promise<unknown>) {
|
||
task.catch((_error) => null);
|
||
}
|
||
|
||
function formatClockTime(timestamp: number) {
|
||
return new Intl.DateTimeFormat("zh-CN", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
}).format(new Date(timestamp));
|
||
}
|
||
|
||
function formatVersionDetailTime(timestamp: number) {
|
||
return new Intl.DateTimeFormat("zh-CN", {
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
}).format(new Date(timestamp));
|
||
}
|
||
|
||
function truncateIdentifier(value?: string | null, length = 10) {
|
||
const normalizedValue = String(value || "").trim();
|
||
if (!normalizedValue) {
|
||
return "未记录";
|
||
}
|
||
|
||
return normalizedValue.length > length
|
||
? `${normalizedValue.slice(0, length)}...`
|
||
: normalizedValue;
|
||
}
|
||
|
||
function truncateText(value?: string | null, length = 88) {
|
||
const normalizedValue = String(value || "")
|
||
.trim()
|
||
.replace(/\s+/g, " ");
|
||
|
||
if (!normalizedValue) {
|
||
return "未填写";
|
||
}
|
||
|
||
return normalizedValue.length > length
|
||
? `${normalizedValue.slice(0, length).trim()}...`
|
||
: normalizedValue;
|
||
}
|
||
|
||
function escapeRegExp(value: string) {
|
||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
}
|
||
|
||
function getStatusLabel(status: Model3DTaskStatus) {
|
||
switch (status) {
|
||
case "queued":
|
||
return "排队中";
|
||
case "running":
|
||
return "生成中";
|
||
case "success":
|
||
return "已完成";
|
||
case "failed":
|
||
return "失败";
|
||
case "banned":
|
||
return "已拦截";
|
||
case "expired":
|
||
return "已过期";
|
||
case "cancelled":
|
||
return "已取消";
|
||
default:
|
||
return "未知";
|
||
}
|
||
}
|
||
|
||
function getStatusBadgeClass(status: Model3DTaskStatus) {
|
||
switch (status) {
|
||
case "success":
|
||
return "border-emerald-400/25 bg-emerald-400/15 text-emerald-100";
|
||
case "running":
|
||
return "border-sky-400/25 bg-sky-400/15 text-sky-100";
|
||
case "queued":
|
||
return "border-amber-400/25 bg-amber-400/15 text-amber-100";
|
||
case "failed":
|
||
case "banned":
|
||
case "cancelled":
|
||
case "expired":
|
||
return "border-rose-400/25 bg-rose-400/15 text-rose-100";
|
||
default:
|
||
return "border-white/10 bg-white/10 text-white/75";
|
||
}
|
||
}
|
||
|
||
function getMessageStateClass(state?: ChatMessageState) {
|
||
switch (state) {
|
||
case "success":
|
||
return "border-white/10 bg-[#262626]";
|
||
case "failed":
|
||
return "border-rose-400/20 bg-rose-400/8";
|
||
case "running":
|
||
return "border-sky-400/20 bg-sky-400/8";
|
||
case "queued":
|
||
case "thinking":
|
||
return "border-white/10 bg-[#232323]";
|
||
default:
|
||
return "border-white/10 bg-[#232323]";
|
||
}
|
||
}
|
||
|
||
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 getProviderSourceLabel(source?: AiCadProviderStatus["source"] | null) {
|
||
switch (source) {
|
||
case "provider":
|
||
return "系统设置";
|
||
case "env":
|
||
return "环境变量";
|
||
default:
|
||
return "未配置";
|
||
}
|
||
}
|
||
|
||
function readNullableText(value: unknown) {
|
||
return typeof value === "string" && value.trim() ? value : null;
|
||
}
|
||
|
||
function getDownloadEntryLabel(name: string, url: string) {
|
||
const extension = getFileExtension(name) || getFileExtension(url);
|
||
switch (extension) {
|
||
case "step":
|
||
case "stp":
|
||
return "STEP";
|
||
case "glb":
|
||
return "GLB";
|
||
case "gltf":
|
||
return "GLTF";
|
||
case "kcl":
|
||
return "KCL";
|
||
case "stl":
|
||
return "STL";
|
||
case "obj":
|
||
return "OBJ";
|
||
case "fbx":
|
||
return "FBX";
|
||
case "3mf":
|
||
return "3MF";
|
||
default:
|
||
return getDownloadLabel(url);
|
||
}
|
||
}
|
||
|
||
function buildDownloadEntries(version?: GenerationVersion | null) {
|
||
const entries = Object.entries(version?.downloadUrls || {}).filter(
|
||
(entry): entry is [string, string] =>
|
||
typeof entry[0] === "string" &&
|
||
entry[0].trim().length > 0 &&
|
||
typeof entry[1] === "string" &&
|
||
entry[1].trim().length > 0
|
||
);
|
||
|
||
if (
|
||
version?.modelUrl &&
|
||
!entries.some(
|
||
([, value]) =>
|
||
String(value || "").trim() === String(version.modelUrl || "").trim()
|
||
)
|
||
) {
|
||
entries.unshift([
|
||
`model.${getFileExtension(version.modelUrl) || "glb"}`,
|
||
version.modelUrl,
|
||
]);
|
||
}
|
||
|
||
return entries
|
||
.map(([name, url]) => ({
|
||
key: `${name}:${url}`,
|
||
name,
|
||
url,
|
||
label: getDownloadEntryLabel(name, url),
|
||
extension: getFileExtension(name) || getFileExtension(url),
|
||
}))
|
||
.sort((left, right) => {
|
||
const leftIndex = DOWNLOAD_ORDER.indexOf(
|
||
left.extension as (typeof DOWNLOAD_ORDER)[number]
|
||
);
|
||
const rightIndex = DOWNLOAD_ORDER.indexOf(
|
||
right.extension as (typeof DOWNLOAD_ORDER)[number]
|
||
);
|
||
const safeLeftIndex =
|
||
leftIndex === -1 ? DOWNLOAD_ORDER.length : leftIndex;
|
||
const safeRightIndex =
|
||
rightIndex === -1 ? DOWNLOAD_ORDER.length : rightIndex;
|
||
return (
|
||
safeLeftIndex - safeRightIndex ||
|
||
left.label.localeCompare(right.label, "zh-CN")
|
||
);
|
||
});
|
||
}
|
||
|
||
function buildVersionExecutionSummary(
|
||
version: GenerationVersion,
|
||
params?: {
|
||
parentVersion?: GenerationVersion | null;
|
||
downloadEntries?: ReturnType<typeof buildDownloadEntries>;
|
||
previewSourceVersion?: GenerationVersion | null;
|
||
}
|
||
) {
|
||
const summaryLines = [
|
||
version.generationMode === "iterate"
|
||
? `这轮采用连续修改模式,基于 ${params?.parentVersion ? `V${params.parentVersion.versionNumber}` : "上一轮 CAD 结果"} 继续推进,而不是重新从零开始。`
|
||
: "这轮采用首轮创建模式,系统会把当前自然语言描述当成一个新的 CAD 起点来组织结构。",
|
||
`当前走的是 ${getProviderLabel(version.provider)} 链路,状态为“${getStatusLabel(version.status)}”。`,
|
||
params?.downloadEntries && params.downloadEntries.length > 0
|
||
? `服务端目前已经返回这些结果:${params.downloadEntries
|
||
.slice(0, 5)
|
||
.map((entry) => entry.label)
|
||
.join(" / ")}。`
|
||
: "服务端还没有返回可下载文件,通常意味着任务还在排队、执行中,或这轮结果只回来了部分代码。",
|
||
version.codeUrl && !version.modelUrl
|
||
? "这一轮目前更偏代码结果,KCL 已可查看,但可预览的 3D 结果还没完全回到当前版本。"
|
||
: "当前版本已经具备可继续查看、下载或作为下一轮参考的结果基础。",
|
||
params?.previewSourceVersion &&
|
||
params.previewSourceVersion.id !== version.id
|
||
? `由于当前版本暂时没有新的可视化模型,右侧 3D 预览会继续借用 V${params.previewSourceVersion.versionNumber} 作为参考。`
|
||
: "右侧预览会优先展示当前版本自己的模型结果,方便你直接判断这一轮是否达到了目标。",
|
||
version.feedback === "positive"
|
||
? "你已经把这轮结果标记为“有帮助”,这有助于后续继续沿着当前方向迭代。"
|
||
: version.feedback === "negative"
|
||
? "你已经把这轮结果标记为“不理想”,后续更适合在下一轮明确指出要保留和要修正的部分。"
|
||
: "如果这轮方向对了,可以直接继续细化;如果偏了,建议下一轮明确写出“保留什么、修改什么、删除什么”。",
|
||
];
|
||
|
||
return summaryLines.join("\n\n");
|
||
}
|
||
|
||
function buildFollowUpSuggestions(params: {
|
||
activeVersion?: GenerationVersion | null;
|
||
activeParentVersion?: GenerationVersion | null;
|
||
previewSourceVersion?: GenerationVersion | null;
|
||
semanticFocus?: SemanticDiffInsight[];
|
||
}) {
|
||
const {
|
||
activeVersion,
|
||
activeParentVersion,
|
||
previewSourceVersion,
|
||
semanticFocus,
|
||
} = params;
|
||
const primarySemanticFocus = semanticFocus?.[0] ?? null;
|
||
|
||
if (!activeVersion) {
|
||
return [...SUGGESTED_PROMPTS];
|
||
}
|
||
|
||
if (activeVersion.status === "failed") {
|
||
return [
|
||
"把需求拆短一点,先只描述主体轮廓和核心结构。",
|
||
"明确写出保留什么、删除什么、替换什么,再重新生成。",
|
||
"先做一个更基础的版本,下一轮再补细节和制造约束。",
|
||
];
|
||
}
|
||
|
||
if (activeVersion.status === "queued" || activeVersion.status === "running") {
|
||
return [
|
||
"等这轮完成后,继续细化连接结构、壁厚和加强筋。",
|
||
"下一轮只改局部,保留主体轮廓,优化支撑与装配方式。",
|
||
"如果方向对了,再补充材料、公差和可制造性约束。",
|
||
];
|
||
}
|
||
|
||
if (activeVersion.feedback === "negative") {
|
||
return [
|
||
`保留 ${activeParentVersion ? `V${activeParentVersion.versionNumber}` : "上一轮"} 的整体比例,重做当前版本最突出的结构问题。`,
|
||
primarySemanticFocus
|
||
? `不要改主体外轮廓,只重新梳理${primarySemanticFocus.label},把目标值和限制条件说清楚。`
|
||
: "不要改主体外轮廓,只把局部支撑、转角和厚度重新组织。",
|
||
"按更工程化的方式重写需求:外形、连接、装配、公差分别说明。",
|
||
];
|
||
}
|
||
|
||
if (activeVersion.generationMode === "iterate") {
|
||
return [
|
||
primarySemanticFocus
|
||
? `继续沿着 V${activeVersion.versionNumber} 往下改,但这次只聚焦 ${primarySemanticFocus.label},别同时改太多区域。`
|
||
: `继续沿着 V${activeVersion.versionNumber} 往下改,但只微调局部结构和连接细节。`,
|
||
primarySemanticFocus
|
||
? `围绕 ${primarySemanticFocus.label} 继续细化,把目标数值、保留项和不能动的区域一起写清楚。`
|
||
: "保留当前整体语言,补充圆角、壁厚、拔模和装配干涉约束。",
|
||
previewSourceVersion && previewSourceVersion.id !== activeVersion.id
|
||
? `以当前代码结果为准,再让可视化结果追上 V${activeVersion.versionNumber} 的修改方向。`
|
||
: primarySemanticFocus
|
||
? SEMANTIC_DIFF_CATEGORIES[primarySemanticFocus.key]?.followUpHint ||
|
||
"不要改主体轮廓,只把当前版本做得更轻量、更容易制造。"
|
||
: "不要改主体轮廓,只把当前版本做得更轻量、更容易制造。",
|
||
];
|
||
}
|
||
|
||
return [
|
||
primarySemanticFocus
|
||
? `基于 V${activeVersion.versionNumber} 继续细化:先保留主体比例,重点收紧 ${primarySemanticFocus.label}。`
|
||
: `基于 V${activeVersion.versionNumber} 继续细化:保留主体比例,优化局部结构和连接方式。`,
|
||
"在当前版本上增加工程约束,例如壁厚、公差、卡扣和加强筋。",
|
||
primarySemanticFocus
|
||
? SEMANTIC_DIFF_CATEGORIES[primarySemanticFocus.key]?.followUpHint ||
|
||
"如果这个方向对了,下一轮可以只针对一个局部做更明确的修改说明。"
|
||
: "如果这个方向对了,下一轮可以只针对一个局部做更明确的修改说明。",
|
||
];
|
||
}
|
||
|
||
function getStructuredDirectivePlaceholder(
|
||
key: StructuredPromptDirectiveKey,
|
||
version?: GenerationVersion | null
|
||
) {
|
||
switch (key) {
|
||
case "keep":
|
||
return version
|
||
? `V${version.versionNumber} 的主体比例、核心轮廓和装配关系`
|
||
: "主体比例、核心轮廓和主要装配关系";
|
||
case "change":
|
||
return "局部支撑、转角、连接方式和需要细化的区域";
|
||
case "remove":
|
||
return "冗余装饰、过厚区域和不必要的悬挑";
|
||
case "constraints":
|
||
return "补充壁厚、公差、圆角、工艺和装配要求";
|
||
case "material":
|
||
return "说明材料、表面处理、使用场景和手感偏好";
|
||
default:
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function extractStructuredPromptDirectives(prompt: string) {
|
||
return STRUCTURED_PROMPT_DIRECTIVES.flatMap((directive) => {
|
||
const pattern = new RegExp(
|
||
`(?:^|\\n)${escapeRegExp(directive.label)}[::]\\s*([^\\n]+)`,
|
||
"m"
|
||
);
|
||
const matchedValue = prompt.match(pattern)?.[1]?.trim();
|
||
|
||
if (!matchedValue) {
|
||
return [];
|
||
}
|
||
|
||
return [
|
||
{
|
||
key: directive.key,
|
||
label: directive.label,
|
||
helper: directive.helper,
|
||
value: matchedValue,
|
||
} satisfies StructuredPromptDirective,
|
||
];
|
||
});
|
||
}
|
||
|
||
function upsertStructuredPromptDirective(
|
||
prompt: string,
|
||
directiveKey: StructuredPromptDirectiveKey,
|
||
version?: GenerationVersion | null
|
||
) {
|
||
const directive = STRUCTURED_PROMPT_DIRECTIVES.find(
|
||
(item) => item.key === directiveKey
|
||
);
|
||
|
||
if (!directive) {
|
||
return prompt;
|
||
}
|
||
|
||
const nextLine = `${directive.label}:${getStructuredDirectivePlaceholder(
|
||
directiveKey,
|
||
version
|
||
)}`;
|
||
const normalizedPrompt = prompt.trimEnd();
|
||
const hasLine = new RegExp(
|
||
`(?:^|\\n)${escapeRegExp(directive.label)}[::]\\s*`,
|
||
"m"
|
||
).test(normalizedPrompt);
|
||
|
||
if (hasLine) {
|
||
return prompt;
|
||
}
|
||
|
||
return normalizedPrompt ? `${normalizedPrompt}\n${nextLine}` : nextLine;
|
||
}
|
||
|
||
function buildStructuredPromptTemplate(
|
||
prompt: string,
|
||
version?: GenerationVersion | null
|
||
) {
|
||
return STRUCTURED_PROMPT_DIRECTIVES.reduce(
|
||
(currentPrompt, directive) =>
|
||
upsertStructuredPromptDirective(currentPrompt, directive.key, version),
|
||
prompt.trimEnd()
|
||
);
|
||
}
|
||
|
||
function buildVersionCompareNotes(params: {
|
||
activeVersion?: GenerationVersion | null;
|
||
parentVersion?: GenerationVersion | null;
|
||
previewSourceVersion?: GenerationVersion | null;
|
||
}) {
|
||
const { activeVersion, parentVersion, previewSourceVersion } = params;
|
||
|
||
if (!activeVersion) {
|
||
return [];
|
||
}
|
||
|
||
const activeDirectives = extractStructuredPromptDirectives(
|
||
activeVersion.prompt
|
||
);
|
||
const notes: string[] = [];
|
||
|
||
if (activeDirectives.length > 0) {
|
||
notes.push(
|
||
...activeDirectives.map(
|
||
(directive) => `${directive.label}:${directive.value}`
|
||
)
|
||
);
|
||
} else if (parentVersion && activeVersion.prompt !== parentVersion.prompt) {
|
||
notes.push(
|
||
`这轮需求从“${truncateText(parentVersion.prompt, 48)}”推进到了“${truncateText(activeVersion.prompt, 64)}”。`
|
||
);
|
||
} else {
|
||
notes.push(
|
||
`当前版本的核心要求是:${truncateText(activeVersion.prompt, 72)}。`
|
||
);
|
||
}
|
||
|
||
if (parentVersion && activeVersion.generationMode === "iterate") {
|
||
notes.push(
|
||
`这轮沿着 V${parentVersion.versionNumber} 继续修改,适合把关注点缩小到局部结构和制造约束。`
|
||
);
|
||
}
|
||
|
||
if (previewSourceVersion && previewSourceVersion.id !== activeVersion.id) {
|
||
notes.push(
|
||
`当前 3D 预览仍借用了 V${previewSourceVersion.versionNumber},说明这轮更偏代码或结构迭代结果。`
|
||
);
|
||
}
|
||
|
||
if (parentVersion && activeVersion.provider !== parentVersion.provider) {
|
||
notes.push(
|
||
`当前版本的生成链路已经切换到 ${getProviderLabel(activeVersion.provider)}。`
|
||
);
|
||
}
|
||
|
||
return notes.slice(0, 5);
|
||
}
|
||
|
||
function buildVersionChildrenMap(versions: GenerationVersion[]) {
|
||
const map = new Map<string, GenerationVersion[]>();
|
||
|
||
for (const version of versions) {
|
||
if (!version.parentVersionId) {
|
||
continue;
|
||
}
|
||
|
||
const currentChildren = map.get(version.parentVersionId) ?? [];
|
||
currentChildren.push(version);
|
||
map.set(
|
||
version.parentVersionId,
|
||
currentChildren.sort(
|
||
(left, right) => left.versionNumber - right.versionNumber
|
||
)
|
||
);
|
||
}
|
||
|
||
return map;
|
||
}
|
||
|
||
function buildVersionLineage(
|
||
version: GenerationVersion | null,
|
||
versions: GenerationVersion[]
|
||
) {
|
||
if (!version) {
|
||
return [];
|
||
}
|
||
|
||
const lineage: GenerationVersion[] = [];
|
||
const visitedVersionIds = new Set<string>();
|
||
let currentVersion: GenerationVersion | null = version;
|
||
|
||
while (currentVersion) {
|
||
if (visitedVersionIds.has(currentVersion.id)) {
|
||
break;
|
||
}
|
||
|
||
visitedVersionIds.add(currentVersion.id);
|
||
lineage.unshift(currentVersion);
|
||
currentVersion = currentVersion.parentVersionId
|
||
? findVersionById(versions, currentVersion.parentVersionId)
|
||
: null;
|
||
}
|
||
|
||
return lineage;
|
||
}
|
||
|
||
function getVersionDepth(
|
||
version: GenerationVersion,
|
||
versions: GenerationVersion[]
|
||
) {
|
||
let depth = 0;
|
||
let currentParentId = version.parentVersionId;
|
||
|
||
while (currentParentId) {
|
||
depth += 1;
|
||
currentParentId =
|
||
findVersionById(versions, currentParentId)?.parentVersionId ?? null;
|
||
}
|
||
|
||
return depth;
|
||
}
|
||
|
||
function getStatusDotClass(status: Model3DTaskStatus, isActive: boolean) {
|
||
if (isActive) {
|
||
return "bg-white";
|
||
}
|
||
|
||
switch (status) {
|
||
case "success":
|
||
return "bg-emerald-300";
|
||
case "running":
|
||
return "bg-sky-300";
|
||
case "queued":
|
||
return "bg-amber-300";
|
||
case "failed":
|
||
case "banned":
|
||
case "cancelled":
|
||
case "expired":
|
||
return "bg-rose-300";
|
||
default:
|
||
return "bg-white/35";
|
||
}
|
||
}
|
||
|
||
function getSiblingVersionCount(
|
||
version: GenerationVersion,
|
||
versions: GenerationVersion[]
|
||
) {
|
||
return versions.filter(
|
||
(candidate) => candidate.parentVersionId === version.parentVersionId
|
||
).length;
|
||
}
|
||
|
||
function buildCompareCandidateVersions(
|
||
activeVersion: GenerationVersion | null,
|
||
versions: GenerationVersion[]
|
||
) {
|
||
if (!activeVersion) {
|
||
return [];
|
||
}
|
||
|
||
const candidateVersions: GenerationVersion[] = [];
|
||
const seenVersionIds = new Set<string>([activeVersion.id]);
|
||
const pushCandidate = (version: GenerationVersion | null) => {
|
||
if (!version || seenVersionIds.has(version.id)) {
|
||
return;
|
||
}
|
||
|
||
seenVersionIds.add(version.id);
|
||
candidateVersions.push(version);
|
||
};
|
||
|
||
pushCandidate(
|
||
activeVersion.parentVersionId
|
||
? findVersionById(versions, activeVersion.parentVersionId)
|
||
: null
|
||
);
|
||
|
||
const siblingCandidates = versions
|
||
.filter(
|
||
(version) =>
|
||
version.parentVersionId === activeVersion.parentVersionId &&
|
||
version.id !== activeVersion.id
|
||
)
|
||
.sort((left, right) => right.versionNumber - left.versionNumber);
|
||
for (const version of siblingCandidates) {
|
||
pushCandidate(version);
|
||
}
|
||
|
||
const childCandidates = versions
|
||
.filter((version) => version.parentVersionId === activeVersion.id)
|
||
.sort((left, right) => right.versionNumber - left.versionNumber);
|
||
for (const version of childCandidates) {
|
||
pushCandidate(version);
|
||
}
|
||
|
||
const lineageCandidates = buildVersionLineage(activeVersion, versions)
|
||
.slice(0, -1)
|
||
.reverse();
|
||
for (const version of lineageCandidates) {
|
||
pushCandidate(version);
|
||
}
|
||
|
||
const fallbackCandidates = versions
|
||
.slice()
|
||
.sort((left, right) => right.versionNumber - left.versionNumber);
|
||
for (const version of fallbackCandidates) {
|
||
pushCandidate(version);
|
||
}
|
||
|
||
return candidateVersions;
|
||
}
|
||
|
||
function buildVersionDiffHighlights(params: {
|
||
activeVersion?: GenerationVersion | null;
|
||
compareVersion?: GenerationVersion | null;
|
||
comparePreviewSourceVersion?: GenerationVersion | null;
|
||
previewSourceVersion?: GenerationVersion | null;
|
||
}) {
|
||
const {
|
||
activeVersion,
|
||
compareVersion,
|
||
comparePreviewSourceVersion,
|
||
previewSourceVersion,
|
||
} = params;
|
||
|
||
if (!activeVersion || !compareVersion) {
|
||
return [];
|
||
}
|
||
|
||
const activeDirectives = extractStructuredPromptDirectives(
|
||
activeVersion.prompt
|
||
);
|
||
const compareDirectives = extractStructuredPromptDirectives(
|
||
compareVersion.prompt
|
||
);
|
||
const compareDirectiveMap = new Map(
|
||
compareDirectives.map((directive) => [directive.key, directive])
|
||
);
|
||
const activeDirectiveMap = new Map(
|
||
activeDirectives.map((directive) => [directive.key, directive])
|
||
);
|
||
const highlights: string[] = [];
|
||
|
||
for (const directive of activeDirectives) {
|
||
const matchedDirective = compareDirectiveMap.get(directive.key);
|
||
if (!matchedDirective) {
|
||
highlights.push(
|
||
`当前版本新增了“${directive.label}”要求:${truncateText(directive.value, 40)}。`
|
||
);
|
||
continue;
|
||
}
|
||
|
||
if (matchedDirective.value !== directive.value) {
|
||
highlights.push(
|
||
`“${directive.label}”从“${truncateText(matchedDirective.value, 24)}”调整为“${truncateText(directive.value, 24)}”。`
|
||
);
|
||
}
|
||
}
|
||
|
||
for (const directive of compareDirectives) {
|
||
if (activeDirectiveMap.has(directive.key)) {
|
||
continue;
|
||
}
|
||
|
||
highlights.push(
|
||
`相较于 V${compareVersion.versionNumber},当前版本不再显式强调“${directive.label}”。`
|
||
);
|
||
}
|
||
|
||
if (
|
||
highlights.length === 0 &&
|
||
activeVersion.prompt !== compareVersion.prompt
|
||
) {
|
||
highlights.push(
|
||
`这轮描述从“${truncateText(compareVersion.prompt, 36)}”推进到了“${truncateText(activeVersion.prompt, 52)}”。`
|
||
);
|
||
}
|
||
|
||
if (
|
||
comparePreviewSourceVersion &&
|
||
comparePreviewSourceVersion.id !== compareVersion.id
|
||
) {
|
||
highlights.push(
|
||
`对照版本的 3D 预览当前借用了 V${comparePreviewSourceVersion.versionNumber}。`
|
||
);
|
||
}
|
||
|
||
if (previewSourceVersion && previewSourceVersion.id !== activeVersion.id) {
|
||
highlights.push(
|
||
`当前版本的 3D 预览暂时借用了 V${previewSourceVersion.versionNumber}。`
|
||
);
|
||
}
|
||
|
||
if (activeVersion.provider !== compareVersion.provider) {
|
||
highlights.push(
|
||
`两版使用的生成链路不同:当前为 ${getProviderLabel(activeVersion.provider)},对照为 ${getProviderLabel(compareVersion.provider)}。`
|
||
);
|
||
}
|
||
|
||
return highlights.slice(0, 5);
|
||
}
|
||
|
||
function splitCodeLines(code: string) {
|
||
if (!code.trim()) {
|
||
return [];
|
||
}
|
||
|
||
return code.replace(/\r\n/g, "\n").split("\n");
|
||
}
|
||
|
||
function buildCodeDiffRows(leftCode: string, rightCode: string) {
|
||
const leftLines = splitCodeLines(leftCode);
|
||
const rightLines = splitCodeLines(rightCode);
|
||
const matrix = Array.from({ length: leftLines.length + 1 }, () =>
|
||
Array.from({ length: rightLines.length + 1 }, () => 0)
|
||
);
|
||
|
||
for (let leftIndex = leftLines.length - 1; leftIndex >= 0; leftIndex -= 1) {
|
||
for (
|
||
let rightIndex = rightLines.length - 1;
|
||
rightIndex >= 0;
|
||
rightIndex -= 1
|
||
) {
|
||
matrix[leftIndex][rightIndex] =
|
||
leftLines[leftIndex] === rightLines[rightIndex]
|
||
? matrix[leftIndex + 1][rightIndex + 1] + 1
|
||
: Math.max(
|
||
matrix[leftIndex + 1][rightIndex],
|
||
matrix[leftIndex][rightIndex + 1]
|
||
);
|
||
}
|
||
}
|
||
|
||
const rawRows: Pick<
|
||
CodeDiffRow,
|
||
"kind" | "leftLineNumber" | "leftText" | "rightLineNumber" | "rightText"
|
||
>[] = [];
|
||
let leftIndex = 0;
|
||
let rightIndex = 0;
|
||
|
||
while (leftIndex < leftLines.length && rightIndex < rightLines.length) {
|
||
if (leftLines[leftIndex] === rightLines[rightIndex]) {
|
||
rawRows.push({
|
||
kind: "equal",
|
||
leftLineNumber: leftIndex + 1,
|
||
leftText: leftLines[leftIndex],
|
||
rightLineNumber: rightIndex + 1,
|
||
rightText: rightLines[rightIndex],
|
||
});
|
||
leftIndex += 1;
|
||
rightIndex += 1;
|
||
continue;
|
||
}
|
||
|
||
if (
|
||
matrix[leftIndex + 1][rightIndex] >= matrix[leftIndex][rightIndex + 1]
|
||
) {
|
||
rawRows.push({
|
||
kind: "removed",
|
||
leftLineNumber: leftIndex + 1,
|
||
leftText: leftLines[leftIndex],
|
||
rightLineNumber: null,
|
||
rightText: "",
|
||
});
|
||
leftIndex += 1;
|
||
continue;
|
||
}
|
||
|
||
rawRows.push({
|
||
kind: "added",
|
||
leftLineNumber: null,
|
||
leftText: "",
|
||
rightLineNumber: rightIndex + 1,
|
||
rightText: rightLines[rightIndex],
|
||
});
|
||
rightIndex += 1;
|
||
}
|
||
|
||
while (leftIndex < leftLines.length) {
|
||
rawRows.push({
|
||
kind: "removed",
|
||
leftLineNumber: leftIndex + 1,
|
||
leftText: leftLines[leftIndex],
|
||
rightLineNumber: null,
|
||
rightText: "",
|
||
});
|
||
leftIndex += 1;
|
||
}
|
||
|
||
while (rightIndex < rightLines.length) {
|
||
rawRows.push({
|
||
kind: "added",
|
||
leftLineNumber: null,
|
||
leftText: "",
|
||
rightLineNumber: rightIndex + 1,
|
||
rightText: rightLines[rightIndex],
|
||
});
|
||
rightIndex += 1;
|
||
}
|
||
|
||
const rows: CodeDiffRow[] = [];
|
||
const summary = {
|
||
added: 0,
|
||
changed: 0,
|
||
removed: 0,
|
||
unchanged: 0,
|
||
};
|
||
|
||
let index = 0;
|
||
while (index < rawRows.length) {
|
||
const currentRow = rawRows[index];
|
||
|
||
if (currentRow.kind === "equal") {
|
||
rows.push(currentRow);
|
||
summary.unchanged += 1;
|
||
index += 1;
|
||
continue;
|
||
}
|
||
|
||
const removedRows: typeof rawRows = [];
|
||
const addedRows: typeof rawRows = [];
|
||
|
||
while (index < rawRows.length && rawRows[index]?.kind !== "equal") {
|
||
const row = rawRows[index];
|
||
if (row?.kind === "removed") {
|
||
removedRows.push(row);
|
||
} else if (row?.kind === "added") {
|
||
addedRows.push(row);
|
||
}
|
||
index += 1;
|
||
}
|
||
|
||
const sharedLength = Math.min(removedRows.length, addedRows.length);
|
||
for (let pairIndex = 0; pairIndex < sharedLength; pairIndex += 1) {
|
||
rows.push({
|
||
kind: "changed",
|
||
leftLineNumber: removedRows[pairIndex]?.leftLineNumber ?? null,
|
||
leftText: removedRows[pairIndex]?.leftText ?? "",
|
||
rightLineNumber: addedRows[pairIndex]?.rightLineNumber ?? null,
|
||
rightText: addedRows[pairIndex]?.rightText ?? "",
|
||
});
|
||
summary.changed += 1;
|
||
}
|
||
|
||
for (const row of removedRows.slice(sharedLength)) {
|
||
rows.push(row);
|
||
summary.removed += 1;
|
||
}
|
||
|
||
for (const row of addedRows.slice(sharedLength)) {
|
||
rows.push(row);
|
||
summary.added += 1;
|
||
}
|
||
}
|
||
|
||
return { rows, summary };
|
||
}
|
||
|
||
function buildCodeDiffInsights(rows: CodeDiffRow[]) {
|
||
const insights: string[] = [];
|
||
let index = 0;
|
||
|
||
while (index < rows.length && insights.length < 4) {
|
||
if (rows[index]?.kind === "equal") {
|
||
index += 1;
|
||
continue;
|
||
}
|
||
|
||
const hunkRows: CodeDiffRow[] = [];
|
||
while (index < rows.length && rows[index]?.kind !== "equal") {
|
||
const row = rows[index];
|
||
if (row) {
|
||
hunkRows.push(row);
|
||
}
|
||
index += 1;
|
||
}
|
||
|
||
if (hunkRows.length === 0) {
|
||
continue;
|
||
}
|
||
|
||
const addedCount = hunkRows.filter((row) => row.kind === "added").length;
|
||
const removedCount = hunkRows.filter(
|
||
(row) => row.kind === "removed"
|
||
).length;
|
||
const changedCount = hunkRows.filter(
|
||
(row) => row.kind === "changed"
|
||
).length;
|
||
const firstLeftLine = hunkRows.find(
|
||
(row) => row.leftLineNumber
|
||
)?.leftLineNumber;
|
||
const firstRightLine = hunkRows.find(
|
||
(row) => row.rightLineNumber
|
||
)?.rightLineNumber;
|
||
const focusSnippet = truncateText(
|
||
hunkRows.find((row) => row.rightText.trim())?.rightText ||
|
||
hunkRows.find((row) => row.leftText.trim())?.leftText ||
|
||
"",
|
||
44
|
||
);
|
||
const locationLabel =
|
||
firstRightLine || firstLeftLine
|
||
? `第 ${firstRightLine ?? firstLeftLine} 行附近`
|
||
: "当前代码块中";
|
||
const summaryParts = [
|
||
changedCount > 0 ? `改写 ${changedCount} 行` : null,
|
||
addedCount > 0 ? `新增 ${addedCount} 行` : null,
|
||
removedCount > 0 ? `删除 ${removedCount} 行` : null,
|
||
].filter(Boolean);
|
||
|
||
insights.push(
|
||
`${locationLabel}${summaryParts.length > 0 ? `主要${summaryParts.join(",")}` : "发生了调整"},重点片段:${focusSnippet}。`
|
||
);
|
||
}
|
||
|
||
return insights;
|
||
}
|
||
|
||
function summarizeSemanticDiffCount(matchCount: number) {
|
||
if (matchCount >= 6) {
|
||
return "高频改动";
|
||
}
|
||
if (matchCount >= 3) {
|
||
return "连续改动";
|
||
}
|
||
return "局部改动";
|
||
}
|
||
|
||
function collectSemanticInsightsFromTexts(
|
||
texts: string[],
|
||
options?: {
|
||
limit?: number;
|
||
}
|
||
) {
|
||
const matches = new Map<
|
||
string,
|
||
{
|
||
count: number;
|
||
examples: string[];
|
||
}
|
||
>();
|
||
|
||
for (const rawText of texts) {
|
||
const text = rawText.trim();
|
||
if (!text) {
|
||
continue;
|
||
}
|
||
|
||
for (const [key, category] of Object.entries(SEMANTIC_DIFF_CATEGORIES)) {
|
||
const hasMatchedKeyword = category.keywords.some((keyword) =>
|
||
keyword.test(text)
|
||
);
|
||
if (!hasMatchedKeyword) {
|
||
continue;
|
||
}
|
||
|
||
const current = matches.get(key) || {
|
||
count: 0,
|
||
examples: [],
|
||
};
|
||
current.count += 1;
|
||
if (current.examples.length < 2 && !current.examples.includes(text)) {
|
||
current.examples.push(truncateText(text, 60));
|
||
}
|
||
matches.set(key, current);
|
||
}
|
||
}
|
||
|
||
return Array.from(matches.entries())
|
||
.map(([key, match]) => {
|
||
const category = SEMANTIC_DIFF_CATEGORIES[key];
|
||
if (!category) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
count: match.count,
|
||
detail: `${summarizeSemanticDiffCount(match.count)},${category.description}`,
|
||
examples: match.examples,
|
||
key,
|
||
label: category.label,
|
||
shortLabel: category.shortLabel,
|
||
} satisfies SemanticDiffInsight;
|
||
})
|
||
.filter((insight): insight is SemanticDiffInsight => Boolean(insight))
|
||
.sort((left, right) => right.count - left.count)
|
||
.slice(0, options?.limit ?? 4);
|
||
}
|
||
|
||
function buildSemanticDiffInsights(rows: CodeDiffRow[]) {
|
||
const diffTexts = rows.flatMap((row) =>
|
||
row.kind === "equal"
|
||
? []
|
||
: [row.leftText, row.rightText]
|
||
.map((value) => value.trim())
|
||
.filter(Boolean)
|
||
);
|
||
|
||
return collectSemanticInsightsFromTexts(diffTexts);
|
||
}
|
||
|
||
function buildVersionSemanticInsights(version?: GenerationVersion | null) {
|
||
if (!version) {
|
||
return [];
|
||
}
|
||
|
||
const structuredTexts = extractStructuredPromptDirectives(version.prompt).map(
|
||
(directive) => `${directive.label}:${directive.value}`
|
||
);
|
||
|
||
return collectSemanticInsightsFromTexts(
|
||
[version.prompt, ...structuredTexts],
|
||
{ limit: 3 }
|
||
);
|
||
}
|
||
|
||
function getCodeDiffRowClass(kind: CodeDiffKind, side: "left" | "right") {
|
||
if (kind === "equal") {
|
||
return "bg-transparent";
|
||
}
|
||
|
||
if (kind === "changed") {
|
||
return "bg-amber-400/10";
|
||
}
|
||
|
||
if (kind === "removed") {
|
||
return side === "left" ? "bg-rose-400/12" : "bg-rose-400/6";
|
||
}
|
||
|
||
return side === "right" ? "bg-emerald-400/12" : "bg-emerald-400/6";
|
||
}
|
||
|
||
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-[10px] border border-white/10 bg-[#071019]">
|
||
<div className="flex flex-wrap items-center justify-between gap-3 border-white/10 border-b bg-white/5 px-4 py-3">
|
||
<div>
|
||
<div className="font-medium text-sm text-white">{title}</div>
|
||
<p className="mt-1 text-white/45 text-xs">{subtitle}</p>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{code ? (
|
||
<Button
|
||
className="gap-2 rounded-full border-white/10 bg-transparent text-white hover:bg-white/8"
|
||
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-white/10 bg-transparent text-white hover:bg-white/8"
|
||
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-[8px] border border-white/10 bg-black/20 text-white/60">
|
||
<div className="flex items-center gap-2 text-sm">
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
正在加载代码差异...
|
||
</div>
|
||
</div>
|
||
) : error ? (
|
||
<div className="rounded-[8px] border border-rose-400/20 bg-rose-400/10 px-4 py-5 text-rose-100 text-sm leading-6">
|
||
{error}
|
||
</div>
|
||
) : rows.length === 0 ? (
|
||
<div className="flex min-h-[280px] items-center justify-center rounded-[8px] border border-white/10 border-dashed bg-black/20 px-5 py-10 text-center text-sm text-white/45 leading-6">
|
||
{rowsEmptyMessage || "当前过滤条件下没有可显示的差异行。"}
|
||
</div>
|
||
) : code ? (
|
||
<div className="overflow-hidden rounded-[8px] border border-white/10 bg-black/30">
|
||
{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-white/5 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-white/28">
|
||
{lineNumber ?? ""}
|
||
</div>
|
||
<pre
|
||
className={cn(
|
||
"whitespace-pre-wrap break-words text-white/84",
|
||
isPlaceholder && "text-white/18 italic"
|
||
)}
|
||
>
|
||
{isPlaceholder ? " " : lineText || " "}
|
||
</pre>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="flex min-h-[280px] items-center justify-center rounded-[8px] border border-white/10 border-dashed bg-black/20 px-5 py-10 text-center text-sm text-white/45 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() {
|
||
return (
|
||
<PanelResizeHandle className="group relative flex shrink-0 items-center justify-center bg-transparent outline-none data-[panel-group-direction=horizontal]:mx-1.5 data-[panel-group-direction=vertical]:my-1.5 data-[panel-group-direction=vertical]:h-2 data-[panel-group-direction=horizontal]:w-2 data-[panel-group-direction=horizontal]:cursor-col-resize data-[panel-group-direction=vertical]:cursor-row-resize">
|
||
<div className="rounded-full bg-white/10 transition group-hover:bg-white/30 data-[panel-group-direction=horizontal]:h-16 data-[panel-group-direction=vertical]:h-[2px] data-[panel-group-direction=horizontal]:w-[2px] data-[panel-group-direction=vertical]:w-16 group-data-[resize-handle-state=drag]:bg-white/40" />
|
||
</PanelResizeHandle>
|
||
);
|
||
}
|
||
|
||
function AiCadCollapsedPanel({
|
||
direction,
|
||
icon: Icon,
|
||
onExpand,
|
||
title,
|
||
}: {
|
||
direction: "horizontal" | "vertical";
|
||
icon: typeof BrainCircuit;
|
||
onExpand: () => void;
|
||
title: string;
|
||
}) {
|
||
return (
|
||
<div className="h-full min-h-0">
|
||
<div
|
||
className={cn(
|
||
"flex h-full rounded-[8px] border border-white/10 bg-[#1d1d1d] text-white shadow-none",
|
||
direction === "horizontal"
|
||
? "flex-col items-center justify-center gap-3 px-3 py-5"
|
||
: "items-center justify-between gap-4 px-4 py-3"
|
||
)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"flex items-center gap-2",
|
||
direction === "horizontal" && "flex-col text-center"
|
||
)}
|
||
>
|
||
<Icon className="h-5 w-5 text-white/72" />
|
||
<div className="font-medium text-sm text-white/85">{title}</div>
|
||
</div>
|
||
<Button
|
||
className="gap-2 rounded-[8px] border-white/10 bg-white/6 text-white transition-all duration-150 ease-out hover:border-white/15 hover:bg-white/10"
|
||
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-white/0 transition-all duration-150 ease-out",
|
||
active && "bg-white/65 shadow-[0_0_8px_rgba(255,255,255,0.18)]"
|
||
)}
|
||
/>
|
||
<button
|
||
className={cn(
|
||
"flex w-10 flex-col items-center gap-1 rounded-[8px] border px-0 py-2 transition-all duration-150 ease-out",
|
||
active
|
||
? "border-white/20 bg-[#2a2a2a] text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.05),0_6px_18px_rgba(0,0,0,0.18)]"
|
||
: "border-white/10 bg-transparent text-white/55 hover:border-white/15 hover:bg-white/6 hover:text-white"
|
||
)}
|
||
onClick={onClick}
|
||
title={label}
|
||
type="button"
|
||
>
|
||
<Icon className="h-4 w-4" />
|
||
<span className="font-medium text-[9px] uppercase leading-none tracking-[0.18em]">
|
||
{shortLabel}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AiCadToolbarGroup({
|
||
children,
|
||
className,
|
||
label,
|
||
}: {
|
||
children: ReactNode;
|
||
className?: string;
|
||
label: string;
|
||
}) {
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"flex flex-wrap items-center gap-2 rounded-[8px] border border-white/10 bg-[#171717] px-2 py-1.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]",
|
||
className
|
||
)}
|
||
>
|
||
<span className="pl-1 text-[10px] text-white/35 uppercase tracking-[0.18em]">
|
||
{label}
|
||
</span>
|
||
<Separator className="h-5 bg-white/10" orientation="vertical" />
|
||
<div className="flex flex-wrap items-center gap-1.5">{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AiCadMessageActionButton({
|
||
icon: Icon,
|
||
label,
|
||
onClick,
|
||
}: {
|
||
icon: typeof BrainCircuit;
|
||
label: string;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
className="inline-flex items-center gap-1.5 rounded-[8px] border border-white/10 bg-[#1b1b1b] px-2.5 py-1 text-[10px] text-white/65 transition-all duration-150 ease-out hover:border-white/15 hover:bg-[#262626] hover:text-white"
|
||
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-400/20 bg-emerald-400/10",
|
||
tone === "pending" && "border-sky-400/20 bg-sky-400/10",
|
||
tone === "warning" && "border-amber-400/20 bg-amber-400/10",
|
||
tone === "neutral" && "border-white/10 bg-[#171717]"
|
||
)}
|
||
>
|
||
<span className="text-[10px] text-white/38 uppercase tracking-[0.18em]">
|
||
{label}
|
||
</span>
|
||
<span
|
||
className={cn(
|
||
"font-medium text-[11px]",
|
||
tone === "success" && "text-emerald-100",
|
||
tone === "pending" && "text-sky-100",
|
||
tone === "warning" && "text-amber-100",
|
||
tone === "neutral" && "text-white/72"
|
||
)}
|
||
>
|
||
{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 [isLoadingKittycadProviderStatus, 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 [activeResultTab, setActiveResultTab] = useState<"preview" | "code">(
|
||
"preview"
|
||
);
|
||
const [compareVersionId, setCompareVersionId] = useState<string | null>(null);
|
||
const [showOnlyChangedCodeRows, setShowOnlyChangedCodeRows] = useState(false);
|
||
const [cachedCodeByUrl, setCachedCodeByUrl] = useState<
|
||
Record<string, string>
|
||
>({});
|
||
const [loadingCodePreviewByUrl, setLoadingCodePreviewByUrl] = useState<
|
||
Record<string, boolean>
|
||
>({});
|
||
const [codePreviewErrorByUrl, setCodePreviewErrorByUrl] = useState<
|
||
Record<string, string | null>
|
||
>({});
|
||
const [codeActionError, setCodeActionError] = useState<string | null>(null);
|
||
const [messages, setMessages] = useState<ChatMessage[]>(
|
||
createWelcomeMessages
|
||
);
|
||
const [versions, setVersions] = useState<GenerationVersion[]>([]);
|
||
const [activeVersionId, setActiveVersionId] = useState<string | null>(null);
|
||
const [expandedVersionDetails, setExpandedVersionDetails] = useState<
|
||
Record<string, boolean>
|
||
>({});
|
||
const [workspaces, setWorkspaces] = useState<WorkspaceListItem[]>([]);
|
||
const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(
|
||
null
|
||
);
|
||
const [workspaceNameDraft, setWorkspaceNameDraft] = useState("");
|
||
const [isLoadingWorkspaceList, setIsLoadingWorkspaceList] = useState(true);
|
||
const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false);
|
||
const [isDeletingWorkspace, setIsDeletingWorkspace] = useState(false);
|
||
const [workspaceOperationError, setWorkspaceOperationError] = useState<
|
||
string | null
|
||
>(null);
|
||
const [hasHydratedFromStorage, setHasHydratedFromStorage] = useState(false);
|
||
const [hasHydratedServerWorkspace, setHasHydratedServerWorkspace] =
|
||
useState(false);
|
||
const [workspaceSyncState, setWorkspaceSyncState] = useState<
|
||
"loading" | "syncing" | "synced" | "error"
|
||
>("loading");
|
||
const [workspaceSyncError, setWorkspaceSyncError] = useState<string | null>(
|
||
null
|
||
);
|
||
const versionsRef = useRef<GenerationVersion[]>(versions);
|
||
const activeWorkspaceIdRef = useRef<string | null>(null);
|
||
const conversationPanelRef = useRef<ImperativePanelHandle | null>(null);
|
||
const historyPanelRef = useRef<ImperativePanelHandle | null>(null);
|
||
const resultPanelRef = useRef<ImperativePanelHandle | null>(null);
|
||
const localSavedAtRef = useRef<Record<string, number>>({});
|
||
const lastServerSavedSnapshotRef = useRef<Record<string, string | null>>({});
|
||
const hasLocalEditsBeforeServerHydrationRef = useRef<Record<string, boolean>>(
|
||
{}
|
||
);
|
||
const isApplyingExternalWorkspaceRef = useRef(false);
|
||
|
||
useEffect(() => {
|
||
versionsRef.current = versions;
|
||
}, [versions]);
|
||
|
||
useEffect(() => {
|
||
if (typeof window === "undefined") {
|
||
return;
|
||
}
|
||
|
||
const mediaQuery = window.matchMedia("(min-width: 1024px)");
|
||
const syncPanelDirection = () => {
|
||
setPanelDirection(mediaQuery.matches ? "horizontal" : "vertical");
|
||
};
|
||
|
||
syncPanelDirection();
|
||
|
||
if (typeof mediaQuery.addEventListener === "function") {
|
||
mediaQuery.addEventListener("change", syncPanelDirection);
|
||
return () => {
|
||
mediaQuery.removeEventListener("change", syncPanelDirection);
|
||
};
|
||
}
|
||
|
||
mediaQuery.addListener(syncPanelDirection);
|
||
return () => {
|
||
mediaQuery.removeListener(syncPanelDirection);
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
|
||
const loadWorkspaces = async () => {
|
||
setIsLoadingWorkspaceList(true);
|
||
setWorkspaceSyncState("loading");
|
||
setWorkspaceSyncError(null);
|
||
setWorkspaceOperationError(null);
|
||
|
||
const preferredWorkspaceId = readPersistedActiveWorkspaceId();
|
||
const result = await aiCadGenerationService.listWorkspaces();
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
|
||
if (!result.success) {
|
||
setWorkspaceSyncState("error");
|
||
setWorkspaceSyncError(result.error || "读取工作台列表失败");
|
||
setWorkspaceOperationError(result.error || "读取工作台列表失败");
|
||
setIsLoadingWorkspaceList(false);
|
||
return;
|
||
}
|
||
|
||
let nextWorkspaces = result.data || [];
|
||
|
||
if (nextWorkspaces.length === 0) {
|
||
const createdResult = await aiCadGenerationService.createWorkspace();
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
|
||
if (!createdResult.success || !createdResult.data) {
|
||
setWorkspaceSyncState("error");
|
||
setWorkspaceSyncError(createdResult.error || "初始化默认工作台失败");
|
||
setWorkspaceOperationError(
|
||
createdResult.error || "初始化默认工作台失败"
|
||
);
|
||
setIsLoadingWorkspaceList(false);
|
||
return;
|
||
}
|
||
|
||
nextWorkspaces = [createdResult.data];
|
||
}
|
||
|
||
const sortedWorkspaces = sortWorkspaceList(nextWorkspaces);
|
||
setWorkspaces(sortedWorkspaces);
|
||
setActiveWorkspaceId(
|
||
sortedWorkspaces.find(
|
||
(workspace) => workspace.id === preferredWorkspaceId
|
||
)?.id ||
|
||
sortedWorkspaces[0]?.id ||
|
||
null
|
||
);
|
||
setIsLoadingWorkspaceList(false);
|
||
};
|
||
|
||
swallowAsync(loadWorkspaces());
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
activeWorkspaceIdRef.current = activeWorkspaceId;
|
||
}, [activeWorkspaceId]);
|
||
|
||
useEffect(() => {
|
||
writePersistedActiveWorkspaceId(activeWorkspaceId);
|
||
}, [activeWorkspaceId]);
|
||
|
||
useEffect(() => {
|
||
if (!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 willSubmitAsIteration = Boolean(iterationBaseVersion);
|
||
const activeDownloadEntries = buildDownloadEntries(activeVersion);
|
||
const primaryDownloadEntry = activeDownloadEntries[0] ?? null;
|
||
const activeCodeEntry =
|
||
activeDownloadEntries.find((entry) => entry.extension === "kcl") ?? null;
|
||
const activeCodeUrl = activeVersion?.codeUrl || activeCodeEntry?.url || null;
|
||
const activeCodeContent = activeCodeUrl
|
||
? cachedCodeByUrl[activeCodeUrl]
|
||
: null;
|
||
const isLoadingActiveCodePreview = activeCodeUrl
|
||
? Boolean(loadingCodePreviewByUrl[activeCodeUrl])
|
||
: false;
|
||
const activeCodePreviewError = activeCodeUrl
|
||
? codePreviewErrorByUrl[activeCodeUrl] || null
|
||
: null;
|
||
const activeExecutionSummary = activeVersion
|
||
? buildVersionExecutionSummary(activeVersion, {
|
||
parentVersion: activeParentVersion,
|
||
downloadEntries: activeDownloadEntries,
|
||
previewSourceVersion,
|
||
})
|
||
: null;
|
||
const activeVersionStructuredDirectives = activeVersion
|
||
? extractStructuredPromptDirectives(activeVersion.prompt)
|
||
: [];
|
||
const promptStructuredDirectives = extractStructuredPromptDirectives(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 isWorkspaceActionDisabled =
|
||
isSubmitting || isCreatingWorkspace || isDeletingWorkspace;
|
||
const canDeleteActiveWorkspace =
|
||
Boolean(activeWorkspaceId) &&
|
||
!hasPendingTasks &&
|
||
!isWorkspaceActionDisabled &&
|
||
!isLoadingWorkspaceList;
|
||
const isKittycadConfigured = kittycadProviderStatus?.configured !== false;
|
||
const isSubmitDisabled =
|
||
!activeWorkspaceId ||
|
||
isLoadingWorkspaceList ||
|
||
!prompt.trim() ||
|
||
isSubmitting ||
|
||
(generationProvider === "kittycad-cad" && !isKittycadConfigured);
|
||
const promptCharacterCount = prompt.trim().length;
|
||
const promptLineCount = prompt.trim()
|
||
? prompt.trim().split(NEWLINE_SPLIT_RE).length
|
||
: 0;
|
||
|
||
function togglePanel(
|
||
panelRef: { current: ImperativePanelHandle | null },
|
||
isCollapsed: boolean
|
||
) {
|
||
if (isCollapsed) {
|
||
panelRef.current?.expand();
|
||
return;
|
||
}
|
||
|
||
panelRef.current?.collapse();
|
||
}
|
||
|
||
function renderPanelToggleIcon(isCollapsed: boolean) {
|
||
if (panelDirection === "horizontal") {
|
||
return isCollapsed ? (
|
||
<ChevronRight className="h-4 w-4" />
|
||
) : (
|
||
<ChevronLeft className="h-4 w-4" />
|
||
);
|
||
}
|
||
|
||
return isCollapsed ? (
|
||
<ChevronDown className="h-4 w-4" />
|
||
) : (
|
||
<ChevronUp className="h-4 w-4" />
|
||
);
|
||
}
|
||
|
||
const serializedWorkbenchSnapshot = serializeWorkbenchState({
|
||
prompt,
|
||
generationProvider,
|
||
modelVersion,
|
||
viewerSettings,
|
||
messages,
|
||
versions,
|
||
activeVersionId,
|
||
});
|
||
|
||
useEffect(() => {
|
||
setWorkspaceNameDraft(activeWorkspace?.name || "");
|
||
}, [activeWorkspace?.name]);
|
||
|
||
useEffect(() => {
|
||
if (!activeVersionId) {
|
||
return;
|
||
}
|
||
|
||
setExpandedVersionDetails((current) =>
|
||
current[activeVersionId]
|
||
? current
|
||
: {
|
||
...current,
|
||
[activeVersionId]: true,
|
||
}
|
||
);
|
||
}, [activeVersionId]);
|
||
|
||
useEffect(() => {
|
||
if (!activeVersion) {
|
||
setCompareVersionId(null);
|
||
return;
|
||
}
|
||
|
||
const compareCandidateIds = new Set(
|
||
compareCandidateVersions.map((version) => version.id)
|
||
);
|
||
|
||
setCompareVersionId((current) => {
|
||
if (current && compareCandidateIds.has(current)) {
|
||
return current;
|
||
}
|
||
|
||
return compareCandidateVersions[0]?.id ?? null;
|
||
});
|
||
}, [activeVersion, compareCandidateVersions]);
|
||
|
||
useEffect(() => {
|
||
if (!compareVersion) {
|
||
setShowOnlyChangedCodeRows(false);
|
||
}
|
||
}, [compareVersion]);
|
||
|
||
useEffect(() => {
|
||
const codeUrlsToFetch = Array.from(
|
||
new Set(
|
||
[activeCodeUrl, compareCodeUrl].filter(
|
||
(url): url is string =>
|
||
typeof url === "string" && url.trim().length > 0
|
||
)
|
||
)
|
||
).filter((url) => !cachedCodeByUrl[url] && !loadingCodePreviewByUrl[url]);
|
||
|
||
if (codeUrlsToFetch.length === 0) {
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
|
||
setLoadingCodePreviewByUrl((current) => ({
|
||
...current,
|
||
...Object.fromEntries(codeUrlsToFetch.map((url) => [url, true])),
|
||
}));
|
||
setCodePreviewErrorByUrl((current) => ({
|
||
...current,
|
||
...Object.fromEntries(codeUrlsToFetch.map((url) => [url, null])),
|
||
}));
|
||
|
||
swallowAsync(
|
||
(async () => {
|
||
await Promise.all(
|
||
codeUrlsToFetch.map(async (url) => {
|
||
try {
|
||
const response = await fetch(url, { cache: "no-store" });
|
||
if (!response.ok) {
|
||
throw new Error(`加载失败(HTTP ${response.status})`);
|
||
}
|
||
|
||
const code = await response.text();
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
|
||
setCachedCodeByUrl((current) => ({
|
||
...current,
|
||
[url]: code,
|
||
}));
|
||
setCodePreviewErrorByUrl((current) => ({
|
||
...current,
|
||
[url]: null,
|
||
}));
|
||
} catch (error) {
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
|
||
setCodePreviewErrorByUrl((current) => ({
|
||
...current,
|
||
[url]:
|
||
error instanceof Error ? error.message : "KCL 代码加载失败",
|
||
}));
|
||
} finally {
|
||
if (!cancelled) {
|
||
setLoadingCodePreviewByUrl((current) => ({
|
||
...current,
|
||
[url]: false,
|
||
}));
|
||
}
|
||
}
|
||
})
|
||
);
|
||
})()
|
||
);
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [activeCodeUrl, cachedCodeByUrl, compareCodeUrl, loadingCodePreviewByUrl]);
|
||
|
||
useEffect(() => {
|
||
if (!hasHydratedFromStorage) {
|
||
return;
|
||
}
|
||
|
||
if (!serializedWorkbenchSnapshot) {
|
||
return;
|
||
}
|
||
|
||
if (isApplyingExternalWorkspaceRef.current) {
|
||
isApplyingExternalWorkspaceRef.current = false;
|
||
return;
|
||
}
|
||
|
||
if (activeWorkspaceId && !hasHydratedServerWorkspace) {
|
||
hasLocalEditsBeforeServerHydrationRef.current[activeWorkspaceId] = true;
|
||
}
|
||
}, [
|
||
activeWorkspaceId,
|
||
hasHydratedFromStorage,
|
||
hasHydratedServerWorkspace,
|
||
serializedWorkbenchSnapshot,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (
|
||
!activeWorkspaceId ||
|
||
!hasHydratedFromStorage ||
|
||
typeof window === "undefined"
|
||
) {
|
||
return;
|
||
}
|
||
|
||
if (
|
||
!hasHydratedServerWorkspace &&
|
||
!hasLocalEditsBeforeServerHydrationRef.current[activeWorkspaceId]
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const savedAt = Date.now();
|
||
const workbenchPayload = JSON.parse(
|
||
serializedWorkbenchSnapshot
|
||
) as PersistedAiCadWorkbenchState;
|
||
const snapshot = {
|
||
...workbenchPayload,
|
||
savedAt,
|
||
};
|
||
const serializedWorkspaceSyncSnapshot = serializeWorkspaceSyncState({
|
||
name: activeWorkspace?.name,
|
||
state: workbenchPayload,
|
||
});
|
||
window.localStorage.setItem(
|
||
getWorkbenchStorageKey(activeWorkspaceId),
|
||
JSON.stringify(snapshot)
|
||
);
|
||
localSavedAtRef.current[activeWorkspaceId] = savedAt;
|
||
|
||
if (!hasHydratedServerWorkspace) {
|
||
return;
|
||
}
|
||
|
||
if (
|
||
serializedWorkspaceSyncSnapshot ===
|
||
lastServerSavedSnapshotRef.current[activeWorkspaceId]
|
||
) {
|
||
setWorkspaceSyncState((current) =>
|
||
current === "error" ? current : "synced"
|
||
);
|
||
return;
|
||
}
|
||
|
||
const workspaceId = activeWorkspaceId;
|
||
setWorkspaceSyncState("syncing");
|
||
const timer = window.setTimeout(() => {
|
||
swallowAsync(
|
||
(async () => {
|
||
const result = await aiCadGenerationService.saveWorkspace(
|
||
workspaceId,
|
||
{
|
||
name: activeWorkspace?.name,
|
||
prompt: workbenchPayload.prompt,
|
||
generationProvider: workbenchPayload.generationProvider,
|
||
modelVersion: workbenchPayload.modelVersion,
|
||
viewerSettings: workbenchPayload.viewerSettings as Record<
|
||
string,
|
||
unknown
|
||
>,
|
||
messages: workbenchPayload.messages as Record<string, unknown>[],
|
||
versions: workbenchPayload.versions as Record<string, unknown>[],
|
||
activeVersionId: workbenchPayload.activeVersionId,
|
||
}
|
||
);
|
||
|
||
if (result.success) {
|
||
lastServerSavedSnapshotRef.current[workspaceId] =
|
||
serializedWorkspaceSyncSnapshot;
|
||
if (result.data?.summary) {
|
||
setWorkspaces((current) =>
|
||
sortWorkspaceList(
|
||
current.map((workspace) =>
|
||
workspace.id === workspaceId
|
||
? result.data?.summary || workspace
|
||
: workspace
|
||
)
|
||
)
|
||
);
|
||
} else {
|
||
setWorkspaces((current) =>
|
||
current.map((workspace) =>
|
||
workspace.id === workspaceId
|
||
? {
|
||
...workspace,
|
||
name: result.data?.name || workspace.name,
|
||
updatedAt:
|
||
result.data?.updatedAt || workspace.updatedAt,
|
||
}
|
||
: workspace
|
||
)
|
||
);
|
||
}
|
||
if (activeWorkspaceIdRef.current === workspaceId) {
|
||
setWorkspaceSyncState("synced");
|
||
setWorkspaceSyncError(null);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (activeWorkspaceIdRef.current === workspaceId) {
|
||
setWorkspaceSyncState("error");
|
||
setWorkspaceSyncError(result.error || "云端同步失败");
|
||
}
|
||
})()
|
||
);
|
||
}, 1200);
|
||
|
||
return () => {
|
||
window.clearTimeout(timer);
|
||
};
|
||
}, [
|
||
activeWorkspace?.name,
|
||
activeWorkspaceId,
|
||
hasHydratedFromStorage,
|
||
hasHydratedServerWorkspace,
|
||
serializedWorkbenchSnapshot,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!activeWorkspaceId) {
|
||
return;
|
||
}
|
||
|
||
setWorkspaces((current) =>
|
||
current.map((workspace) =>
|
||
workspace.id === activeWorkspaceId
|
||
? buildWorkspaceSummaryFromWorkbench(workspace, {
|
||
prompt,
|
||
generationProvider,
|
||
versions,
|
||
})
|
||
: workspace
|
||
)
|
||
);
|
||
}, [activeWorkspaceId, generationProvider, prompt, versions]);
|
||
|
||
useEffect(() => {
|
||
if (!activeVersion) {
|
||
setActiveResultTab("preview");
|
||
return;
|
||
}
|
||
|
||
if (!activePreviewVersion && activeCodeUrl) {
|
||
setActiveResultTab("code");
|
||
return;
|
||
}
|
||
|
||
setActiveResultTab("preview");
|
||
}, [activeCodeUrl, activePreviewVersion, activeVersion]);
|
||
|
||
useEffect(() => {
|
||
if (!codeContextKey) {
|
||
return;
|
||
}
|
||
|
||
setCodeActionError(null);
|
||
}, [codeContextKey]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
|
||
const loadProviderStatus = async () => {
|
||
setIsLoadingKittycadProviderStatus(true);
|
||
const result = await aiCadGenerationService.getProviderStatus();
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
|
||
if (result.success && result.data) {
|
||
setKittycadProviderStatus(result.data);
|
||
} else {
|
||
setKittycadProviderStatus({
|
||
configured: false,
|
||
source: "none",
|
||
});
|
||
}
|
||
setIsLoadingKittycadProviderStatus(false);
|
||
};
|
||
|
||
swallowAsync(loadProviderStatus());
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!hasPendingTasks) {
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
|
||
const syncPendingTasks = async () => {
|
||
const pendingVersions = versionsRef.current.filter(
|
||
(version) => !FINAL_TASK_STATUS.has(version.status)
|
||
);
|
||
|
||
if (pendingVersions.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const responses = await Promise.all(
|
||
pendingVersions.map(async (version) => ({
|
||
versionId: version.id,
|
||
taskId: version.taskId,
|
||
assistantMessageId: version.assistantMessageId,
|
||
versionNumber: version.versionNumber,
|
||
result:
|
||
version.provider === "kittycad-cad"
|
||
? await aiCadGenerationService.getTask(version.taskId)
|
||
: await model3DGenerationService.getTask(version.taskId),
|
||
}))
|
||
);
|
||
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
|
||
for (const response of responses) {
|
||
if (!response.result.success || !response.result.data) {
|
||
continue;
|
||
}
|
||
|
||
const payload = response.result.data;
|
||
|
||
setVersions((current) =>
|
||
current.map((version) =>
|
||
version.id === response.versionId
|
||
? {
|
||
...version,
|
||
status: payload.status ?? version.status,
|
||
progress:
|
||
typeof payload.progress === "number"
|
||
? payload.progress
|
||
: version.progress,
|
||
modelUrl:
|
||
payload.modelUrl === undefined
|
||
? version.modelUrl
|
||
: payload.modelUrl,
|
||
previewImageUrl:
|
||
payload.previewImageUrl === undefined
|
||
? version.previewImageUrl
|
||
: payload.previewImageUrl,
|
||
codeUrl:
|
||
"codeUrl" in payload
|
||
? payload.codeUrl || null
|
||
: version.codeUrl,
|
||
errorMessage:
|
||
payload.errorMessage === undefined
|
||
? version.errorMessage
|
||
: payload.errorMessage,
|
||
conversationId:
|
||
"conversationId" in payload
|
||
? payload.conversationId || null
|
||
: version.conversationId,
|
||
downloadUrls:
|
||
"downloadUrls" in payload
|
||
? payload.downloadUrls || null
|
||
: version.downloadUrls,
|
||
modelVersion:
|
||
"modelVersion" in payload &&
|
||
typeof payload.modelVersion === "string" &&
|
||
payload.modelVersion.trim()
|
||
? payload.modelVersion
|
||
: version.modelVersion,
|
||
}
|
||
: version
|
||
)
|
||
);
|
||
|
||
setMessages((current) =>
|
||
current.map((message) =>
|
||
message.id === response.assistantMessageId
|
||
? {
|
||
...message,
|
||
state:
|
||
payload.status === "success"
|
||
? "success"
|
||
: payload.status === "failed" ||
|
||
payload.status === "banned" ||
|
||
payload.status === "cancelled" ||
|
||
payload.status === "expired"
|
||
? "failed"
|
||
: payload.status === "running"
|
||
? "running"
|
||
: "queued",
|
||
content: buildAssistantMessage(
|
||
response.versionNumber,
|
||
payload.status ?? "unknown",
|
||
payload.progress,
|
||
payload.errorMessage
|
||
),
|
||
}
|
||
: message
|
||
)
|
||
);
|
||
}
|
||
};
|
||
|
||
swallowAsync(syncPendingTasks());
|
||
const timer = window.setInterval(() => {
|
||
swallowAsync(syncPendingTasks());
|
||
}, 2500);
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
window.clearInterval(timer);
|
||
};
|
||
}, [hasPendingTasks]);
|
||
|
||
function updateActiveWorkspaceName(nextName: string) {
|
||
if (!activeWorkspaceId) {
|
||
return;
|
||
}
|
||
|
||
const normalizedName = normalizeWorkspaceTitle(nextName);
|
||
setWorkspaces((current) =>
|
||
current.map((workspace) =>
|
||
workspace.id === activeWorkspaceId
|
||
? { ...workspace, name: normalizedName }
|
||
: workspace
|
||
)
|
||
);
|
||
setWorkspaceNameDraft(normalizedName);
|
||
}
|
||
|
||
function commitWorkspaceName(nextName: string) {
|
||
const normalizedName = normalizeWorkspaceTitle(nextName);
|
||
setWorkspaceNameDraft(normalizedName);
|
||
if (!activeWorkspaceId || normalizedName === normalizedWorkspaceName) {
|
||
return;
|
||
}
|
||
|
||
setWorkspaceOperationError(null);
|
||
updateActiveWorkspaceName(normalizedName);
|
||
}
|
||
|
||
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,
|
||
}));
|
||
}
|
||
|
||
function handleWorkspaceNameKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
commitWorkspaceName(workspaceNameDraft);
|
||
event.currentTarget.blur();
|
||
return;
|
||
}
|
||
|
||
if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
setWorkspaceNameDraft(normalizedWorkspaceName);
|
||
event.currentTarget.blur();
|
||
}
|
||
}
|
||
|
||
function handleInsertStructuredDirective(
|
||
directiveKey: StructuredPromptDirectiveKey
|
||
) {
|
||
setPrompt((current) =>
|
||
upsertStructuredPromptDirective(current, directiveKey, activeVersion)
|
||
);
|
||
}
|
||
|
||
function handleInsertStructuredTemplate() {
|
||
setPrompt((current) =>
|
||
buildStructuredPromptTemplate(current, activeVersion)
|
||
);
|
||
}
|
||
|
||
async function handleCopyCodeContent(code: string) {
|
||
if (
|
||
typeof window === "undefined" ||
|
||
!navigator.clipboard ||
|
||
typeof navigator.clipboard.writeText !== "function"
|
||
) {
|
||
setCodeActionError("当前环境不支持复制代码");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(code);
|
||
setCodeActionError(null);
|
||
} catch (error) {
|
||
setCodeActionError(
|
||
error instanceof Error ? error.message : "复制 KCL 代码失败"
|
||
);
|
||
}
|
||
}
|
||
|
||
function handleFocusVersionForIteration(version: GenerationVersion) {
|
||
setActiveVersionId(version.id);
|
||
|
||
if (conversationPanelRef.current) {
|
||
conversationPanelRef.current.expand();
|
||
}
|
||
|
||
if (!canIterateKittyCadVersion(version)) {
|
||
return;
|
||
}
|
||
|
||
setPrompt((current) =>
|
||
current.trim()
|
||
? current
|
||
: buildStructuredPromptTemplate(
|
||
`保留:V${version.versionNumber} 的主体比例和主要装配关系`,
|
||
version
|
||
)
|
||
);
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
const trimmedPrompt = prompt.trim();
|
||
if (
|
||
!activeWorkspaceId ||
|
||
!trimmedPrompt ||
|
||
isSubmitting ||
|
||
(generationProvider === "kittycad-cad" && !isKittycadConfigured)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const nextVersionNumber = versions.length + 1;
|
||
const userMessageId = createId();
|
||
const assistantMessageId = createId();
|
||
const nextWorkspaceTitle =
|
||
versions.length === 0 && isDefaultWorkspaceTitle(activeWorkspaceName)
|
||
? buildWorkspaceTitleFromPrompt(trimmedPrompt)
|
||
: null;
|
||
|
||
if (nextWorkspaceTitle) {
|
||
updateActiveWorkspaceName(nextWorkspaceTitle);
|
||
}
|
||
|
||
setPrompt("");
|
||
setIsSubmitting(true);
|
||
setMessages((current) => [
|
||
...current,
|
||
{
|
||
id: userMessageId,
|
||
role: "user",
|
||
createdAt: Date.now(),
|
||
content: trimmedPrompt,
|
||
},
|
||
{
|
||
id: assistantMessageId,
|
||
role: "assistant",
|
||
createdAt: Date.now(),
|
||
state: "thinking",
|
||
content: iterationBaseVersion
|
||
? `收到,这一轮我会基于 V${iterationBaseVersion.versionNumber} 继续修改,先提交连续迭代任务。`
|
||
: `收到,这一轮我会把它推进成 V${nextVersionNumber},先开始提交生成任务。`,
|
||
},
|
||
]);
|
||
|
||
try {
|
||
const result =
|
||
generationProvider === "kittycad-cad"
|
||
? await aiCadGenerationService.createTask({
|
||
prompt: trimmedPrompt,
|
||
outputFormat: "glb",
|
||
iterationSourceTaskId: iterationBaseVersion?.taskId,
|
||
})
|
||
: await model3DGenerationService.createTask({
|
||
taskType: "text_to_model",
|
||
prompt: trimmedPrompt,
|
||
modelVersion,
|
||
});
|
||
|
||
if (!result.success || !result.data?.taskId) {
|
||
throw new Error(result.error || "创建 3D 任务失败");
|
||
}
|
||
|
||
const versionId = createId();
|
||
const createdGenerationMode =
|
||
generationProvider === "kittycad-cad" &&
|
||
"generationMode" in result.data &&
|
||
result.data.generationMode === "iterate"
|
||
? "iterate"
|
||
: "create";
|
||
const createdModelVersion =
|
||
generationProvider === "kittycad-cad"
|
||
? readNullableText(
|
||
"modelVersion" in result.data ? result.data.modelVersion : null
|
||
) ||
|
||
(createdGenerationMode === "iterate"
|
||
? "KCL Iteration"
|
||
: "GLB / STEP / KCL")
|
||
: modelVersion;
|
||
const createdConversationId = readNullableText(
|
||
"conversationId" in result.data ? result.data.conversationId : null
|
||
);
|
||
const nextVersion: GenerationVersion = {
|
||
id: versionId,
|
||
versionNumber: nextVersionNumber,
|
||
provider: generationProvider,
|
||
taskId: result.data.taskId,
|
||
generationMode: createdGenerationMode,
|
||
parentVersionId:
|
||
createdGenerationMode === "iterate"
|
||
? iterationBaseVersion?.id || null
|
||
: null,
|
||
prompt: trimmedPrompt,
|
||
modelVersion: createdModelVersion,
|
||
createdAt: Date.now(),
|
||
status: result.data.status || "queued",
|
||
progress: null,
|
||
conversationId: createdConversationId,
|
||
assistantMessageId,
|
||
};
|
||
|
||
setVersions((current) => [nextVersion, ...current]);
|
||
setActiveVersionId(versionId);
|
||
setMessages((current) =>
|
||
current.map((message) =>
|
||
message.id === assistantMessageId
|
||
? {
|
||
...message,
|
||
versionId,
|
||
state:
|
||
nextVersion.status === "running"
|
||
? "running"
|
||
: nextVersion.status === "success"
|
||
? "success"
|
||
: "queued",
|
||
content: buildAssistantMessage(
|
||
nextVersionNumber,
|
||
nextVersion.status,
|
||
null,
|
||
null
|
||
),
|
||
}
|
||
: message
|
||
)
|
||
);
|
||
} catch (error) {
|
||
setMessages((current) =>
|
||
current.map((message) =>
|
||
message.id === assistantMessageId
|
||
? {
|
||
...message,
|
||
state: "failed",
|
||
content: `这轮提交失败了:${error instanceof Error ? error.message : "未知错误"}。你可以稍后重试,或者换个更明确的结构描述。`,
|
||
}
|
||
: message
|
||
)
|
||
);
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
}
|
||
|
||
async function handleCreateWorkspace() {
|
||
if (isWorkspaceActionDisabled) {
|
||
return;
|
||
}
|
||
|
||
setIsCreatingWorkspace(true);
|
||
setWorkspaceOperationError(null);
|
||
|
||
try {
|
||
const result = await aiCadGenerationService.createWorkspace();
|
||
if (!result.success || !result.data) {
|
||
throw new Error(result.error || "创建工作台失败");
|
||
}
|
||
|
||
const createdWorkspace = result.data;
|
||
setWorkspaces((current) =>
|
||
sortWorkspaceList([createdWorkspace, ...current])
|
||
);
|
||
setActiveWorkspaceId(createdWorkspace.id);
|
||
} catch (error) {
|
||
setWorkspaceOperationError(
|
||
error instanceof Error ? error.message : "创建工作台失败"
|
||
);
|
||
} finally {
|
||
setIsCreatingWorkspace(false);
|
||
}
|
||
}
|
||
|
||
async function handleDeleteWorkspace() {
|
||
if (!activeWorkspaceId || !canDeleteActiveWorkspace) {
|
||
return;
|
||
}
|
||
|
||
const confirmed = window.confirm(
|
||
`确认删除“${activeWorkspaceName}”吗?本地与云端的工作台快照都会清除,但不会撤销已经生成过的外部任务。`
|
||
);
|
||
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
|
||
setIsDeletingWorkspace(true);
|
||
setWorkspaceOperationError(null);
|
||
|
||
try {
|
||
const result =
|
||
await aiCadGenerationService.deleteWorkspace(activeWorkspaceId);
|
||
if (!result.success) {
|
||
throw new Error(result.error || "删除工作台失败");
|
||
}
|
||
|
||
removePersistedWorkbench(activeWorkspaceId);
|
||
delete localSavedAtRef.current[activeWorkspaceId];
|
||
delete lastServerSavedSnapshotRef.current[activeWorkspaceId];
|
||
delete hasLocalEditsBeforeServerHydrationRef.current[activeWorkspaceId];
|
||
|
||
const remainingWorkspaces = workspaces.filter(
|
||
(workspace) => workspace.id !== activeWorkspaceId
|
||
);
|
||
|
||
if (remainingWorkspaces.length > 0) {
|
||
const nextWorkspaces = sortWorkspaceList(remainingWorkspaces);
|
||
setWorkspaces(nextWorkspaces);
|
||
setActiveWorkspaceId(nextWorkspaces[0]?.id || null);
|
||
return;
|
||
}
|
||
|
||
const createdResult = await aiCadGenerationService.createWorkspace();
|
||
if (!createdResult.success || !createdResult.data) {
|
||
setWorkspaces([]);
|
||
setActiveWorkspaceId(null);
|
||
throw new Error(createdResult.error || "重建默认工作台失败");
|
||
}
|
||
|
||
setWorkspaces([createdResult.data]);
|
||
setActiveWorkspaceId(createdResult.data.id);
|
||
} catch (error) {
|
||
setWorkspaceOperationError(
|
||
error instanceof Error ? error.message : "删除工作台失败"
|
||
);
|
||
} finally {
|
||
setIsDeletingWorkspace(false);
|
||
}
|
||
}
|
||
|
||
function handleResetWorkbench() {
|
||
const nextGenerationProvider: CadGenerationProvider =
|
||
kittycadProviderStatus?.configured === false
|
||
? "tripo-3d"
|
||
: "kittycad-cad";
|
||
|
||
if (activeWorkspaceId) {
|
||
removePersistedWorkbench(activeWorkspaceId);
|
||
}
|
||
|
||
setPrompt("");
|
||
setGenerationProvider(nextGenerationProvider);
|
||
setModelVersion(MODEL_VERSION_OPTIONS[0]);
|
||
setViewerSettings(DEFAULT_VIEWER_SETTINGS);
|
||
setMessages(createWelcomeMessages());
|
||
setVersions([]);
|
||
setActiveVersionId(null);
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-full flex-col overflow-hidden bg-[#1b1b1b] text-white">
|
||
<div className="border-white/10 border-b bg-[#252525] px-3 py-2.5 xl:px-4">
|
||
<div className="space-y-2.5">
|
||
<div className="flex flex-col gap-2.5 border-white/10 border-b pb-2.5 xl:flex-row xl:items-center xl:justify-between">
|
||
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="h-2.5 w-2.5 rounded-full bg-rose-400/80" />
|
||
<span className="h-2.5 w-2.5 rounded-full bg-amber-300/80" />
|
||
<span className="h-2.5 w-2.5 rounded-full bg-emerald-300/80" />
|
||
</div>
|
||
<div className="flex items-center gap-2 rounded-[8px] border border-white/10 bg-[#1f1f1f] px-2 py-1.5">
|
||
<div className="flex h-6 w-8 items-center justify-center rounded-sm bg-[#6af29a] font-semibold text-[#08120d] text-[11px]">
|
||
AI
|
||
</div>
|
||
<span className="font-medium text-[13px] text-white">
|
||
AI-CAD Studio
|
||
</span>
|
||
</div>
|
||
<div className="min-w-0 rounded-[8px] border border-white/10 bg-[#1f1f1f] px-3 py-1.5 text-[13px] text-white/65">
|
||
<span className="truncate">
|
||
{normalizedWorkspaceName} / main.kcl
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<button
|
||
className="inline-flex items-center gap-2 rounded-[8px] border border-white/10 bg-[#1f1f1f] px-3 py-1.5 text-[13px] text-white/72 transition-all duration-150 ease-out hover:border-white/15 hover:bg-[#2a2a2a] hover:text-white"
|
||
type="button"
|
||
>
|
||
<Grid3X3 className="h-4 w-4" />
|
||
Commands
|
||
</button>
|
||
<button
|
||
className="inline-flex items-center gap-2 rounded-[8px] border border-white/10 bg-[#1f1f1f] px-3 py-1.5 text-[13px] text-white/72 transition-all duration-150 ease-out hover:border-white/15 hover:bg-[#2a2a2a] hover:text-white"
|
||
type="button"
|
||
>
|
||
<ArrowUpRight className="h-4 w-4" />
|
||
Share
|
||
</button>
|
||
<div className="flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-[#1f1f1f] text-sm text-white/78">
|
||
AI
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2.5 xl:flex-row xl:items-center xl:justify-between">
|
||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||
<div className="flex h-10 items-center rounded-md border border-white/10 bg-[#1f1f1f] px-3 text-white/75 text-xs uppercase tracking-[0.16em]">
|
||
AI-CAD
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<Input
|
||
className="h-10 rounded-md border-white/10 bg-[#1b1b1b] px-3 text-sm text-white placeholder:text-white/28 focus-visible:ring-white/15"
|
||
disabled={
|
||
!activeWorkspaceId ||
|
||
isWorkspaceActionDisabled ||
|
||
isLoadingWorkspaceList
|
||
}
|
||
onBlur={() => commitWorkspaceName(workspaceNameDraft)}
|
||
onChange={(event) =>
|
||
setWorkspaceNameDraft(event.target.value)
|
||
}
|
||
onKeyDown={handleWorkspaceNameKeyDown}
|
||
placeholder="untitled / main.kcl"
|
||
value={workspaceNameDraft}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Badge className="rounded-[8px] border-white/10 bg-[#1d1d1d] text-[11px] text-white/70">
|
||
<FolderOpen className="mr-1 h-3.5 w-3.5" />
|
||
{isLoadingWorkspaceList ? "正在读取" : normalizedWorkspaceName}
|
||
</Badge>
|
||
<Badge className="rounded-[8px] border-white/10 bg-[#1d1d1d] text-[11px] text-white/70">
|
||
{versions.length} versions
|
||
</Badge>
|
||
<Badge className="rounded-[8px] border-white/10 bg-[#1d1d1d] text-[11px] text-white/70">
|
||
{completedCount} completed
|
||
</Badge>
|
||
<Badge
|
||
className={cn(
|
||
"gap-1.5 rounded-[8px] border text-[11px]",
|
||
workspaceSyncState === "synced"
|
||
? "border-emerald-400/20 bg-emerald-400/10 text-emerald-100"
|
||
: workspaceSyncState === "syncing" ||
|
||
workspaceSyncState === "loading"
|
||
? "border-sky-400/20 bg-sky-400/10 text-sky-100"
|
||
: "border-rose-400/20 bg-rose-400/10 text-rose-100"
|
||
)}
|
||
>
|
||
{workspaceSyncState === "synced" ? (
|
||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||
) : workspaceSyncState === "syncing" ||
|
||
workspaceSyncState === "loading" ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<AlertCircle className="h-3.5 w-3.5" />
|
||
)}
|
||
{workspaceSyncState === "synced"
|
||
? "Synced"
|
||
: workspaceSyncState === "syncing"
|
||
? "Syncing"
|
||
: workspaceSyncState === "loading"
|
||
? "Loading"
|
||
: "Sync error"}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
|
||
{(workspaceSyncError || workspaceOperationError) && (
|
||
<div className="flex flex-wrap gap-3 text-xs leading-5">
|
||
{workspaceSyncError ? (
|
||
<p className="text-rose-200/90">
|
||
云端同步提示:{workspaceSyncError}
|
||
</p>
|
||
) : null}
|
||
{workspaceOperationError ? (
|
||
<p className="text-amber-100/90">
|
||
工作台提示:{workspaceOperationError}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-2.5">
|
||
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-center lg:justify-between">
|
||
<div className="flex items-center gap-2 text-white/55 text-xs uppercase tracking-[0.22em]">
|
||
<History className="h-3.5 w-3.5" />
|
||
多工作台
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
className="gap-2 rounded-[8px] border-white/10 bg-[#1d1d1d] text-white transition-all duration-150 ease-out hover:border-white/15 hover:bg-[#262626]"
|
||
disabled={isWorkspaceActionDisabled || isLoadingWorkspaceList}
|
||
onClick={handleCreateWorkspace}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
{isCreatingWorkspace ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Plus className="h-4 w-4" />
|
||
)}
|
||
新建工作台
|
||
</Button>
|
||
<Button
|
||
className="gap-2 rounded-[8px] border-white/10 bg-[#1d1d1d] text-white transition-all duration-150 ease-out hover:border-white/15 hover:bg-[#262626]"
|
||
disabled={
|
||
!activeWorkspaceId ||
|
||
isWorkspaceActionDisabled ||
|
||
isLoadingWorkspaceList
|
||
}
|
||
onClick={handleResetWorkbench}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<RotateCcw className="h-4 w-4" />
|
||
清空当前
|
||
</Button>
|
||
<Button
|
||
className="gap-2 rounded-[8px] border-rose-400/20 bg-rose-400/10 text-rose-100 transition-all duration-150 ease-out hover:bg-rose-400/16 disabled:opacity-50"
|
||
disabled={!canDeleteActiveWorkspace}
|
||
onClick={handleDeleteWorkspace}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
{isDeletingWorkspace ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Trash2 className="h-4 w-4" />
|
||
)}
|
||
删除当前
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<ScrollArea className="w-full whitespace-nowrap">
|
||
<div className="flex gap-2.5 pb-1">
|
||
{workspaces.map((workspace) => (
|
||
<button
|
||
className={cn(
|
||
"min-w-[228px] rounded-[8px] border px-3.5 py-3 text-left transition-all duration-150 ease-out",
|
||
workspace.id === activeWorkspaceId
|
||
? "border-white/20 bg-[#242424]"
|
||
: "border-white/10 bg-[#1d1d1d] hover:border-white/15 hover:bg-[#222]",
|
||
isWorkspaceActionDisabled &&
|
||
workspace.id !== activeWorkspaceId &&
|
||
"cursor-not-allowed opacity-70"
|
||
)}
|
||
disabled={
|
||
isWorkspaceActionDisabled &&
|
||
workspace.id !== activeWorkspaceId
|
||
}
|
||
key={workspace.id}
|
||
onClick={() => setActiveWorkspaceId(workspace.id)}
|
||
type="button"
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-2">
|
||
<FolderOpen className="h-4 w-4 text-white/55" />
|
||
<span className="font-medium text-[13px] text-white">
|
||
{workspace.name}
|
||
</span>
|
||
</div>
|
||
<Badge
|
||
className={cn(
|
||
"rounded-md border px-2 py-0 text-[10px]",
|
||
workspace.id === activeWorkspaceId
|
||
? "border-white/20 bg-white/10 text-white"
|
||
: "border-white/10 bg-white/8 text-white/60"
|
||
)}
|
||
>
|
||
{workspace.id === activeWorkspaceId ? "当前" : "切换"}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="mt-3 flex items-center gap-3 text-white/60 text-xs">
|
||
<span>
|
||
{getProviderLabel(workspace.generationProvider)}
|
||
</span>
|
||
<span>{workspace.versionCount} 个版本</span>
|
||
<span>{workspace.completedCount} 个完成</span>
|
||
</div>
|
||
|
||
<div className="mt-2 flex items-center gap-2 text-xs">
|
||
{workspace.hasPendingTasks ? (
|
||
<>
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin text-sky-300" />
|
||
<span className="text-sky-100/85">有任务在同步</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-300" />
|
||
<span className="text-emerald-100/85">
|
||
已静止保存
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<p className="mt-2.5 line-clamp-2 min-h-10 text-[13px] text-white/62 leading-5">
|
||
{workspace.latestPrompt ||
|
||
"这个工作台还没有开始描述设计意图。"}
|
||
</p>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</ScrollArea>
|
||
|
||
<div className="flex flex-col gap-2.5 rounded-[8px] border border-white/10 bg-[#1d1d1d] px-3.5 py-3 lg:flex-row lg:items-center lg:justify-between">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="text-[10px] text-white/55 uppercase tracking-[0.22em]">
|
||
Panel Layout
|
||
</div>
|
||
<Badge className="rounded-[8px] border-white/10 bg-[#171717] text-[11px] text-white/58">
|
||
left tree / center canvas / right agent
|
||
</Badge>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
className={cn(
|
||
"gap-2 rounded-[8px] border text-white transition-all duration-150 ease-out",
|
||
isHistoryPanelCollapsed
|
||
? "border-white/10 bg-[#1b1b1b] hover:bg-[#262626]"
|
||
: "border-white/20 bg-[#262626]"
|
||
)}
|
||
onClick={() =>
|
||
togglePanel(historyPanelRef, isHistoryPanelCollapsed)
|
||
}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<History className="h-4 w-4" />
|
||
版本树
|
||
{renderPanelToggleIcon(isHistoryPanelCollapsed)}
|
||
</Button>
|
||
<Button
|
||
className={cn(
|
||
"gap-2 rounded-[8px] border text-white transition-all duration-150 ease-out",
|
||
isResultPanelCollapsed
|
||
? "border-white/10 bg-[#1b1b1b] hover:bg-[#262626]"
|
||
: "border-white/20 bg-[#262626]"
|
||
)}
|
||
onClick={() =>
|
||
togglePanel(resultPanelRef, isResultPanelCollapsed)
|
||
}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<Orbit className="h-4 w-4" />
|
||
预览
|
||
{renderPanelToggleIcon(isResultPanelCollapsed)}
|
||
</Button>
|
||
<Button
|
||
className={cn(
|
||
"gap-2 rounded-[8px] border text-white transition-all duration-150 ease-out",
|
||
isConversationPanelCollapsed
|
||
? "border-white/10 bg-[#1b1b1b] hover:bg-[#262626]"
|
||
: "border-white/20 bg-[#262626]"
|
||
)}
|
||
onClick={() =>
|
||
togglePanel(
|
||
conversationPanelRef,
|
||
isConversationPanelCollapsed
|
||
)
|
||
}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<BrainCircuit className="h-4 w-4" />
|
||
AI-CAD Agent
|
||
{renderPanelToggleIcon(isConversationPanelCollapsed)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<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-white/10 border-r bg-[#1f1f1f] px-2 py-3">
|
||
<div className="flex flex-col items-center gap-3">
|
||
<div className="text-[9px] text-white/25 uppercase tracking-[0.22em]">
|
||
Panels
|
||
</div>
|
||
<div className="flex flex-col items-center gap-2">
|
||
<AiCadRailButton
|
||
active={!isHistoryPanelCollapsed}
|
||
icon={History}
|
||
label="Feature Tree"
|
||
onClick={() => {
|
||
historyPanelRef.current?.expand();
|
||
setActiveResultTab("preview");
|
||
}}
|
||
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>
|
||
|
||
<div className="flex flex-col items-center gap-3">
|
||
<div className="text-[9px] text-white/25 uppercase tracking-[0.22em]">
|
||
Tools
|
||
</div>
|
||
<div className="flex flex-col items-center gap-2">
|
||
{primaryDownloadEntry ? (
|
||
<a
|
||
className="flex w-10 flex-col items-center gap-1 rounded-[8px] border border-white/10 px-0 py-2 text-white/55 transition-all duration-150 ease-out hover:border-white/15 hover:bg-white/6 hover:text-white"
|
||
href={primaryDownloadEntry.url}
|
||
rel="noreferrer"
|
||
target="_blank"
|
||
title="导出当前版本"
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
<span className="font-medium text-[9px] uppercase leading-none tracking-[0.18em]">
|
||
Export
|
||
</span>
|
||
</a>
|
||
) : (
|
||
<button
|
||
className="flex w-10 cursor-not-allowed flex-col items-center gap-1 rounded-[8px] border border-white/10 px-0 py-2 text-white/25"
|
||
disabled
|
||
title="暂无可导出文件"
|
||
type="button"
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
<span className="font-medium text-[9px] uppercase leading-none tracking-[0.18em]">
|
||
Export
|
||
</span>
|
||
</button>
|
||
)}
|
||
<button
|
||
className="flex w-10 flex-col items-center gap-1 rounded-[8px] border border-white/10 px-0 py-2 text-white/55 transition-all duration-150 ease-out hover:border-white/15 hover:bg-white/6 hover:text-white"
|
||
onClick={handleResetWorkbench}
|
||
title="重置当前工作台"
|
||
type="button"
|
||
>
|
||
<RotateCcw className="h-4 w-4" />
|
||
<span className="font-medium text-[9px] uppercase leading-none tracking-[0.18em]">
|
||
Reset
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||
<div className="border-white/10 border-b bg-[#202020] px-3 py-2.5">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="text-[10px] text-white/35 uppercase tracking-[0.22em]">
|
||
Canvas Workspace
|
||
</div>
|
||
<Badge className="rounded-[8px] border-white/10 bg-[#171717] text-[10px] text-white/52 uppercase tracking-[0.18em]">
|
||
Docked Panels
|
||
</Badge>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-white/42">
|
||
<span>
|
||
{activeVersion
|
||
? `Focus V${activeVersion.versionNumber}`
|
||
: "Ready for first pass"}
|
||
</span>
|
||
<span>{versions.length} versions</span>
|
||
<span>
|
||
{hasPendingTasks
|
||
? `${pendingTaskCount} task active`
|
||
: "Workspace stable"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-2.5 flex flex-wrap items-start gap-2">
|
||
<AiCadToolbarGroup label="Mode">
|
||
<button
|
||
className={cn(
|
||
"inline-flex items-center gap-2 rounded-[8px] border px-3 py-1.5 text-[13px] transition-all duration-150 ease-out",
|
||
activeResultTab === "preview"
|
||
? "border-white/20 bg-[#262626] text-white"
|
||
: "border-white/10 bg-[#1b1b1b] text-white/72 hover:border-white/15 hover:bg-[#2a2a2a] hover:text-white"
|
||
)}
|
||
onClick={() => {
|
||
resultPanelRef.current?.expand();
|
||
setActiveResultTab("preview");
|
||
}}
|
||
type="button"
|
||
>
|
||
<Orbit className="h-4 w-4" />
|
||
3D Preview
|
||
</button>
|
||
<button
|
||
className={cn(
|
||
"inline-flex items-center gap-2 rounded-[8px] border px-3 py-1.5 text-[13px] transition-all duration-150 ease-out",
|
||
activeResultTab === "code"
|
||
? "border-white/20 bg-[#262626] text-white"
|
||
: "border-white/10 bg-[#1b1b1b] text-white/72 hover:border-white/15 hover:bg-[#2a2a2a] hover:text-white"
|
||
)}
|
||
onClick={() => {
|
||
resultPanelRef.current?.expand();
|
||
setActiveResultTab("code");
|
||
}}
|
||
type="button"
|
||
>
|
||
<FileCode2 className="h-4 w-4" />
|
||
KCL
|
||
</button>
|
||
</AiCadToolbarGroup>
|
||
|
||
<AiCadToolbarGroup label="Display">
|
||
<button
|
||
className={cn(
|
||
"inline-flex h-8 w-8 items-center justify-center rounded-[8px] border transition-all duration-150 ease-out",
|
||
viewerSettings.showGrid
|
||
? "border-white/20 bg-[#2a2a2a] text-white"
|
||
: "border-white/10 bg-[#1b1b1b] text-white/55 hover:border-white/15 hover:bg-[#2a2a2a] hover:text-white"
|
||
)}
|
||
onClick={() =>
|
||
setViewerSettings((current) => ({
|
||
...current,
|
||
showGrid: !current.showGrid,
|
||
}))
|
||
}
|
||
title="切换网格"
|
||
type="button"
|
||
>
|
||
<Grid3X3 className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
className={cn(
|
||
"inline-flex h-8 w-8 items-center justify-center rounded-[8px] border transition-all duration-150 ease-out",
|
||
viewerSettings.autoRotate
|
||
? "border-white/20 bg-[#2a2a2a] text-white"
|
||
: "border-white/10 bg-[#1b1b1b] text-white/55 hover:border-white/15 hover:bg-[#2a2a2a] hover:text-white"
|
||
)}
|
||
onClick={() =>
|
||
setViewerSettings((current) => ({
|
||
...current,
|
||
autoRotate: !current.autoRotate,
|
||
}))
|
||
}
|
||
title="切换自动旋转"
|
||
type="button"
|
||
>
|
||
<RotateCcw className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
className={cn(
|
||
"inline-flex h-8 w-8 items-center justify-center rounded-[8px] border transition-all duration-150 ease-out",
|
||
viewerSettings.wireframe
|
||
? "border-white/20 bg-[#2a2a2a] text-white"
|
||
: "border-white/10 bg-[#1b1b1b] text-white/55 hover:border-white/15 hover:bg-[#2a2a2a] hover:text-white"
|
||
)}
|
||
onClick={() =>
|
||
setViewerSettings((current) => ({
|
||
...current,
|
||
wireframe: !current.wireframe,
|
||
}))
|
||
}
|
||
title="切换线框模式"
|
||
type="button"
|
||
>
|
||
<Box className="h-4 w-4" />
|
||
</button>
|
||
</AiCadToolbarGroup>
|
||
|
||
<AiCadToolbarGroup className="lg:ml-auto" label="Session">
|
||
<Badge className="rounded-md border-white/10 bg-[#1b1b1b] text-white/65">
|
||
{generationProvider === "kittycad-cad"
|
||
? "KittyCAD / Zoo CAD"
|
||
: "Tripo3D"}
|
||
</Badge>
|
||
<Badge className="rounded-md border-white/10 bg-[#1b1b1b] text-white/65">
|
||
{viewerSettings.envPreset}
|
||
</Badge>
|
||
{activeVersion ? (
|
||
<Badge className="rounded-md border-white/10 bg-[#1b1b1b] text-white/65">
|
||
V{activeVersion.versionNumber}
|
||
</Badge>
|
||
) : null}
|
||
</AiCadToolbarGroup>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 p-3">
|
||
<PanelGroup
|
||
autoSaveId={`ai-cad-panel-layout:${panelDirection}`}
|
||
className="flex h-full min-h-0"
|
||
direction={panelDirection}
|
||
>
|
||
<Panel
|
||
className="min-h-0"
|
||
collapsedSize={panelDirection === "horizontal" ? 11 : 12}
|
||
collapsible
|
||
defaultSize={29}
|
||
minSize={panelDirection === "horizontal" ? 24 : 22}
|
||
onCollapse={() => setIsConversationPanelCollapsed(true)}
|
||
onExpand={() => setIsConversationPanelCollapsed(false)}
|
||
order={3}
|
||
ref={conversationPanelRef}
|
||
>
|
||
{isConversationPanelCollapsed ? (
|
||
<AiCadCollapsedPanel
|
||
direction={panelDirection}
|
||
icon={BrainCircuit}
|
||
onExpand={() => conversationPanelRef.current?.expand()}
|
||
title="AI-CAD Agent"
|
||
/>
|
||
) : (
|
||
<Card className="flex h-full min-h-0 flex-col rounded-[8px] border-white/10 bg-[#1d1d1d] text-white shadow-none">
|
||
<CardHeader className="space-y-2.5 border-white/10 border-b bg-[#232323] pb-2.5">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<CardTitle className="flex items-center gap-2 text-[15px]">
|
||
<BrainCircuit className="h-4 w-4 text-white/72" />
|
||
AI-CAD Agent
|
||
</CardTitle>
|
||
<CardDescription className="mt-1 text-[12px] text-white/42">
|
||
Continuous design thread for the active version.
|
||
</CardDescription>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Badge className="rounded-[8px] border-white/10 bg-[#1b1b1b] text-[11px] text-white/68">
|
||
Agent
|
||
</Badge>
|
||
<button
|
||
className="inline-flex h-8 w-8 items-center justify-center rounded-[8px] border border-white/10 bg-[#1b1b1b] text-white/55 transition-all duration-150 ease-out hover:border-white/15 hover:bg-[#262626] hover:text-white"
|
||
type="button"
|
||
>
|
||
<MoreHorizontal className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
className="inline-flex h-8 w-8 items-center justify-center rounded-[8px] border border-white/10 bg-[#1b1b1b] text-white/55 transition-all duration-150 ease-out hover:border-white/15 hover:bg-[#262626] hover:text-white"
|
||
onClick={() =>
|
||
conversationPanelRef.current?.collapse()
|
||
}
|
||
type="button"
|
||
>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-[8px] border border-white/10 bg-[#1b1b1b] px-3 py-2">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Badge className="rounded-[8px] border-white/10 bg-[#232323] text-[11px] text-white/68">
|
||
Session Context
|
||
</Badge>
|
||
{activeVersion ? (
|
||
<Badge className="rounded-[8px] border-white/10 bg-[#232323] text-[11px] text-white/68">
|
||
Focus V{activeVersion.versionNumber}
|
||
</Badge>
|
||
) : (
|
||
<Badge className="rounded-[8px] border-white/10 bg-[#232323] text-[11px] text-white/55">
|
||
New thread
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
{activeVersion ? (
|
||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-white/45">
|
||
<span>
|
||
{getProviderLabel(activeVersion.provider)}
|
||
</span>
|
||
<span>
|
||
{getStatusLabel(activeVersion.status)}
|
||
</span>
|
||
<span>
|
||
{activeVersion.generationMode === "iterate"
|
||
? "Iterative"
|
||
: "Initial"}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<p className="mt-1.5 text-[12px] text-white/58 leading-5">
|
||
{activeVersion
|
||
? activeVersion.generationMode === "iterate"
|
||
? `当前已经沿着 V${activeVersion.versionNumber} 进入连续修改模式。`
|
||
: `当前焦点在 V${activeVersion.versionNumber}。`
|
||
: "当前还是首轮对话阶段。"}
|
||
</p>
|
||
</div>
|
||
</CardHeader>
|
||
|
||
<CardContent className="flex min-h-0 flex-1 flex-col p-0">
|
||
<ScrollArea className="min-h-0 flex-1 px-3.5 py-3.5">
|
||
<div className="space-y-3">
|
||
{messages.map((message) => {
|
||
const linkedVersion = message.versionId
|
||
? findVersionById(versions, message.versionId)
|
||
: null;
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"rounded-[8px] border px-3 py-2.5 shadow-none transition-all duration-150 ease-out",
|
||
message.role === "user"
|
||
? "ml-auto max-w-[84%] border-white/10 bg-[#2b2b2b] text-white hover:border-white/15"
|
||
: "w-full",
|
||
message.role !== "user" &&
|
||
getMessageStateClass(message.state)
|
||
)}
|
||
key={message.id}
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-2 text-[10px] text-white/45 uppercase tracking-[0.18em]">
|
||
{message.role === "user" ? (
|
||
<>
|
||
<WandSparkles className="h-3.5 w-3.5" />
|
||
你
|
||
</>
|
||
) : (
|
||
<>
|
||
<Bot className="h-3.5 w-3.5" />
|
||
AI-CAD Agent
|
||
</>
|
||
)}
|
||
<span>
|
||
{formatClockTime(message.createdAt)}
|
||
</span>
|
||
</div>
|
||
{message.role !== "user" &&
|
||
message.state ? (
|
||
<Badge className="rounded-[8px] border-white/10 bg-[#1b1b1b] text-[10px] text-white/58 uppercase tracking-[0.16em]">
|
||
{getMessageStateLabel(message.state)}
|
||
</Badge>
|
||
) : null}
|
||
</div>
|
||
|
||
{linkedVersion ? (
|
||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||
<Badge className="border-sky-400/20 bg-sky-400/10 text-sky-100">
|
||
V{linkedVersion.versionNumber}
|
||
</Badge>
|
||
<Badge
|
||
className={cn(
|
||
"border px-2 py-0 font-medium text-[11px]",
|
||
getStatusBadgeClass(
|
||
linkedVersion.status
|
||
)
|
||
)}
|
||
>
|
||
{getStatusLabel(linkedVersion.status)}
|
||
</Badge>
|
||
{linkedVersion.generationMode ===
|
||
"iterate" ? (
|
||
<Badge className="border-white/10 bg-white/6 text-white/70">
|
||
连续修改
|
||
</Badge>
|
||
) : null}
|
||
{linkedVersion.parentVersionId ? (
|
||
<Badge className="border-white/10 bg-white/6 text-white/60">
|
||
linked output
|
||
</Badge>
|
||
) : (
|
||
<Badge className="border-white/10 bg-white/6 text-white/60">
|
||
root output
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mt-2 text-[13px] text-white/88 leading-[1.45]">
|
||
{message.content}
|
||
</div>
|
||
|
||
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-white/10 border-t pt-2.5">
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
{linkedVersion ? (
|
||
<AiCadMessageActionButton
|
||
icon={ArrowUpRight}
|
||
label={`聚焦 V${linkedVersion.versionNumber}`}
|
||
onClick={() =>
|
||
setActiveVersionId(linkedVersion.id)
|
||
}
|
||
/>
|
||
) : null}
|
||
{linkedVersion &&
|
||
canIterateKittyCadVersion(
|
||
linkedVersion
|
||
) ? (
|
||
<AiCadMessageActionButton
|
||
icon={GitBranch}
|
||
label="继续修改"
|
||
onClick={() =>
|
||
handleFocusVersionForIteration(
|
||
linkedVersion
|
||
)
|
||
}
|
||
/>
|
||
) : null}
|
||
<AiCadMessageActionButton
|
||
icon={Copy}
|
||
label="填入输入框"
|
||
onClick={() =>
|
||
setPrompt(message.content)
|
||
}
|
||
/>
|
||
</div>
|
||
<span className="text-[11px] text-white/38">
|
||
{linkedVersion
|
||
? linkedVersion.generationMode ===
|
||
"iterate"
|
||
? "variant thread"
|
||
: "initial thread"
|
||
: message.role === "assistant"
|
||
? "agent note"
|
||
: "prompt draft"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</ScrollArea>
|
||
|
||
<div className="border-white/10 border-t bg-[#1c1c1c] p-3.5">
|
||
<div className="mb-2.5 flex flex-wrap items-center gap-2">
|
||
<Badge className="border-white/10 bg-white/6 text-white/70">
|
||
生成引擎
|
||
</Badge>
|
||
<Select
|
||
onValueChange={(value) =>
|
||
setGenerationProvider(
|
||
value as CadGenerationProvider
|
||
)
|
||
}
|
||
value={generationProvider}
|
||
>
|
||
<SelectTrigger className="h-9 w-[220px] border-white/10 bg-white/5 text-white">
|
||
<SelectValue placeholder="选择生成引擎" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="kittycad-cad">
|
||
KittyCAD / Zoo CAD
|
||
</SelectItem>
|
||
<SelectItem value="tripo-3d">
|
||
Tripo3D(当前回退)
|
||
</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
{generationProvider === "tripo-3d" ? (
|
||
<>
|
||
<Badge className="border-white/10 bg-white/6 text-white/70">
|
||
Tripo 模型版本
|
||
</Badge>
|
||
<Select
|
||
onValueChange={setModelVersion}
|
||
value={modelVersion}
|
||
>
|
||
<SelectTrigger className="h-9 w-[220px] border-white/10 bg-white/5 text-white">
|
||
<SelectValue placeholder="选择模型版本" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{MODEL_VERSION_OPTIONS.map((option) => (
|
||
<SelectItem key={option} value={option}>
|
||
{option}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</>
|
||
) : (
|
||
<Badge className="border-emerald-400/20 bg-emerald-400/10 text-emerald-100">
|
||
输出 GLB / STEP / KCL
|
||
</Badge>
|
||
)}
|
||
{activeVersion ? (
|
||
<Badge className="border-sky-400/20 bg-sky-400/10 text-sky-100">
|
||
{willSubmitAsIteration
|
||
? `基于 V${activeVersion.versionNumber} 连续修改`
|
||
: `参考版本 V${activeVersion.versionNumber}`}
|
||
</Badge>
|
||
) : null}
|
||
</div>
|
||
|
||
{generationProvider === "kittycad-cad" ? (
|
||
<div
|
||
className={cn(
|
||
"mb-2.5 rounded-[8px] border px-3.5 py-2.5 text-[13px] leading-6",
|
||
kittycadProviderStatus?.configured
|
||
? "border-emerald-400/20 bg-emerald-400/10 text-emerald-100"
|
||
: "border-amber-400/20 bg-amber-400/10 text-amber-100"
|
||
)}
|
||
>
|
||
<div className="flex items-start gap-2">
|
||
<AlertCircle className="mt-0.5 h-4 w-4 flex-none" />
|
||
<div>
|
||
{isLoadingKittycadProviderStatus ? (
|
||
<span>
|
||
正在检查 KittyCAD CAD 配置状态...
|
||
</span>
|
||
) : kittycadProviderStatus?.configured ? (
|
||
<span>
|
||
当前将通过
|
||
{kittycadProviderStatus.providerName
|
||
? ` ${kittycadProviderStatus.providerName}`
|
||
: " KittyCAD CAD"}
|
||
发起生成,配置来源:
|
||
{getProviderSourceLabel(
|
||
kittycadProviderStatus.source
|
||
)}
|
||
。
|
||
</span>
|
||
) : (
|
||
<span>
|
||
还没有检测到 KittyCAD CAD
|
||
可用配置。请先到“系统设置 > 本地AI”保存
|
||
API Token,或者先切换到 Tripo3D 回退链路。
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mb-2.5 rounded-[8px] border border-white/10 bg-[#171717] px-3.5 py-3">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="text-white/45 text-xs uppercase tracking-[0.16em]">
|
||
Suggested Next Edits
|
||
</div>
|
||
{semanticFocusInsights.length > 0 ? (
|
||
<div className="flex flex-wrap gap-2">
|
||
{semanticFocusInsights.map((insight) => (
|
||
<Badge
|
||
className="rounded-md border-white/10 bg-[#1f1f1f] text-white/70"
|
||
key={insight.key}
|
||
>
|
||
{insight.shortLabel}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{followUpSuggestions.slice(0, 3).map((item) => (
|
||
<button
|
||
className="rounded-[8px] border border-white/10 bg-[#1f1f1f] px-3 py-1.5 text-left text-white/72 text-xs transition-all duration-150 ease-out hover:border-white/15 hover:bg-[#292929] hover:text-white"
|
||
key={item}
|
||
onClick={() => setPrompt(item)}
|
||
type="button"
|
||
>
|
||
{item}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="mt-4 text-white/45 text-xs uppercase tracking-[0.16em]">
|
||
Prompt Directives
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<button
|
||
className="rounded-[8px] border border-white/10 bg-[#232323] px-3 py-1.5 text-left text-white/78 text-xs transition-all duration-150 ease-out hover:border-white/15 hover:bg-[#2b2b2b]"
|
||
onClick={handleInsertStructuredTemplate}
|
||
type="button"
|
||
>
|
||
插入完整模板
|
||
</button>
|
||
{STRUCTURED_PROMPT_DIRECTIVES.map((directive) => (
|
||
<button
|
||
className="rounded-[8px] border border-white/10 bg-[#1f1f1f] px-3 py-1.5 text-left text-white/72 text-xs transition-all duration-150 ease-out hover:border-white/15 hover:bg-[#292929] hover:text-white"
|
||
key={directive.key}
|
||
onClick={() =>
|
||
handleInsertStructuredDirective(
|
||
directive.key
|
||
)
|
||
}
|
||
type="button"
|
||
>
|
||
{directive.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{promptStructuredDirectives.length > 0 ? (
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{promptStructuredDirectives.map((directive) => (
|
||
<Badge
|
||
className="rounded-md border-white/10 bg-[#1f1f1f] text-white/72"
|
||
key={directive.key}
|
||
>
|
||
{directive.label}:
|
||
{truncateText(directive.value, 22)}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="rounded-[8px] border border-white/10 bg-[#151515] p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]">
|
||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2 border-white/10 border-b pb-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="text-[10px] text-white/35 uppercase tracking-[0.22em]">
|
||
Compose
|
||
</span>
|
||
<Badge className="rounded-md border-white/10 bg-[#1b1b1b] text-white/65">
|
||
{willSubmitAsIteration
|
||
? "Iterative edit"
|
||
: "New version"}
|
||
</Badge>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-white/40">
|
||
<span>{promptLineCount} lines</span>
|
||
<span>{promptCharacterCount} chars</span>
|
||
</div>
|
||
</div>
|
||
<Textarea
|
||
className="min-h-[120px] resize-none border-0 bg-transparent px-0 py-0 text-white placeholder:text-white/35 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="Describe the next geometry change, or say what should stay the same."
|
||
value={prompt}
|
||
/>
|
||
|
||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 border-white/10 border-t pt-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Badge className="rounded-[8px] border-white/10 bg-[#1f1f1f] text-[11px] text-white/68">
|
||
{activeWorkspaceId
|
||
? "1 workspace"
|
||
: "No workspace"}
|
||
</Badge>
|
||
<Badge className="rounded-[8px] border-white/10 bg-[#1f1f1f] text-[11px] text-white/68">
|
||
{generationProvider === "kittycad-cad"
|
||
? "KCL-ready"
|
||
: modelVersion}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
className="gap-2 rounded-[8px] border-white/10 bg-transparent text-white transition-all duration-150 ease-out hover:bg-white/8"
|
||
onClick={() => setPrompt("")}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
清空输入
|
||
</Button>
|
||
<Button
|
||
className="gap-2 rounded-[8px] border border-white/10 bg-[#f3f3f3] px-4 text-[#101010] transition-all duration-150 ease-out hover:bg-white"
|
||
disabled={isSubmitDisabled}
|
||
onClick={() => swallowAsync(handleSubmit())}
|
||
type="button"
|
||
>
|
||
{isSubmitting ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Send className="h-4 w-4" />
|
||
)}
|
||
{willSubmitAsIteration
|
||
? "继续修改当前版本"
|
||
: "生成新版本"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<p className="mt-3 text-white/40 text-xs leading-5">
|
||
AI-CAD Agent
|
||
可能出错,请始终检查尺寸、约束、装配关系和几何结果。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</Panel>
|
||
|
||
<AiCadPanelResizeHandle />
|
||
|
||
<Panel
|
||
className="min-h-0"
|
||
collapsedSize={panelDirection === "horizontal" ? 11 : 12}
|
||
collapsible
|
||
defaultSize={19}
|
||
minSize={panelDirection === "horizontal" ? 16 : 18}
|
||
onCollapse={() => setIsHistoryPanelCollapsed(true)}
|
||
onExpand={() => setIsHistoryPanelCollapsed(false)}
|
||
order={1}
|
||
ref={historyPanelRef}
|
||
>
|
||
{isHistoryPanelCollapsed ? (
|
||
<AiCadCollapsedPanel
|
||
direction={panelDirection}
|
||
icon={History}
|
||
onExpand={() => historyPanelRef.current?.expand()}
|
||
title="Feature Tree"
|
||
/>
|
||
) : (
|
||
<Card className="flex h-full min-h-0 flex-col rounded-[8px] border-white/10 bg-[#1d1d1d] text-white shadow-none">
|
||
<CardHeader className="border-white/10 border-b bg-[#232323] pb-2.5">
|
||
<CardTitle className="flex items-center gap-2 text-[15px]">
|
||
<History className="h-4 w-4 text-white/72" />
|
||
Feature Tree
|
||
</CardTitle>
|
||
<CardDescription className="text-[12px] text-white/42">
|
||
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-[8px] border border-white/10 bg-[#1b1b1b] p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="flex items-center gap-2 font-semibold text-sm text-white">
|
||
<GitBranch className="h-4 w-4 text-white/55" />
|
||
Tree Summary
|
||
</div>
|
||
<p className="mt-1 text-white/45 text-xs leading-5">
|
||
当前项目的 root、branch 和 variant 概况。
|
||
</p>
|
||
</div>
|
||
{activeVersion ? (
|
||
<Badge className="rounded-[8px] border-white/10 bg-[#232323] text-[11px] text-white/70">
|
||
depth {activeVersionLineage.length}
|
||
</Badge>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<Badge className="rounded-[8px] border-white/10 bg-white/5 text-[11px] text-white/70">
|
||
roots {rootVersionCount}
|
||
</Badge>
|
||
<Badge className="rounded-[8px] border-white/10 bg-white/5 text-[11px] text-white/70">
|
||
branches {branchPointCount}
|
||
</Badge>
|
||
<Badge className="rounded-[8px] border-white/10 bg-white/5 text-[11px] text-white/70">
|
||
variants {derivedVariantCount}
|
||
</Badge>
|
||
</div>
|
||
|
||
{activeVersionLineage.length > 0 ? (
|
||
<div className="mt-2.5">
|
||
<div className="mb-2 text-[11px] text-white/40 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-white/20 bg-[#2a2a2a] text-white"
|
||
: "border-white/10 bg-white/6 text-white/72 hover:border-white/15 hover:bg-white/10 hover:text-white"
|
||
)}
|
||
key={lineageVersion.id}
|
||
onClick={() =>
|
||
setActiveVersionId(
|
||
lineageVersion.id
|
||
)
|
||
}
|
||
type="button"
|
||
>
|
||
V{lineageVersion.versionNumber}
|
||
{lineageVersion.generationMode ===
|
||
"iterate"
|
||
? " · 变体"
|
||
: " · 起点"}
|
||
</button>
|
||
)
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{versions.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center rounded-[8px] border border-white/12 border-dashed bg-white/4 px-5 py-12 text-center">
|
||
<Box className="h-10 w-10 text-white/20" />
|
||
<p className="mt-4 font-medium text-sm text-white/80">
|
||
还没有版本记录
|
||
</p>
|
||
<p className="mt-2 text-white/45 text-xs leading-5">
|
||
从左侧发起第一轮描述后,这里会自动堆叠版本历史。
|
||
</p>
|
||
</div>
|
||
) : 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-[8px] border text-left transition-all duration-150 ease-out",
|
||
isActive
|
||
? "border-white/20 bg-[#262626]"
|
||
: "border-white/10 bg-[#1f1f1f] hover:border-white/15 hover:bg-[#252525]"
|
||
)}
|
||
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-white/8" />
|
||
) : 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-white text-xs uppercase tracking-[0.16em]">
|
||
V{version.versionNumber}
|
||
</span>
|
||
<span className="text-[11px] text-white/38 uppercase tracking-[0.14em]">
|
||
{parentVersion
|
||
? "feature"
|
||
: "root"}
|
||
</span>
|
||
{version.generationMode ===
|
||
"iterate" ? (
|
||
<span className="text-[11px] text-white/45">
|
||
variant
|
||
</span>
|
||
) : null}
|
||
{isOnActiveLineage ? (
|
||
<span className="text-[11px] text-white/45">
|
||
active
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<p className="mt-1 line-clamp-1 text-[12px] text-white/76 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-300" />
|
||
) : version.status ===
|
||
"success" ? (
|
||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-300" />
|
||
) : version.status ===
|
||
"failed" ? (
|
||
<XCircle className="h-3.5 w-3.5 text-rose-300" />
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-[10px] text-white/42 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-white/10 bg-[#1b1b1b] text-[10px] text-white/60"
|
||
key={`${version.id}-${insight.key}`}
|
||
>
|
||
{insight.shortLabel}
|
||
</Badge>
|
||
))}
|
||
{versionSemanticInsights.length >
|
||
2 ? (
|
||
<Badge className="rounded-[8px] border-white/10 bg-[#1b1b1b] text-[10px] text-white/54">
|
||
+
|
||
{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-[11px] text-white/45">
|
||
<span>生成进度</span>
|
||
<span>
|
||
{Math.round(version.progress)}%
|
||
</span>
|
||
</div>
|
||
<div className="h-1.5 overflow-hidden rounded-full bg-white/10">
|
||
<div
|
||
className="h-full rounded-full bg-gradient-to-r from-sky-400 to-cyan-300"
|
||
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-white/10 bg-[#1b1b1b] text-white/45 transition-all duration-150 ease-out hover:border-white/15 hover:bg-[#262626] hover:text-white"
|
||
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-white/10 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-white/10 bg-[#1b1b1b] px-3 py-1.5 text-white/65 text-xs transition-all duration-150 ease-out hover:border-white/15 hover:bg-[#262626] hover:text-white"
|
||
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-400/25 bg-emerald-400/12 text-emerald-100"
|
||
: "border-white/10 bg-white/6 text-white/65 hover:border-white/15 hover:bg-emerald-400/8 hover:text-white",
|
||
!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-400/25 bg-rose-400/12 text-rose-100"
|
||
: "border-white/10 bg-white/6 text-white/65 hover:border-white/15 hover:bg-rose-400/8 hover:text-white",
|
||
!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-white/10 bg-[#1b1b1b] px-3 py-1.5 text-white/70 text-xs transition-all duration-150 ease-out hover:border-white/15 hover:bg-[#262626] hover:text-white"
|
||
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-white/10 bg-black/15 px-3 py-3">
|
||
<div className="text-[11px] text-white/40 uppercase tracking-[0.16em]">
|
||
执行方式
|
||
</div>
|
||
<div className="mt-2 text-sm text-white/85">
|
||
{version.generationMode ===
|
||
"iterate"
|
||
? "基于现有版本连续修改"
|
||
: "从自然语言创建新版本"}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-[8px] border border-white/10 bg-black/15 px-3 py-3">
|
||
<div className="text-[11px] text-white/40 uppercase tracking-[0.16em]">
|
||
参考来源
|
||
</div>
|
||
<div className="mt-2 text-sm text-white/85">
|
||
{parentVersion
|
||
? `V${parentVersion.versionNumber}`
|
||
: "首轮创建"}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-[8px] border border-white/10 bg-black/15 px-3 py-3">
|
||
<div className="text-[11px] text-white/40 uppercase tracking-[0.16em]">
|
||
派生关系
|
||
</div>
|
||
<div className="mt-2 text-sm text-white/85">
|
||
{childVersions.length > 0
|
||
? `已向后派生 ${childVersions.length} 个变体`
|
||
: "当前还没有后续派生版本"}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-[8px] border border-white/10 bg-black/15 px-3 py-3">
|
||
<div className="text-[11px] text-white/40 uppercase tracking-[0.16em]">
|
||
输出文件
|
||
</div>
|
||
<div className="mt-2 text-sm text-white/85">
|
||
{versionDownloadEntries.length > 0
|
||
? versionDownloadEntries
|
||
.slice(0, 4)
|
||
.map((entry) => entry.label)
|
||
.join(" / ")
|
||
: "等待服务端返回"}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-[8px] border border-white/10 bg-black/15 px-3 py-3">
|
||
<div className="text-[11px] text-white/40 uppercase tracking-[0.16em]">
|
||
生成时间
|
||
</div>
|
||
<div className="mt-2 text-sm text-white/85">
|
||
{formatVersionDetailTime(
|
||
version.createdAt
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-[8px] border border-white/10 bg-black/15 px-3 py-3 sm:col-span-2">
|
||
<div className="text-[11px] text-white/40 uppercase tracking-[0.16em]">
|
||
设计语义
|
||
</div>
|
||
{versionSemanticInsights.length >
|
||
0 ? (
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
{versionSemanticInsights.map(
|
||
(insight) => (
|
||
<Badge
|
||
className="border-sky-400/20 bg-sky-400/10 text-sky-100"
|
||
key={`${version.id}-${insight.key}-detail`}
|
||
>
|
||
{insight.label}
|
||
</Badge>
|
||
)
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="mt-2 text-sm text-white/60">
|
||
当前还没有识别到明显的结构关注点。
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-3 rounded-[8px] border border-white/10 bg-black/15 px-3 py-3">
|
||
<div className="flex flex-wrap items-center gap-3 text-[11px] text-white/45 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-sm text-white/68 leading-6">
|
||
{version.generationMode ===
|
||
"iterate"
|
||
? "这轮更接近官方当前的连续对话建模节奏,会沿着上一版结果继续推进,而不是把每次输入都当成全新项目。"
|
||
: "这轮是一个新的 CAD 版本起点,适合把初始结构、约束和整体造型一次讲清楚。"}
|
||
</p>
|
||
</div>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</ScrollArea>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</Panel>
|
||
|
||
<AiCadPanelResizeHandle />
|
||
|
||
<Panel
|
||
className="min-h-0"
|
||
collapsedSize={panelDirection === "horizontal" ? 11 : 12}
|
||
collapsible
|
||
defaultSize={52}
|
||
minSize={panelDirection === "horizontal" ? 34 : 22}
|
||
onCollapse={() => setIsResultPanelCollapsed(true)}
|
||
onExpand={() => setIsResultPanelCollapsed(false)}
|
||
order={2}
|
||
ref={resultPanelRef}
|
||
>
|
||
{isResultPanelCollapsed ? (
|
||
<AiCadCollapsedPanel
|
||
direction={panelDirection}
|
||
icon={Orbit}
|
||
onExpand={() => resultPanelRef.current?.expand()}
|
||
title="建模画布"
|
||
/>
|
||
) : (
|
||
<Card className="flex h-full min-h-0 flex-col rounded-[10px] border-white/10 bg-[#1d1d1d] text-white shadow-none">
|
||
<CardHeader className="border-white/10 border-b bg-[#232323] pb-3">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<CardTitle className="flex items-center gap-2 text-base">
|
||
<Orbit className="h-4 w-4 text-white/72" />
|
||
建模画布
|
||
</CardTitle>
|
||
<CardDescription className="mt-1 text-white/45">
|
||
把中间区域收成更接近官方的大画布,预览优先,代码与对照放到下层。
|
||
</CardDescription>
|
||
</div>
|
||
{activeVersion ? (
|
||
<Badge className="rounded-md border-white/10 bg-[#1b1b1b] text-white/75">
|
||
V{activeVersion.versionNumber}
|
||
</Badge>
|
||
) : null}
|
||
</div>
|
||
</CardHeader>
|
||
|
||
<CardContent className="min-h-0 flex-1 overflow-hidden p-0">
|
||
<ScrollArea className="h-full">
|
||
<div className="space-y-4 p-4">
|
||
<div className="grid gap-3 sm:grid-cols-3">
|
||
<div className="rounded-[8px] border border-white/10 bg-white/5 px-4 py-3">
|
||
<div className="text-white/45 text-xs uppercase tracking-[0.16em]">
|
||
视图环境
|
||
</div>
|
||
<div className="mt-2 font-medium text-white">
|
||
{viewerSettings.envPreset}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-[8px] border border-white/10 bg-white/5 px-4 py-3">
|
||
<div className="text-white/45 text-xs uppercase tracking-[0.16em]">
|
||
结果状态
|
||
</div>
|
||
<div className="mt-2 font-medium text-white">
|
||
{activeVersion
|
||
? getStatusLabel(activeVersion.status)
|
||
: "等待生成"}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-[8px] border border-white/10 bg-white/5 px-4 py-3">
|
||
<div className="text-white/45 text-xs uppercase tracking-[0.16em]">
|
||
输出文件
|
||
</div>
|
||
<div className="mt-2 font-medium text-white">
|
||
{activeDownloadEntries.length > 0
|
||
? activeDownloadEntries
|
||
.slice(0, 3)
|
||
.map((item) => item.label)
|
||
.join(" / ")
|
||
: "暂无"}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{previewSourceVersion ? (
|
||
<div className="rounded-[8px] border border-sky-400/20 bg-sky-400/10 px-4 py-3 text-sky-100 text-sm leading-6">
|
||
当前版本先返回了代码结果,右侧 3D 预览暂时沿用 V
|
||
{previewSourceVersion.versionNumber} 作为参考。
|
||
</div>
|
||
) : null}
|
||
|
||
{activeVersion && activeExecutionSummary ? (
|
||
<div className="rounded-[8px] border border-white/10 bg-white/4 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-white/70 hover:text-white">
|
||
<span className="flex items-center gap-2">
|
||
<BrainCircuit className="h-4 w-4 text-white/55" />
|
||
执行摘要
|
||
</span>
|
||
</ReasoningTrigger>
|
||
<ReasoningContent className="text-white/70">
|
||
{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-white/10 bg-[#171717] p-1">
|
||
<TabsTrigger
|
||
className="gap-2 text-white/72 data-[state=active]:bg-[#2a2a2a] data-[state=active]:text-white"
|
||
value="preview"
|
||
>
|
||
<Orbit className="h-4 w-4" />
|
||
3D 预览
|
||
</TabsTrigger>
|
||
<TabsTrigger
|
||
className="gap-2 text-white/72 data-[state=active]:bg-[#2a2a2a] data-[state=active]:text-white"
|
||
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-[8px] border border-white/10 bg-white/4 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-sm text-white">
|
||
<GitBranch className="h-4 w-4 text-white/55" />
|
||
双版本对照
|
||
</div>
|
||
<p className="mt-1 text-white/45 text-xs leading-5">
|
||
优先推荐父版本、同层变体和当前链路上的近邻版本,方便直接比对这轮修改有没有偏离。
|
||
</p>
|
||
</div>
|
||
{compareVersion ? (
|
||
<Badge className="border-white/10 bg-white/6 text-white/72">
|
||
当前对照 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-white/20 bg-[#2a2a2a] text-white"
|
||
: "border-white/10 bg-white/6 text-white/72 hover:bg-white/10 hover:text-white"
|
||
)}
|
||
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-[10px] border border-white/10 bg-[#171717]">
|
||
<div className="flex items-center justify-between border-white/10 border-b bg-[#232323] px-4 py-3">
|
||
<div>
|
||
<div className="font-medium text-sm text-white">
|
||
{activeVersion
|
||
? `当前版本 V${activeVersion.versionNumber}`
|
||
: "当前版本"}
|
||
</div>
|
||
<p className="mt-1 text-white/45 text-xs">
|
||
当前工作焦点
|
||
</p>
|
||
</div>
|
||
{activeVersion ? (
|
||
<Badge className="rounded-md border-white/10 bg-[#1b1b1b] text-white/72">
|
||
{getStatusLabel(
|
||
activeVersion.status
|
||
)}
|
||
</Badge>
|
||
) : null}
|
||
</div>
|
||
<div className="min-h-[360px]">
|
||
<Model3DViewer
|
||
emptyStateTitle={
|
||
activeVersion?.codeUrl
|
||
? "当前版本已生成 KCL,可先切到代码视图查看;如果后续返回 3D 文件,这里会自动切换预览。"
|
||
: "生成完成后,这里会显示当前版本的 3D 预览"
|
||
}
|
||
modelUrl={
|
||
activePreviewVersion?.modelUrl
|
||
}
|
||
previewImageUrl={
|
||
activePreviewVersion?.previewImageUrl
|
||
}
|
||
settings={viewerSettings}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{compareVersion ? (
|
||
<div className="overflow-hidden rounded-[10px] border border-white/10 bg-[#171717]">
|
||
<div className="flex items-center justify-between border-white/10 border-b bg-[#232323] px-4 py-3">
|
||
<div>
|
||
<div className="font-medium text-sm text-white">
|
||
对照版本 V
|
||
{compareVersion.versionNumber}
|
||
</div>
|
||
<p className="mt-1 text-white/45 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-white/10 bg-[#1b1b1b] px-3 py-1.5 text-white/72 text-xs transition hover:bg-[#262626] hover:text-white"
|
||
onClick={() =>
|
||
setActiveVersionId(
|
||
compareVersion.id
|
||
)
|
||
}
|
||
type="button"
|
||
>
|
||
查看该版本
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="min-h-[360px]">
|
||
<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-[8px] border border-white/8 bg-white/4 px-3 py-2.5"
|
||
key={highlight}
|
||
>
|
||
<CheckCircle2 className="mt-0.5 h-4 w-4 flex-none text-emerald-300" />
|
||
<p className="text-sm text-white/72 leading-6">
|
||
{highlight}
|
||
</p>
|
||
</div>
|
||
)
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</TabsContent>
|
||
|
||
<TabsContent
|
||
className="mt-4 min-h-[360px]"
|
||
value="code"
|
||
>
|
||
<div className="space-y-4">
|
||
{compareVersion ? (
|
||
<div className="rounded-[8px] border border-white/10 bg-white/4 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-sm text-white">
|
||
<FileCode2 className="h-4 w-4 text-white/55" />
|
||
KCL 双版本对照
|
||
</div>
|
||
<button
|
||
className={cn(
|
||
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition",
|
||
showOnlyChangedCodeRows
|
||
? "border-white/20 bg-[#2a2a2a] text-white"
|
||
: "border-white/10 bg-white/6 text-white/72 hover:bg-white/10 hover:text-white"
|
||
)}
|
||
onClick={() =>
|
||
setShowOnlyChangedCodeRows(
|
||
(current) => !current
|
||
)
|
||
}
|
||
type="button"
|
||
>
|
||
{showOnlyChangedCodeRows
|
||
? "显示全部"
|
||
: "只看变更"}
|
||
</button>
|
||
</div>
|
||
<p className="mt-2 text-white/45 text-xs leading-5">
|
||
这里会并排显示当前版本和对照版本的代码结果,方便检查这轮连续迭代到底改动了哪些约束和结构表达。
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
|
||
{compareVersion ? (
|
||
<div className="flex flex-wrap gap-2">
|
||
<Badge className="border-emerald-400/20 bg-emerald-400/10 text-emerald-100">
|
||
新增 {codeDiffResult.summary.added}
|
||
</Badge>
|
||
<Badge className="border-rose-400/20 bg-rose-400/10 text-rose-100">
|
||
删除 {codeDiffResult.summary.removed}
|
||
</Badge>
|
||
<Badge className="border-amber-400/20 bg-amber-400/10 text-amber-100">
|
||
修改 {codeDiffResult.summary.changed}
|
||
</Badge>
|
||
<Badge className="border-white/10 bg-white/6 text-white/70">
|
||
未变 {codeDiffResult.summary.unchanged}
|
||
</Badge>
|
||
</div>
|
||
) : null}
|
||
|
||
{compareVersion &&
|
||
semanticCodeDiffInsights.length > 0 ? (
|
||
<div className="rounded-[8px] border border-[#7dd3fc]/18 bg-[#071019] 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-sm text-white">
|
||
<BrainCircuit className="h-4 w-4 text-sky-300" />
|
||
设计语义摘要
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{semanticCodeDiffInsights.map(
|
||
(insight) => (
|
||
<Badge
|
||
className="border-sky-400/20 bg-sky-400/10 text-sky-100"
|
||
key={insight.key}
|
||
>
|
||
{insight.shortLabel}{" "}
|
||
{insight.count}
|
||
</Badge>
|
||
)
|
||
)}
|
||
</div>
|
||
</div>
|
||
<p className="mt-2 text-white/45 text-xs leading-5">
|
||
这层摘要会优先把 KCL
|
||
里的差异翻译成更接近设计语言的改动方向,方便先判断本轮是不是在改你关心的结构。
|
||
</p>
|
||
<div className="mt-4 grid gap-3 xl:grid-cols-2">
|
||
{semanticCodeDiffInsights.map(
|
||
(insight) => (
|
||
<div
|
||
className="rounded-[8px] border border-white/8 bg-white/4 px-4 py-3"
|
||
key={insight.key}
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="font-medium text-sm text-white">
|
||
{insight.label}
|
||
</div>
|
||
<p className="mt-1 text-white/50 text-xs">
|
||
{insight.detail}
|
||
</p>
|
||
</div>
|
||
<Badge className="border-white/10 bg-white/8 text-white/72">
|
||
命中 {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-white/10 bg-black/20 px-3 py-1 text-[11px] text-white/70"
|
||
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-[8px] border border-white/8 bg-white/4 px-3 py-2.5"
|
||
key={insight}
|
||
>
|
||
<CheckCircle2 className="mt-0.5 h-4 w-4 flex-none text-sky-300" />
|
||
<p className="text-sm text-white/72 leading-6">
|
||
{insight}
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
{codeActionError ? (
|
||
<div className="rounded-[8px] border border-rose-400/20 bg-rose-400/10 px-4 py-2 text-rose-100 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-[10px] border border-white/10 bg-[#171717]">
|
||
<div className="flex flex-wrap items-center justify-between gap-3 border-white/10 border-b bg-[#232323] px-4 py-3">
|
||
<div>
|
||
<div className="font-medium text-sm text-white">
|
||
{activeVersion
|
||
? `当前版本 V${activeVersion.versionNumber} KCL`
|
||
: "KCL 代码"}
|
||
</div>
|
||
<p className="mt-1 text-white/45 text-xs">
|
||
{activeCodeLineCount > 0
|
||
? `当前共 ${activeCodeLineCount} 行。`
|
||
: "连续迭代返回的代码会优先显示在这里。"}
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{activeCodeUrl ? (
|
||
<Button
|
||
asChild
|
||
className="gap-2 rounded-full border-white/10 bg-transparent text-white hover:bg-white/8"
|
||
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-[8px] border border-white/10 bg-black/20 text-white/60">
|
||
<div className="flex items-center gap-2 text-sm">
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
正在加载当前版本 KCL...
|
||
</div>
|
||
</div>
|
||
) : activeCodePreviewError ? (
|
||
<div className="rounded-[8px] border border-rose-400/20 bg-rose-400/10 px-4 py-5 text-rose-100 text-sm leading-6">
|
||
当前版本 KCL 暂时无法预览:
|
||
{activeCodePreviewError}
|
||
</div>
|
||
) : activeCodeContent ? (
|
||
<CodeBlock
|
||
className="overflow-hidden rounded-[8px] border-white/10 bg-black/30"
|
||
code={activeCodeContent}
|
||
language="python"
|
||
showLineNumbers
|
||
>
|
||
<CodeBlockCopyButton
|
||
className="h-8 w-8 rounded-full border border-white/10 bg-black/45 text-white hover:bg-white/10 hover:text-white"
|
||
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-[8px] border border-white/10 border-dashed bg-black/20 px-5 py-10 text-center text-sm text-white/45 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-[10px] border border-white/10 bg-white/5 p-4">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<div className="font-semibold text-sm text-white">
|
||
{activeVersion
|
||
? `V${activeVersion.versionNumber} 设计摘要`
|
||
: "当前没有激活版本"}
|
||
</div>
|
||
<p className="mt-2 text-sm text-white/65 leading-6">
|
||
{activeVersion
|
||
? activeVersion.prompt
|
||
: "在左侧输入你的 CAD 需求,工作台会开始生成第一版。"}
|
||
</p>
|
||
{activeVersion ? (
|
||
<div className="mt-3 space-y-1 text-white/45 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-white text-black hover:bg-white/90"
|
||
>
|
||
<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-white/10" />
|
||
<p className="text-rose-200/90 text-sm leading-6">
|
||
{activeVersion.errorMessage}
|
||
</p>
|
||
</>
|
||
) : null}
|
||
|
||
{activeDownloadEntries.length > 1 ? (
|
||
<>
|
||
<Separator className="my-4 bg-white/10" />
|
||
<div className="flex flex-wrap gap-2">
|
||
{activeDownloadEntries
|
||
.slice(0, 6)
|
||
.map((entry) => (
|
||
<Button
|
||
asChild
|
||
className="rounded-full border-white/10 bg-transparent text-white hover:bg-white/8"
|
||
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-white/10" />
|
||
<div className="rounded-[8px] border border-white/10 bg-black/15 p-4">
|
||
<div className="mb-3 flex items-center gap-2 font-semibold text-sm text-white">
|
||
<RotateCcw className="h-4 w-4 text-white/55" />
|
||
版本对比
|
||
</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-white/20 bg-[#2a2a2a] text-white"
|
||
: "border-white/10 bg-white/6 text-white/72 hover:bg-white/10 hover:text-white"
|
||
)}
|
||
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-[8px] border border-white/10 bg-white/5 p-4">
|
||
<div className="flex items-center gap-2 text-white/45 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-sm text-white">
|
||
{compareVersion
|
||
? truncateText(
|
||
compareVersion.prompt,
|
||
84
|
||
)
|
||
: "当前版本是这条设计链的起点,还没有可供对照的其他版本。"}
|
||
</p>
|
||
{compareVersion ? (
|
||
<>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<Badge className="border-white/10 bg-white/6 text-white/72">
|
||
{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-white/10 bg-black/25 text-white/65"
|
||
key={`compare-${directive.key}`}
|
||
>
|
||
{directive.label}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="rounded-[8px] border border-white/10 bg-[#242424] p-4">
|
||
<div className="flex items-center gap-2 text-white/55 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-sm text-white">
|
||
{truncateText(
|
||
activeVersion.prompt,
|
||
84
|
||
)}
|
||
</p>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<Badge className="rounded-md border-white/10 bg-[#1b1b1b] text-white/72">
|
||
{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-white/10 bg-[#1b1b1b] text-white/72">
|
||
连续修改
|
||
</Badge>
|
||
) : null}
|
||
</div>
|
||
{activeVersionStructuredDirectives.length >
|
||
0 ? (
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{activeVersionStructuredDirectives.map(
|
||
(directive) => (
|
||
<Badge
|
||
className="border-white/10 bg-[#1b1b1b] text-white/72"
|
||
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-[8px] border border-white/8 bg-white/4 px-3 py-2.5"
|
||
key={note}
|
||
>
|
||
<CheckCircle2 className="mt-0.5 h-4 w-4 flex-none text-emerald-300" />
|
||
<p className="text-sm text-white/72 leading-6">
|
||
{note}
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="rounded-[10px] border border-white/10 bg-white/5 p-4">
|
||
<div className="mb-4 flex items-center gap-2 font-semibold text-sm text-white">
|
||
<Grid3X3 className="h-4 w-4 text-sky-300" />
|
||
查看器设置
|
||
</div>
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<div className="text-white/45 text-xs uppercase tracking-[0.16em]">
|
||
环境贴图
|
||
</div>
|
||
<Select
|
||
onValueChange={(value) =>
|
||
setViewerSettings((current) => ({
|
||
...current,
|
||
envPreset:
|
||
value as Model3DViewerSettings["envPreset"],
|
||
}))
|
||
}
|
||
value={viewerSettings.envPreset}
|
||
>
|
||
<SelectTrigger className="border-white/10 bg-white/5 text-white">
|
||
<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-[8px] border border-white/10 bg-black/10 px-3 py-3">
|
||
<div>
|
||
<div className="text-sm text-white">
|
||
自动旋转
|
||
</div>
|
||
<div className="text-white/45 text-xs">
|
||
更像产品展示台
|
||
</div>
|
||
</div>
|
||
<Switch
|
||
checked={viewerSettings.autoRotate}
|
||
onCheckedChange={(checked) =>
|
||
setViewerSettings((current) => ({
|
||
...current,
|
||
autoRotate: checked,
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between rounded-[8px] border border-white/10 bg-black/10 px-3 py-3">
|
||
<div>
|
||
<div className="text-sm text-white">
|
||
网格地面
|
||
</div>
|
||
<div className="text-white/45 text-xs">
|
||
方便观察尺度
|
||
</div>
|
||
</div>
|
||
<Switch
|
||
checked={viewerSettings.showGrid}
|
||
onCheckedChange={(checked) =>
|
||
setViewerSettings((current) => ({
|
||
...current,
|
||
showGrid: checked,
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between rounded-[8px] border border-white/10 bg-black/10 px-3 py-3">
|
||
<div>
|
||
<div className="text-sm text-white">
|
||
线框模式
|
||
</div>
|
||
<div className="text-white/45 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-white/10 bg-transparent text-white hover:bg-white/8"
|
||
variant="outline"
|
||
>
|
||
<a
|
||
href={primaryDownloadEntry.url}
|
||
rel="noreferrer"
|
||
target="_blank"
|
||
>
|
||
<ArrowUpRight className="h-4 w-4" />
|
||
新窗口打开结果
|
||
</a>
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</ScrollArea>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</Panel>
|
||
</PanelGroup>
|
||
</div>
|
||
|
||
<div className="border-white/10 border-t bg-[#202020] px-3 py-2">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<AiCadStatusChip
|
||
label="Engine"
|
||
value={getProviderLabel(generationProvider)}
|
||
/>
|
||
<AiCadStatusChip
|
||
label="Focus"
|
||
value={
|
||
activeVersion
|
||
? `V${activeVersion.versionNumber}`
|
||
: "No focus"
|
||
}
|
||
/>
|
||
<AiCadStatusChip
|
||
label="State"
|
||
tone={
|
||
hasPendingTasks
|
||
? "pending"
|
||
: activeVersion?.status === "success"
|
||
? "success"
|
||
: "neutral"
|
||
}
|
||
value={
|
||
activeVersion
|
||
? getStatusLabel(activeVersion.status)
|
||
: "Ready to prompt"
|
||
}
|
||
/>
|
||
<AiCadStatusChip
|
||
label="View"
|
||
value={
|
||
activeResultTab === "preview" ? "3D preview" : "KCL view"
|
||
}
|
||
/>
|
||
<AiCadStatusChip label="Env" value={viewerSettings.envPreset} />
|
||
<AiCadStatusChip
|
||
label="Tasks"
|
||
tone={hasPendingTasks ? "warning" : "neutral"}
|
||
value={
|
||
hasPendingTasks
|
||
? `${pendingTaskCount} active`
|
||
: "Workspace idle"
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|