From 413b5ffac1d75b112e60f04fd232242020ef8d28 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 6 Apr 2026 14:46:51 +0800 Subject: [PATCH] feat: add AI-CAD workspace and KittyCAD integration --- .env.example | 5 + .../settings/admin-settings-client.tsx | 256 + app/(chat)/ai-cad/page.tsx | 5 + app/api/admin/ai-providers/route.ts | 4 + app/api/admin/kittycad/test/route.ts | 148 + app/api/ai-cad/provider/route.ts | 27 + app/api/ai-cad/tasks/[taskId]/route.ts | 391 ++ app/api/ai-cad/tasks/route.ts | 309 + app/api/ai-cad/workspace-utils.ts | 187 + app/api/ai-cad/workspace/route.ts | 89 + .../ai-cad/workspaces/[workspaceId]/route.ts | 114 + app/api/ai-cad/workspaces/route.ts | 69 + components/ai-cad/ai-cad-workbench.tsx | 6239 +++++++++++++++++ components/top-navbar.tsx | 615 +- lib/ai/provider-defaults.ts | 1 + lib/db/migrate.ts | 3 +- .../0065_add_kittycad_provider_type.sql | 32 + .../migrations/0066_add_ai_cad_workspace.sql | 23 + .../0067_add_ai_cad_multi_workspace.sql | 5 + lib/db/migrations/meta/_journal.json | 21 + lib/db/queries.ts | 152 + lib/db/schema.ts | 28 +- lib/kittycad/client.ts | 134 + lib/kittycad/server.ts | 94 + services/ai-cad/index.ts | 193 + tsconfig.tsbuildinfo | 2 +- 26 files changed, 8900 insertions(+), 246 deletions(-) create mode 100644 app/(chat)/ai-cad/page.tsx create mode 100644 app/api/admin/kittycad/test/route.ts create mode 100644 app/api/ai-cad/provider/route.ts create mode 100644 app/api/ai-cad/tasks/[taskId]/route.ts create mode 100644 app/api/ai-cad/tasks/route.ts create mode 100644 app/api/ai-cad/workspace-utils.ts create mode 100644 app/api/ai-cad/workspace/route.ts create mode 100644 app/api/ai-cad/workspaces/[workspaceId]/route.ts create mode 100644 app/api/ai-cad/workspaces/route.ts create mode 100644 components/ai-cad/ai-cad-workbench.tsx create mode 100644 lib/db/migrations/0065_add_kittycad_provider_type.sql create mode 100644 lib/db/migrations/0066_add_ai_cad_workspace.sql create mode 100644 lib/db/migrations/0067_add_ai_cad_multi_workspace.sql create mode 100644 lib/kittycad/client.ts create mode 100644 lib/kittycad/server.ts create mode 100644 services/ai-cad/index.ts diff --git a/.env.example b/.env.example index e630e0a3f..ac14a3f53 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,8 @@ GTP5_MODEL=gpt-5-nano # 优先使用系统设置-本地AI里的 `tripo-3d` 提供商;若未配置则回退到环境变量 TRIPO_API_KEY=your-tripo-api-key TRIPO_BASE_URL=https://api.tripo3d.ai/v2/openapi + +# KittyCAD / Zoo Text-to-CAD(可选) +# 优先使用系统设置-本地AI里的 `kittycad-cad` 提供商;若未配置则回退到环境变量 +KITTYCAD_API_TOKEN=your-kittycad-api-token +KITTYCAD_BASE_URL=https://api.zoo.dev diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index a54f61a3f..4bad711f0 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -331,6 +331,7 @@ type AiProvider = { | "zhipu" | "tripo-3d" | "worldlabs-3d" + | "kittycad-cad" | "runway" | "volcano-multimodal"; baseUrl: string; @@ -655,6 +656,7 @@ const PROVIDER_TYPES = [ { value: "midjourney", label: "Midjourney" }, { value: "tripo-3d", label: "Tripo3D 模型生成" }, { value: "worldlabs-3d", label: "World Labs 3D 世界生成" }, + { value: "kittycad-cad", label: "KittyCAD / Zoo CAD" }, { value: "runway", label: "Runway API" }, { value: "zhipu", label: "智普官方 4.7" }, { value: "volcano-multimodal", label: "火山引擎多模态" }, @@ -683,6 +685,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record = { "fal-video": "https://fal.run", "tripo-3d": "https://api.tripo3d.ai/v2/openapi", "worldlabs-3d": "https://api.worldlabs.ai", + "kittycad-cad": "https://api.zoo.dev", runway: "https://api.dev.runwayml.com", zhipu: "https://open.bigmodel.cn/api/paas/v4", "volcano-multimodal": "https://ark.cn-beijing.volces.com/api/v3", @@ -1371,6 +1374,14 @@ export function AdminSettingsClient() { const [worldlabsProviderBusyId, setWorldlabsProviderBusyId] = useState< string | null >(null); + const [kittycadCadConfig, setKittycadCadConfig] = useState({ + providerId: "", + name: "KittyCAD CAD", + baseUrl: getProviderDefaultBaseUrl("kittycad-cad"), + apiKey: "", + isEnabled: true, + }); + const [isTestingKittycadCad, setIsTestingKittycadCad] = useState(false); const [moonshotDetailAgentConfig, setMoonshotDetailAgentConfig] = useState({ providerId: "", modelEntryId: "", @@ -1902,6 +1913,32 @@ export function AdminSettingsClient() { [worldlabsProviders] ); + useEffect(() => { + const kittycadProvider = (providersData?.providers || []).find( + (provider) => provider.type === "kittycad-cad" + ); + if (kittycadProvider) { + setKittycadCadConfig({ + providerId: kittycadProvider.id, + name: kittycadProvider.name || "KittyCAD CAD", + baseUrl: + kittycadProvider.baseUrl || + getProviderDefaultBaseUrl("kittycad-cad"), + apiKey: "", + isEnabled: kittycadProvider.isEnabled, + }); + return; + } + + setKittycadCadConfig({ + providerId: "", + name: "KittyCAD CAD", + baseUrl: getProviderDefaultBaseUrl("kittycad-cad"), + apiKey: "", + isEnabled: true, + }); + }, [providersData?.providers]); + // 导航链接状态 const [navLinkForm, setNavLinkForm] = useState({ id: "", @@ -2217,6 +2254,7 @@ export function AdminSettingsClient() { | "zhipu" | "tripo-3d" | "worldlabs-3d" + | "kittycad-cad" | "runway" | "volcano-multimodal", baseUrl: "", @@ -4248,6 +4286,98 @@ export function AdminSettingsClient() { } }; + const handleSaveKittycadCadConfig = async () => { + try { + const trimmedName = kittycadCadConfig.name.trim() || "KittyCAD CAD"; + const trimmedBaseUrl = + kittycadCadConfig.baseUrl.trim() || + getProviderDefaultBaseUrl("kittycad-cad"); + const hasProvider = Boolean(kittycadCadConfig.providerId); + + if (!hasProvider && !kittycadCadConfig.apiKey.trim()) { + toast.error("请填写 KittyCAD API Token"); + return; + } + + const requestData: Record = hasProvider + ? { + id: kittycadCadConfig.providerId, + name: trimmedName, + type: "kittycad-cad", + baseUrl: trimmedBaseUrl, + isEnabled: kittycadCadConfig.isEnabled, + } + : { + name: trimmedName, + type: "kittycad-cad", + baseUrl: trimmedBaseUrl, + apiKey: kittycadCadConfig.apiKey.trim(), + isEnabled: kittycadCadConfig.isEnabled, + }; + + if (hasProvider && kittycadCadConfig.apiKey.trim()) { + requestData.apiKey = kittycadCadConfig.apiKey.trim(); + } + + const response = await fetch("/api/admin/ai-providers", { + method: hasProvider ? "PUT" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(requestData), + }); + + if (!response.ok) { + throw new Error("保存失败"); + } + + toast.success("KittyCAD CAD 配置保存成功"); + setKittycadCadConfig((prev) => ({ ...prev, apiKey: "" })); + mutateProviders(); + } catch (error) { + console.error("保存 KittyCAD CAD 配置失败:", error); + toast.error("保存 KittyCAD CAD 配置失败"); + } + }; + + const handleTestKittycadCadConfig = async () => { + try { + setIsTestingKittycadCad(true); + + const response = await fetch("/api/admin/kittycad/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + config: { + providerId: kittycadCadConfig.providerId || undefined, + name: kittycadCadConfig.name.trim() || "KittyCAD CAD", + baseUrl: + kittycadCadConfig.baseUrl.trim() || + getProviderDefaultBaseUrl("kittycad-cad"), + apiKey: kittycadCadConfig.apiKey.trim() || undefined, + }, + }), + }); + + const payload = await response.json().catch(() => null); + if (!response.ok) { + throw new Error( + payload?.cause || payload?.message || payload?.error || "连接测试失败" + ); + } + + const accountLabel = + payload?.userName || payload?.userEmail || payload?.userId || "当前账号"; + toast.success( + `连接成功:${accountLabel}(${payload?.latencyMs || 0}ms)` + ); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "KittyCAD 连接测试失败" + ); + } finally { + setIsTestingKittycadCad(false); + } + }; + const handleSaveMoonshotDetailAgentConfig = async () => { try { const trimmedBaseUrl = @@ -6430,6 +6560,7 @@ export function AdminSettingsClient() { midjourney: "bg-fuchsia-600", "tripo-3d": "bg-teal-500", "worldlabs-3d": "bg-sky-600", + "kittycad-cad": "bg-orange-500", openai: "bg-green-500", zhipu: "bg-lime-500", "volcano-multimodal": "bg-fuchsia-500", @@ -12716,6 +12847,131 @@ export function AdminSettingsClient() { + + +
+ + KittyCAD CAD API 配置 + + + 配置 AI-CAD 页面后续接入的 KittyCAD / Zoo Text-to-CAD + 服务,保存后写入数据库 AI 提供商配置。 + +
+ toggleLocalAiSection("kittycad-cad")} + /> +
+ +
+ + + setKittycadCadConfig({ + ...kittycadCadConfig, + name: e.target.value, + }) + } + placeholder="KittyCAD CAD" + /> +
+
+ + + setKittycadCadConfig({ + ...kittycadCadConfig, + baseUrl: e.target.value, + }) + } + placeholder={getProviderDefaultBaseUrl("kittycad-cad")} + /> +
+ 默认:{getProviderDefaultBaseUrl("kittycad-cad")} +
+
+
+ + + setKittycadCadConfig({ + ...kittycadCadConfig, + apiKey: e.target.value, + }) + } + placeholder={ + kittycadCadConfig.providerId + ? "留空则不修改当前 Token" + : "请输入 KittyCAD / Zoo API Token" + } + /> +
+
+
+ +
+ 关闭后 AI-CAD 页面将无法切换到外部 CAD 服务 +
+
+
+ + setKittycadCadConfig({ + ...kittycadCadConfig, + isEnabled: checked, + }) + } + /> + + {kittycadCadConfig.isEnabled ? "已开启" : "已关闭"} + +
+
+
+

• 默认接入地址为 `https://api.zoo.dev`。

+

+ • 建议填写 Zoo 官方 API Token;如果你有自建兼容服务,也可以改成自定义地址。 +

+

+ • 测试连接只会校验当前账号和 Token,不会创建 CAD 任务,也不会消耗生成额度。 +

+
+
+ + +
+
+
+ {/* 抠图API配置 */} diff --git a/app/(chat)/ai-cad/page.tsx b/app/(chat)/ai-cad/page.tsx new file mode 100644 index 000000000..64c8a3f21 --- /dev/null +++ b/app/(chat)/ai-cad/page.tsx @@ -0,0 +1,5 @@ +import { AiCadWorkbench } from "@/components/ai-cad/ai-cad-workbench"; + +export default function AiCadPage() { + return ; +} diff --git a/app/api/admin/ai-providers/route.ts b/app/api/admin/ai-providers/route.ts index 170608b62..c301be781 100644 --- a/app/api/admin/ai-providers/route.ts +++ b/app/api/admin/ai-providers/route.ts @@ -31,6 +31,7 @@ const providerTypes = [ "zhipu", "tripo-3d", "worldlabs-3d", + "kittycad-cad", "runway", "volcano-multimodal", ] as const; @@ -64,6 +65,7 @@ const BFL_IMAGE_BASE_URL = "https://api.bfl.ai"; const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com"; const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi"; const WORLDLABS_3D_BASE_URL = "https://api.worldlabs.ai"; +const KITTYCAD_CAD_BASE_URL = "https://api.zoo.dev"; const RUNWAY_BASE_URL = "https://api.dev.runwayml.com"; const RUNWAY_API_KEY_PATTERN = /^key_[0-9a-f]{128}$/; @@ -212,6 +214,8 @@ export async function POST(request: Request) { ? data.baseUrl || TRIPO_3D_BASE_URL : data.type === "worldlabs-3d" ? data.baseUrl || WORLDLABS_3D_BASE_URL + : data.type === "kittycad-cad" + ? data.baseUrl || KITTYCAD_CAD_BASE_URL : data.type === "runway" ? data.baseUrl || RUNWAY_BASE_URL : data.baseUrl, diff --git a/app/api/admin/kittycad/test/route.ts b/app/api/admin/kittycad/test/route.ts new file mode 100644 index 000000000..c8585247f --- /dev/null +++ b/app/api/admin/kittycad/test/route.ts @@ -0,0 +1,148 @@ +import { z } from "zod"; +import { getAiProviderById } from "@/lib/db/queries"; +import { ChatSDKError } from "@/lib/errors"; +import { KittyCadApiError, kittyCadRequest } from "@/lib/kittycad/client"; +import { getKittyCadProviderConfig } from "@/lib/kittycad/server"; +import { requireAdmin } from "@/lib/middleware/admin"; + +export const runtime = "nodejs"; + +const requestSchema = z.object({ + config: z + .object({ + providerId: z.string().uuid().optional(), + name: z.string().trim().optional(), + baseUrl: z.string().trim().optional(), + apiKey: z.string().trim().optional(), + }) + .optional(), +}); + +function pickDisplayName(user: Record) { + const candidates = [ + user.name, + user.full_name, + user.display_name, + user.username, + user.first_name, + ]; + + for (const value of candidates) { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + + return null; +} + +function pickEmail(user: Record) { + const candidates = [user.email, user.primary_email]; + + for (const value of candidates) { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + + return null; +} + +async function resolveKittyCadConfig( + input?: { + providerId?: string; + name?: string; + baseUrl?: string; + apiKey?: string; + } | null +) { + const providerId = String(input?.providerId || "").trim(); + const inputApiKey = String(input?.apiKey || "").trim(); + const inputBaseUrl = String(input?.baseUrl || "").trim(); + const inputName = String(input?.name || "").trim(); + + if (providerId) { + const savedProvider = await getAiProviderById({ id: providerId }); + if (!savedProvider) { + throw new Error("未找到对应的 KittyCAD CAD 提供商"); + } + + return { + apiKey: inputApiKey || String(savedProvider.apiKey || "").trim(), + baseUrl: inputBaseUrl || String(savedProvider.baseUrl || "").trim(), + providerId: savedProvider.id, + providerName: inputName || savedProvider.name || "KittyCAD CAD", + source: "provider" as const, + }; + } + + if (inputApiKey) { + return { + apiKey: inputApiKey, + baseUrl: inputBaseUrl || "https://api.zoo.dev", + providerName: inputName || "KittyCAD CAD", + source: "provider" as const, + }; + } + + return await getKittyCadProviderConfig(); +} + +export async function POST(request: Request) { + try { + await requireAdmin(); + + const body = requestSchema.parse(await request.json().catch(() => ({}))); + const config = await resolveKittyCadConfig(body.config); + + if (!config.apiKey.trim()) { + throw new Error("请填写 KittyCAD API Token"); + } + + const startedAt = Date.now(); + const user = await kittyCadRequest>(config, { + method: "GET", + path: "/user", + }); + const latencyMs = Date.now() - startedAt; + + return Response.json({ + success: true, + providerName: config.providerName || "KittyCAD CAD", + baseUrl: config.baseUrl, + latencyMs, + userId: + typeof user.id === "string" && user.id.trim() ? user.id.trim() : null, + userName: pickDisplayName(user), + userEmail: pickEmail(user), + }); + } catch (error) { + if (error instanceof ChatSDKError) { + return error.toResponse(); + } + + if (error instanceof KittyCadApiError) { + return Response.json( + { + code: "bad_request:api", + message: error.message, + payload: error.payload, + }, + { status: error.httpStatus || 400 } + ); + } + + if (error instanceof z.ZodError) { + return new ChatSDKError("bad_request:api", error.message).toResponse(); + } + + return Response.json( + { + code: "bad_request:api", + message: + error instanceof Error ? error.message : "KittyCAD 连接测试失败", + }, + { status: 400 } + ); + } +} diff --git a/app/api/ai-cad/provider/route.ts b/app/api/ai-cad/provider/route.ts new file mode 100644 index 000000000..5017afe58 --- /dev/null +++ b/app/api/ai-cad/provider/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { findKittyCadProviderConfig } from "@/lib/kittycad/server"; + +export const runtime = "nodejs"; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: "未登录" }, + { status: 401 } + ); + } + + const config = await findKittyCadProviderConfig(); + + return NextResponse.json({ + success: true, + data: { + configured: Boolean(config), + source: config?.source || "none", + providerName: config?.providerName || null, + baseUrl: config?.baseUrl || null, + }, + }); +} diff --git a/app/api/ai-cad/tasks/[taskId]/route.ts b/app/api/ai-cad/tasks/[taskId]/route.ts new file mode 100644 index 000000000..a61dbaa3d --- /dev/null +++ b/app/api/ai-cad/tasks/[taskId]/route.ts @@ -0,0 +1,391 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { KittyCadApiError, kittyCadRequest } from "@/lib/kittycad/client"; +import { getKittyCadProviderConfig } from "@/lib/kittycad/server"; +import { uploadFile } from "@/lib/storage/service"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +type KittyCadTaskResponse = { + id?: string; + status?: string; + outputs?: Record | null; + error?: string | null; + code?: string | null; + prompt?: string | null; + conversation_id?: string | null; + model_version?: string | null; + output_format?: string | null; +}; + +type ArchivedFile = { + sourceHash: string; + archivedUrl: string; +}; + +type RouteParams = { + params: Promise<{ taskId: string }>; +}; + +function getArchiveCache() { + const globalState = globalThis as Record; + if (!globalState.__kittyCadAssetArchiveCache) { + globalState.__kittyCadAssetArchiveCache = new Map(); + } + return globalState.__kittyCadAssetArchiveCache as Map; +} + +function getArchiveInflight() { + const globalState = globalThis as Record; + if (!globalState.__kittyCadAssetArchiveInflight) { + globalState.__kittyCadAssetArchiveInflight = new Map< + string, + Promise + >(); + } + return globalState.__kittyCadAssetArchiveInflight as Map< + string, + Promise + >; +} + +function mapKittyCadStatus(status: unknown) { + const normalized = String(status || "") + .trim() + .toLowerCase(); + if (!normalized) { + return "queued" as const; + } + if ( + normalized === "queued" || + normalized === "pending" || + normalized === "created" || + normalized === "submitted" + ) { + return "queued" as const; + } + if ( + normalized === "running" || + normalized === "processing" || + normalized === "in_progress" || + normalized === "working" || + normalized === "generating" + ) { + return "running" as const; + } + if ( + normalized === "completed" || + normalized === "success" || + normalized === "succeeded" || + normalized === "done" || + normalized === "finished" + ) { + return "success" as const; + } + if (normalized === "cancelled" || normalized === "canceled") { + return "cancelled" as const; + } + if (normalized === "expired" || normalized === "timed_out") { + return "expired" as const; + } + if (normalized === "banned" || normalized === "blocked") { + return "banned" as const; + } + if ( + normalized === "failed" || + normalized === "error" || + normalized === "errored" + ) { + return "failed" as const; + } + return "unknown" as const; +} + +function getFileExtension(filename: string) { + const normalized = String(filename || "") + .trim() + .toLowerCase(); + const cleanName = normalized.split("?")[0]?.split("#")[0] || normalized; + const segments = cleanName.split("."); + return segments.length > 1 ? segments.at(-1) || "" : ""; +} + +function isImageExtension(extension: string) { + return ["png", "jpg", "jpeg", "webp", "gif"].includes(extension); +} + +function pickPreferredModelFile(files: Array<{ name: string; url: string }>) { + const preferredOrder = ["glb", "gltf", "obj", "stl", "step", "stp"]; + for (const extension of preferredOrder) { + const matched = files.find( + (item) => getFileExtension(item.name) === extension + ); + if (matched) { + return matched; + } + } + return null; +} + +function pickPreviewImageFile(files: Array<{ name: string; url: string }>) { + return ( + files.find((item) => isImageExtension(getFileExtension(item.name))) || null + ); +} + +async function archiveOutputIfNeeded(params: { + taskId: string; + filename: string; + base64: string; +}) { + const normalizedFilename = String(params.filename || "").trim(); + const normalizedBase64 = String(params.base64 || "").trim(); + if (!normalizedFilename || !normalizedBase64) { + return null; + } + + const cacheKey = `${params.taskId}:${normalizedFilename}`; + const cache = getArchiveCache(); + const cached = cache.get(cacheKey); + if (cached && cached.sourceHash === normalizedBase64) { + return cached.archivedUrl; + } + + const inflight = getArchiveInflight(); + const existingTask = inflight.get(cacheKey); + if (existingTask) { + return (await existingTask) || null; + } + + const archiveTask = (async () => { + try { + const binary = Buffer.from(normalizedBase64, "base64"); + if (binary.byteLength <= 0) { + throw new Error("KittyCAD 输出文件为空"); + } + const arrayBuffer = Uint8Array.from(binary).buffer; + + const safeFilename = + normalizedFilename.replace(/[^\w.-]+/g, "-") || "kittycad-output.bin"; + const uploaded = await uploadFile( + `kittycad-${params.taskId}-${safeFilename}`, + arrayBuffer + ); + cache.set(cacheKey, { + sourceHash: normalizedBase64, + archivedUrl: uploaded.url, + }); + return uploaded.url; + } catch (error) { + console.error("[KittyCADArchive] failed", { + taskId: params.taskId, + filename: normalizedFilename, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } finally { + inflight.delete(cacheKey); + } + })(); + + inflight.set(cacheKey, archiveTask); + return await archiveTask; +} + +async function archiveTextOutputIfNeeded(params: { + taskId: string; + filename: string; + content: string; +}) { + const normalizedFilename = String(params.filename || "").trim(); + const normalizedContent = String(params.content || ""); + if (!normalizedFilename || !normalizedContent.trim()) { + return null; + } + + const cacheKey = `${params.taskId}:${normalizedFilename}`; + const cache = getArchiveCache(); + const cached = cache.get(cacheKey); + if (cached && cached.sourceHash === normalizedContent) { + return cached.archivedUrl; + } + + const inflight = getArchiveInflight(); + const existingTask = inflight.get(cacheKey); + if (existingTask) { + return (await existingTask) || null; + } + + const archiveTask = (async () => { + try { + const arrayBuffer = Uint8Array.from( + new TextEncoder().encode(normalizedContent) + ).buffer; + const safeFilename = + normalizedFilename.replace(/[^\w.-]+/g, "-") || "kittycad-output.kcl"; + const uploaded = await uploadFile( + `kittycad-${params.taskId}-${safeFilename}`, + arrayBuffer + ); + cache.set(cacheKey, { + sourceHash: normalizedContent, + archivedUrl: uploaded.url, + }); + return uploaded.url; + } catch (error) { + console.error("[KittyCADArchive:text] failed", { + taskId: params.taskId, + filename: normalizedFilename, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } finally { + inflight.delete(cacheKey); + } + })(); + + inflight.set(cacheKey, archiveTask); + return await archiveTask; +} + +export async function GET(_request: Request, { params }: RouteParams) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: "未登录" }, + { status: 401 } + ); + } + + const { taskId } = await params; + const normalizedTaskId = String(taskId || "").trim(); + if (!normalizedTaskId) { + return NextResponse.json( + { success: false, error: "缺少 taskId" }, + { status: 400 } + ); + } + + const config = await getKittyCadProviderConfig(); + const result = await kittyCadRequest(config, { + method: "GET", + path: `/user/text-to-cad/${encodeURIComponent(normalizedTaskId)}`, + }); + + const status = mapKittyCadStatus(result.status); + if (status !== "success") { + return NextResponse.json({ + success: true, + data: { + taskId: normalizedTaskId, + type: "cad", + status, + progress: status === "running" ? 65 : null, + errorMessage: result.error || null, + conversationId: result.conversation_id || null, + modelVersion: result.model_version || null, + outputFormat: result.output_format || null, + output: result, + }, + }); + } + + const outputEntries = Object.entries(result.outputs || {}).filter( + (entry): entry is [string, string] => + typeof entry[0] === "string" && + entry[0].trim().length > 0 && + typeof entry[1] === "string" && + entry[1].trim().length > 0 + ); + + const archivedEntries = await Promise.all( + outputEntries.map(async ([name, base64]) => { + const archivedUrl = await archiveOutputIfNeeded({ + taskId: normalizedTaskId, + filename: name, + base64, + }); + return archivedUrl ? { name, url: archivedUrl } : null; + }) + ); + + const archivedFiles = archivedEntries.filter( + (item): item is { name: string; url: string } => + Boolean(item?.name && item?.url) + ); + + const hasArchivedKclFile = archivedFiles.some( + (item) => getFileExtension(item.name) === "kcl" + ); + if ( + !hasArchivedKclFile && + typeof result.code === "string" && + result.code.trim() + ) { + const archivedCodeUrl = await archiveTextOutputIfNeeded({ + taskId: normalizedTaskId, + filename: "main.kcl", + content: result.code, + }); + if (archivedCodeUrl) { + archivedFiles.push({ + name: "main.kcl", + url: archivedCodeUrl, + }); + } + } + + const modelFile = pickPreferredModelFile(archivedFiles); + const previewImageFile = pickPreviewImageFile(archivedFiles); + const downloadUrls = Object.fromEntries( + archivedFiles.map((item) => [item.name, item.url]) + ); + const codeFile = + archivedFiles.find((item) => getFileExtension(item.name) === "kcl") || + null; + + return NextResponse.json({ + success: true, + data: { + taskId: normalizedTaskId, + type: "cad", + status: "success", + progress: 100, + modelUrl: + modelFile && + ["glb", "gltf"].includes(getFileExtension(modelFile.name)) + ? modelFile.url + : null, + previewImageUrl: previewImageFile?.url || null, + errorMessage: result.error || null, + conversationId: result.conversation_id || null, + modelVersion: result.model_version || null, + outputFormat: result.output_format || null, + codeUrl: codeFile?.url || null, + downloadUrls, + output: result, + }, + }); + } catch (error) { + if (error instanceof KittyCadApiError) { + return NextResponse.json( + { + success: false, + error: error.message, + payload: error.payload, + }, + { status: error.httpStatus || 502 } + ); + } + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "查询 AI-CAD 任务失败", + }, + { status: 500 } + ); + } +} diff --git a/app/api/ai-cad/tasks/route.ts b/app/api/ai-cad/tasks/route.ts new file mode 100644 index 000000000..d9e2e3a60 --- /dev/null +++ b/app/api/ai-cad/tasks/route.ts @@ -0,0 +1,309 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { + consumeUserPoints, + getPointsSystemConfig, + getUserById, +} from "@/lib/db/queries"; +import { KittyCadApiError, kittyCadRequest } from "@/lib/kittycad/client"; +import { getKittyCadProviderConfig } from "@/lib/kittycad/server"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +type CreateAiCadTaskRequest = { + prompt?: string; + outputFormat?: string; + projectName?: string; + iterationSourceTaskId?: string; +}; + +type KittyCadCreateResponse = { + id?: string; + status?: string; + conversation_id?: string | null; + model_version?: string | null; + output_format?: string | null; +}; + +type KittyCadSourceTaskResponse = { + code?: string | null; + outputs?: Record | null; +}; + +type KittyCadIterationResponse = { + id?: string; + status?: string; + conversation_id?: string | null; + model_version?: string | null; + output_format?: string | null; +}; + +function getFileExtension(filename: string) { + const normalized = String(filename || "") + .trim() + .toLowerCase(); + const cleanName = normalized.split("?")[0]?.split("#")[0] || normalized; + const segments = cleanName.split("."); + return segments.length > 1 ? segments.at(-1) || "" : ""; +} + +function decodeBase64Text(value: unknown) { + const normalized = String(value || "").trim(); + if (!normalized) { + return null; + } + + try { + const decoded = Buffer.from(normalized, "base64").toString("utf8").trim(); + return decoded || null; + } catch (_error) { + return null; + } +} + +function extractSourceCodeFromTask(task: KittyCadSourceTaskResponse) { + const directCode = String(task.code || "").trim(); + if (directCode) { + return directCode; + } + + const kclOutputEntry = Object.entries(task.outputs || {}).find( + (entry): entry is [string, string] => + typeof entry[0] === "string" && + entry[0].trim().length > 0 && + getFileExtension(entry[0]) === "kcl" && + typeof entry[1] === "string" && + entry[1].trim().length > 0 + ); + + if (!kclOutputEntry) { + return null; + } + + return decodeBase64Text(kclOutputEntry[1]); +} + +function mapKittyCadStatus(status: unknown) { + const normalized = String(status || "") + .trim() + .toLowerCase(); + if (!normalized) { + return "queued" as const; + } + if ( + normalized === "queued" || + normalized === "pending" || + normalized === "created" || + normalized === "submitted" + ) { + return "queued" as const; + } + if ( + normalized === "running" || + normalized === "processing" || + normalized === "in_progress" || + normalized === "working" || + normalized === "generating" + ) { + return "running" as const; + } + if ( + normalized === "completed" || + normalized === "success" || + normalized === "succeeded" || + normalized === "done" || + normalized === "finished" + ) { + return "success" as const; + } + if (normalized === "cancelled" || normalized === "canceled") { + return "cancelled" as const; + } + if (normalized === "expired" || normalized === "timed_out") { + return "expired" as const; + } + if (normalized === "banned" || normalized === "blocked") { + return "banned" as const; + } + if ( + normalized === "failed" || + normalized === "error" || + normalized === "errored" + ) { + return "failed" as const; + } + return "unknown" as const; +} + +export async function POST(request: Request) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: "未登录" }, + { status: 401 } + ); + } + + const body = (await request.json()) as CreateAiCadTaskRequest; + const prompt = String(body.prompt || "").trim(); + if (!prompt) { + return NextResponse.json( + { success: false, error: "请输入 CAD 描述" }, + { status: 400 } + ); + } + + const pointsConfig = await getPointsSystemConfig(); + const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3DModel)) + ? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3DModel))) + : 0; + + if ( + pointsConfig.enabled && + unitCost > 0 && + pointsConfig.blockOnInsufficient !== false + ) { + const userData = await getUserById({ id: session.user.id }); + if (!userData?.isHighVip) { + const currentBalance = Math.max( + 0, + Number(userData?.pointsBalance || 0) + ); + if (currentBalance < unitCost) { + return NextResponse.json( + { + success: false, + error: "积分不足", + code: "insufficient_points", + message: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${unitCost}。`, + balance: currentBalance, + expectedCost: unitCost, + unitCost, + }, + { status: 402 } + ); + } + } + } + + const outputFormat = + String(body.outputFormat || "glb") + .trim() + .toLowerCase() || "glb"; + const normalizedProjectName = String(body.projectName || "").trim(); + const iterationSourceTaskId = String( + body.iterationSourceTaskId || "" + ).trim(); + const config = await getKittyCadProviderConfig(); + const generationMode = iterationSourceTaskId ? "iterate" : "create"; + const result = iterationSourceTaskId + ? await (async () => { + const sourceTask = await kittyCadRequest( + config, + { + method: "GET", + path: `/user/text-to-cad/${encodeURIComponent(iterationSourceTaskId)}`, + } + ); + const originalSourceCode = extractSourceCodeFromTask(sourceTask); + + if (!originalSourceCode) { + throw new KittyCadApiError({ + message: + "当前参考版本缺少可继续编辑的 KCL 源码,请先等待该版本完成,或重新选择一个 KittyCAD 版本。", + httpStatus: 409, + }); + } + + return kittyCadRequest(config, { + method: "POST", + path: "/ml/text-to-cad/iteration", + json: { + original_source_code: originalSourceCode, + prompt, + source_ranges: [], + ...(normalizedProjectName + ? { project_name: normalizedProjectName } + : {}), + }, + }); + })() + : await kittyCadRequest(config, { + method: "POST", + path: `/ai/text-to-cad/${encodeURIComponent(outputFormat)}?kcl=true`, + json: { + prompt, + ...(normalizedProjectName + ? { project_name: normalizedProjectName } + : {}), + }, + }); + + const taskId = String(result.id || "").trim(); + if (!taskId) { + return NextResponse.json( + { + success: false, + error: "KittyCAD 返回缺少任务 ID", + raw: result, + }, + { status: 502 } + ); + } + + if (pointsConfig.enabled && unitCost > 0) { + try { + await consumeUserPoints({ + userId: session.user.id, + amount: unitCost, + reason: "model3d_generation", + modelId: "kittycad-cad", + note: + generationMode === "iterate" + ? "AI-CAD 连续迭代消耗积分" + : "AI-CAD 生成消耗积分", + }); + } catch (error) { + console.error("[Points] Failed to consume AI-CAD points:", error); + } + } + + return NextResponse.json({ + success: true, + data: { + taskId, + status: mapKittyCadStatus(result.status), + conversationId: result.conversation_id || null, + modelVersion: result.model_version || null, + outputFormat: + generationMode === "iterate" + ? result.output_format || null + : result.output_format || outputFormat, + generationMode, + sourceTaskId: iterationSourceTaskId || null, + raw: result, + }, + }); + } catch (error) { + if (error instanceof KittyCadApiError) { + return NextResponse.json( + { + success: false, + error: error.message, + payload: error.payload, + }, + { status: error.httpStatus || 502 } + ); + } + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "创建 AI-CAD 任务失败", + }, + { status: 500 } + ); + } +} diff --git a/app/api/ai-cad/workspace-utils.ts b/app/api/ai-cad/workspace-utils.ts new file mode 100644 index 000000000..38277896b --- /dev/null +++ b/app/api/ai-cad/workspace-utils.ts @@ -0,0 +1,187 @@ +import type { AiCadWorkspace } from "@/lib/db/schema"; + +type CadGenerationProvider = "kittycad-cad" | "tripo-3d"; + +const FINAL_TASK_STATUS = new Set([ + "success", + "failed", + "banned", + "expired", + "cancelled", + "unknown", +]); + +const DEFAULT_VIEWER_SETTINGS = { + envPreset: "studio", + lightIntensity: 1.1, + autoRotate: true, + showGrid: true, + wireframe: false, +} as const; + +type WorkspaceVersionLike = { + status?: unknown; + prompt?: unknown; +}; + +export type UpdateAiCadWorkspaceRequest = { + name?: string; + prompt?: string; + generationProvider?: string; + modelVersion?: string; + viewerSettings?: unknown; + messages?: unknown; + versions?: unknown; + activeVersionId?: string | null; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function getWorkspaceVersions(value: unknown): WorkspaceVersionLike[] { + if (!Array.isArray(value)) { + return []; + } + + return value.filter((item): item is WorkspaceVersionLike => isRecord(item)); +} + +export function normalizeViewerSettings( + value: unknown +): Record { + if (!isRecord(value)) { + return { ...DEFAULT_VIEWER_SETTINGS }; + } + + return { + envPreset: + typeof value.envPreset === "string" && value.envPreset.trim() + ? value.envPreset + : 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, + }; +} + +export function normalizeJsonRecordArray( + value: unknown +): Record[] { + if (!Array.isArray(value)) { + return []; + } + + return value.filter((item): item is Record => + isRecord(item) + ); +} + +export function normalizeWorkspaceName( + value: unknown, + fallbackName = "AI-CAD 工作台" +) { + return typeof value === "string" && value.trim() + ? value.trim() + : fallbackName; +} + +export function normalizeWorkspacePayload(body: UpdateAiCadWorkspaceRequest): { + name?: string; + prompt: string; + generationProvider: CadGenerationProvider; + modelVersion: string; + viewerSettings: Record; + messages: Record[]; + versions: Record[]; + activeVersionId: string | null; +} { + return { + ...(typeof body.name === "string" && body.name.trim() + ? { name: body.name.trim() } + : {}), + prompt: String(body.prompt || ""), + generationProvider: + body.generationProvider === "tripo-3d" ? "tripo-3d" : "kittycad-cad", + modelVersion: + typeof body.modelVersion === "string" && body.modelVersion.trim() + ? body.modelVersion + : "Turbo-v1.0-20250506", + viewerSettings: normalizeViewerSettings(body.viewerSettings), + messages: normalizeJsonRecordArray(body.messages), + versions: normalizeJsonRecordArray(body.versions), + activeVersionId: + typeof body.activeVersionId === "string" && body.activeVersionId.trim() + ? body.activeVersionId + : null, + }; +} + +export function buildDefaultWorkspaceName(index: number) { + return index > 1 ? `AI-CAD 工作台 ${index}` : "AI-CAD 工作台"; +} + +export function createWorkspaceSummary(workspace: AiCadWorkspace) { + const versions = getWorkspaceVersions(workspace.versions); + const versionCount = versions.length; + const completedCount = versions.filter( + (version) => version.status === "success" + ).length; + const hasPendingTasks = versions.some( + (version) => + typeof version.status !== "string" || + !FINAL_TASK_STATUS.has(version.status) + ); + const latestPrompt = + typeof workspace.prompt === "string" && workspace.prompt.trim() + ? workspace.prompt.trim() + : versions.find( + (version) => + typeof version.prompt === "string" && version.prompt.trim() + )?.prompt || null; + + return { + id: workspace.id, + name: workspace.name, + generationProvider: workspace.generationProvider, + versionCount, + completedCount, + hasPendingTasks, + latestPrompt: + typeof latestPrompt === "string" && latestPrompt.trim() + ? latestPrompt.trim() + : null, + createdAt: workspace.createdAt?.toISOString() || null, + updatedAt: workspace.updatedAt?.toISOString() || null, + }; +} + +export function createWorkspaceDocument(workspace: AiCadWorkspace) { + return { + id: workspace.id, + name: workspace.name, + prompt: workspace.prompt, + generationProvider: workspace.generationProvider, + modelVersion: workspace.modelVersion, + viewerSettings: workspace.viewerSettings, + messages: workspace.messages, + versions: workspace.versions, + activeVersionId: workspace.activeVersionId, + createdAt: workspace.createdAt?.toISOString() || null, + updatedAt: workspace.updatedAt?.toISOString() || null, + }; +} diff --git a/app/api/ai-cad/workspace/route.ts b/app/api/ai-cad/workspace/route.ts new file mode 100644 index 000000000..2675d20ba --- /dev/null +++ b/app/api/ai-cad/workspace/route.ts @@ -0,0 +1,89 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { + createWorkspaceDocument, + normalizeWorkspacePayload, + type UpdateAiCadWorkspaceRequest, +} from "@/app/api/ai-cad/workspace-utils"; +import { + createAiCadWorkspace, + listAiCadWorkspacesByUserId, + updateAiCadWorkspace, +} from "@/lib/db/queries"; + +export const runtime = "nodejs"; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: "未登录" }, + { status: 401 } + ); + } + + const workspaces = await listAiCadWorkspacesByUserId({ + userId: session.user.id, + }); + const workspace = workspaces[0] ?? null; + + if (!workspace) { + return NextResponse.json({ + success: true, + data: null, + }); + } + + return NextResponse.json({ + success: true, + data: createWorkspaceDocument(workspace), + }); +} + +export async function PUT(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: "未登录" }, + { status: 401 } + ); + } + + const body = (await request.json()) as UpdateAiCadWorkspaceRequest; + const existingWorkspaces = await listAiCadWorkspacesByUserId({ + userId: session.user.id, + }); + const targetWorkspace = + existingWorkspaces[0] ?? + (await createAiCadWorkspace({ + userId: session.user.id, + name: "AI-CAD 工作台", + })); + + if (!targetWorkspace) { + return NextResponse.json( + { success: false, error: "初始化 AI-CAD 工作台失败" }, + { status: 500 } + ); + } + + const savedWorkspace = await updateAiCadWorkspace({ + workspaceId: targetWorkspace.id, + userId: session.user.id, + ...normalizeWorkspacePayload(body), + }); + + if (!savedWorkspace) { + return NextResponse.json( + { success: false, error: "保存 AI-CAD 工作台失败" }, + { status: 500 } + ); + } + + return NextResponse.json({ + success: true, + data: { + updatedAt: savedWorkspace.updatedAt?.toISOString() || null, + }, + }); +} diff --git a/app/api/ai-cad/workspaces/[workspaceId]/route.ts b/app/api/ai-cad/workspaces/[workspaceId]/route.ts new file mode 100644 index 000000000..5f87a7ecd --- /dev/null +++ b/app/api/ai-cad/workspaces/[workspaceId]/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { + createWorkspaceDocument, + createWorkspaceSummary, + normalizeWorkspacePayload, + type UpdateAiCadWorkspaceRequest, +} from "@/app/api/ai-cad/workspace-utils"; +import { + deleteAiCadWorkspaceById, + getAiCadWorkspaceById, + updateAiCadWorkspace, +} from "@/lib/db/queries"; + +export const runtime = "nodejs"; + +type RouteContext = { + params: Promise<{ + workspaceId: string; + }>; +}; + +export async function GET(_request: Request, context: RouteContext) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: "未登录" }, + { status: 401 } + ); + } + + const { workspaceId } = await context.params; + const workspace = await getAiCadWorkspaceById({ + userId: session.user.id, + workspaceId, + }); + + if (!workspace) { + return NextResponse.json( + { success: false, error: "AI-CAD 工作台不存在" }, + { status: 404 } + ); + } + + return NextResponse.json({ + success: true, + data: createWorkspaceDocument(workspace), + }); +} + +export async function PUT(request: Request, context: RouteContext) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: "未登录" }, + { status: 401 } + ); + } + + const { workspaceId } = await context.params; + const body = (await request.json()) as UpdateAiCadWorkspaceRequest; + const savedWorkspace = await updateAiCadWorkspace({ + workspaceId, + userId: session.user.id, + ...normalizeWorkspacePayload(body), + }); + + if (!savedWorkspace) { + return NextResponse.json( + { success: false, error: "AI-CAD 工作台不存在或保存失败" }, + { status: 404 } + ); + } + + return NextResponse.json({ + success: true, + data: { + id: savedWorkspace.id, + name: savedWorkspace.name, + updatedAt: savedWorkspace.updatedAt?.toISOString() || null, + summary: createWorkspaceSummary(savedWorkspace), + }, + }); +} + +export async function DELETE(_request: Request, context: RouteContext) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: "未登录" }, + { status: 401 } + ); + } + + const { workspaceId } = await context.params; + const deletedWorkspace = await deleteAiCadWorkspaceById({ + workspaceId, + userId: session.user.id, + }); + + if (!deletedWorkspace) { + return NextResponse.json( + { success: false, error: "AI-CAD 工作台不存在" }, + { status: 404 } + ); + } + + return NextResponse.json({ + success: true, + data: { + id: deletedWorkspace.id, + }, + }); +} diff --git a/app/api/ai-cad/workspaces/route.ts b/app/api/ai-cad/workspaces/route.ts new file mode 100644 index 000000000..b0347e5d6 --- /dev/null +++ b/app/api/ai-cad/workspaces/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { + buildDefaultWorkspaceName, + createWorkspaceSummary, + normalizeWorkspaceName, +} from "@/app/api/ai-cad/workspace-utils"; +import { + createAiCadWorkspace, + listAiCadWorkspacesByUserId, +} from "@/lib/db/queries"; + +export const runtime = "nodejs"; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: "未登录" }, + { status: 401 } + ); + } + + const workspaces = await listAiCadWorkspacesByUserId({ + userId: session.user.id, + }); + + return NextResponse.json({ + success: true, + data: workspaces.map(createWorkspaceSummary), + }); +} + +export async function POST(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: "未登录" }, + { status: 401 } + ); + } + + const body = (await request.json().catch(() => ({}))) as { name?: unknown }; + const existingWorkspaces = await listAiCadWorkspacesByUserId({ + userId: session.user.id, + }); + const workspace = await createAiCadWorkspace({ + userId: session.user.id, + name: normalizeWorkspaceName( + body.name, + buildDefaultWorkspaceName(existingWorkspaces.length + 1) + ), + }); + + if (!workspace) { + return NextResponse.json( + { success: false, error: "创建 AI-CAD 工作台失败" }, + { status: 500 } + ); + } + + return NextResponse.json( + { + success: true, + data: createWorkspaceSummary(workspace), + }, + { status: 201 } + ); +} diff --git a/components/ai-cad/ai-cad-workbench.tsx b/components/ai-cad/ai-cad-workbench.tsx new file mode 100644 index 000000000..6c465f86d --- /dev/null +++ b/components/ai-cad/ai-cad-workbench.tsx @@ -0,0 +1,6239 @@ +"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 | 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([ + "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 = { + thickness: { + description: "这轮更像是在调整壁厚、抽壳或整体壳体厚度。", + followUpHint: "下一轮可以明确写出目标壁厚、抽壳方向和哪些区域不能再变薄。", + keywords: [ + /\bthickness\b/i, + /\bwall\b/i, + /\bshell\b/i, + /壁厚|壳厚|厚度|抽壳/, + ], + label: "壁厚 / 壳厚", + shortLabel: "壁厚", + }, + edgeTreatment: { + description: "这轮集中在圆角、倒角或边缘过渡处理。", + followUpHint: + "下一轮可以把哪些边要保留锐边、哪些边需要统一圆角半径写得更明确。", + keywords: [ + /\bfillet\b/i, + /\bchamfer\b/i, + /\bround(ed|ing)?\b/i, + /圆角|倒角|边缘过渡|转角/, + ], + label: "圆角 / 倒角", + shortLabel: "圆角", + }, + holeLayout: { + description: "这轮重点动了孔位、通孔、沉头孔或安装孔表达。", + followUpHint: "下一轮可以把孔径、孔深、孔间距和安装基准面写清楚。", + keywords: [ + /\bhole\b/i, + /\bbore\b/i, + /\bdrill\b/i, + /\bcounter(sink|bore)\b/i, + /\bmount(ing)?\b/i, + /孔位|孔径|通孔|沉头孔|安装孔|安装点/, + ], + label: "孔位 / 安装点", + shortLabel: "孔位", + }, + dimensions: { + description: "这轮主要改了尺寸、半径、直径、偏移或间隙参数。", + followUpHint: + "下一轮可以补上关键尺寸、间隙、公差范围,避免模型只改了语气没改到数值。", + keywords: [ + /\blength\b/i, + /\bwidth\b/i, + /\bheight\b/i, + /\bradius\b/i, + /\bdiameter\b/i, + /\boffset\b/i, + /\bclearance\b/i, + /\bgap\b/i, + /\btolerance\b/i, + /尺寸|长宽高|半径|直径|偏移|间隙|公差|厚薄|比例/, + ], + label: "尺寸 / 公差", + shortLabel: "尺寸", + }, + patternLayout: { + description: "这轮涉及阵列、镜像、间距或布局组织方式。", + followUpHint: "下一轮可以说明阵列方向、数量、间距,以及是否要镜像或错位。", + keywords: [ + /\bpattern\b/i, + /\barray\b/i, + /\bmirror\b/i, + /\bspacing\b/i, + /\brepeat\b/i, + /\btranslate\b/i, + /\brotate\b/i, + /阵列|镜像|间距|排布|布局|重复|旋转分布/, + ], + label: "阵列 / 布局", + shortLabel: "阵列", + }, + profileShape: { + description: "这轮更像是在改主体成形方式,比如拉伸、旋转或放样轮廓。", + followUpHint: + "下一轮可以把主体轮廓、截面变化和成形方式拆开描述,减少形体歧义。", + keywords: [ + /\bextrude\b/i, + /\brevolve\b/i, + /\bloft\b/i, + /\bsweep\b/i, + /\bsketch\b/i, + /\bprofile\b/i, + /拉伸|旋转|放样|扫描|轮廓|草图|截面|主体形态/, + ], + label: "轮廓 / 成形", + shortLabel: "成形", + }, + materialProcess: { + description: "这轮补充了材料、表面、制造约束或装配工艺信息。", + followUpHint: + "下一轮可以把材料、表面处理、加工方式和装配限制拆成独立要求。", + keywords: [ + /\bmaterial\b/i, + /\bfinish\b/i, + /\banod/i, + /\bcoating\b/i, + /\bprint(ed|ing)?\b/i, + /\bmold(ing)?\b/i, + /\bassembly\b/i, + /\bfastener\b/i, + /材料|表面处理|阳极|喷涂|打印|注塑|装配|工艺|紧固件/, + ], + label: "材料 / 工艺", + shortLabel: "工艺", + }, +}; + +function createId() { + return crypto.randomUUID(); +} + +function swallowAsync(task: Promise) { + task.catch((_error) => null); +} + +function formatClockTime(timestamp: number) { + return new Intl.DateTimeFormat("zh-CN", { + hour: "2-digit", + minute: "2-digit", + }).format(new Date(timestamp)); +} + +function formatVersionDetailTime(timestamp: number) { + return new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(timestamp)); +} + +function truncateIdentifier(value?: string | null, length = 10) { + const normalizedValue = String(value || "").trim(); + if (!normalizedValue) { + return "未记录"; + } + + return normalizedValue.length > length + ? `${normalizedValue.slice(0, length)}...` + : normalizedValue; +} + +function truncateText(value?: string | null, length = 88) { + const normalizedValue = String(value || "") + .trim() + .replace(/\s+/g, " "); + + if (!normalizedValue) { + return "未填写"; + } + + return normalizedValue.length > length + ? `${normalizedValue.slice(0, length).trim()}...` + : normalizedValue; +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function getStatusLabel(status: Model3DTaskStatus) { + switch (status) { + case "queued": + return "排队中"; + case "running": + return "生成中"; + case "success": + return "已完成"; + case "failed": + return "失败"; + case "banned": + return "已拦截"; + case "expired": + return "已过期"; + case "cancelled": + return "已取消"; + default: + return "未知"; + } +} + +function getStatusBadgeClass(status: Model3DTaskStatus) { + switch (status) { + case "success": + return "border-emerald-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; + previewSourceVersion?: GenerationVersion | null; + } +) { + const summaryLines = [ + version.generationMode === "iterate" + ? `这轮采用连续修改模式,基于 ${params?.parentVersion ? `V${params.parentVersion.versionNumber}` : "上一轮 CAD 结果"} 继续推进,而不是重新从零开始。` + : "这轮采用首轮创建模式,系统会把当前自然语言描述当成一个新的 CAD 起点来组织结构。", + `当前走的是 ${getProviderLabel(version.provider)} 链路,状态为“${getStatusLabel(version.status)}”。`, + params?.downloadEntries && params.downloadEntries.length > 0 + ? `服务端目前已经返回这些结果:${params.downloadEntries + .slice(0, 5) + .map((entry) => entry.label) + .join(" / ")}。` + : "服务端还没有返回可下载文件,通常意味着任务还在排队、执行中,或这轮结果只回来了部分代码。", + version.codeUrl && !version.modelUrl + ? "这一轮目前更偏代码结果,KCL 已可查看,但可预览的 3D 结果还没完全回到当前版本。" + : "当前版本已经具备可继续查看、下载或作为下一轮参考的结果基础。", + params?.previewSourceVersion && + params.previewSourceVersion.id !== version.id + ? `由于当前版本暂时没有新的可视化模型,右侧 3D 预览会继续借用 V${params.previewSourceVersion.versionNumber} 作为参考。` + : "右侧预览会优先展示当前版本自己的模型结果,方便你直接判断这一轮是否达到了目标。", + version.feedback === "positive" + ? "你已经把这轮结果标记为“有帮助”,这有助于后续继续沿着当前方向迭代。" + : version.feedback === "negative" + ? "你已经把这轮结果标记为“不理想”,后续更适合在下一轮明确指出要保留和要修正的部分。" + : "如果这轮方向对了,可以直接继续细化;如果偏了,建议下一轮明确写出“保留什么、修改什么、删除什么”。", + ]; + + return summaryLines.join("\n\n"); +} + +function buildFollowUpSuggestions(params: { + activeVersion?: GenerationVersion | null; + activeParentVersion?: GenerationVersion | null; + previewSourceVersion?: GenerationVersion | null; + semanticFocus?: SemanticDiffInsight[]; +}) { + const { + activeVersion, + activeParentVersion, + previewSourceVersion, + semanticFocus, + } = params; + const primarySemanticFocus = semanticFocus?.[0] ?? null; + + if (!activeVersion) { + return [...SUGGESTED_PROMPTS]; + } + + if (activeVersion.status === "failed") { + return [ + "把需求拆短一点,先只描述主体轮廓和核心结构。", + "明确写出保留什么、删除什么、替换什么,再重新生成。", + "先做一个更基础的版本,下一轮再补细节和制造约束。", + ]; + } + + if (activeVersion.status === "queued" || activeVersion.status === "running") { + return [ + "等这轮完成后,继续细化连接结构、壁厚和加强筋。", + "下一轮只改局部,保留主体轮廓,优化支撑与装配方式。", + "如果方向对了,再补充材料、公差和可制造性约束。", + ]; + } + + if (activeVersion.feedback === "negative") { + return [ + `保留 ${activeParentVersion ? `V${activeParentVersion.versionNumber}` : "上一轮"} 的整体比例,重做当前版本最突出的结构问题。`, + primarySemanticFocus + ? `不要改主体外轮廓,只重新梳理${primarySemanticFocus.label},把目标值和限制条件说清楚。` + : "不要改主体外轮廓,只把局部支撑、转角和厚度重新组织。", + "按更工程化的方式重写需求:外形、连接、装配、公差分别说明。", + ]; + } + + if (activeVersion.generationMode === "iterate") { + return [ + primarySemanticFocus + ? `继续沿着 V${activeVersion.versionNumber} 往下改,但这次只聚焦 ${primarySemanticFocus.label},别同时改太多区域。` + : `继续沿着 V${activeVersion.versionNumber} 往下改,但只微调局部结构和连接细节。`, + primarySemanticFocus + ? `围绕 ${primarySemanticFocus.label} 继续细化,把目标数值、保留项和不能动的区域一起写清楚。` + : "保留当前整体语言,补充圆角、壁厚、拔模和装配干涉约束。", + previewSourceVersion && previewSourceVersion.id !== activeVersion.id + ? `以当前代码结果为准,再让可视化结果追上 V${activeVersion.versionNumber} 的修改方向。` + : primarySemanticFocus + ? SEMANTIC_DIFF_CATEGORIES[primarySemanticFocus.key]?.followUpHint || + "不要改主体轮廓,只把当前版本做得更轻量、更容易制造。" + : "不要改主体轮廓,只把当前版本做得更轻量、更容易制造。", + ]; + } + + return [ + primarySemanticFocus + ? `基于 V${activeVersion.versionNumber} 继续细化:先保留主体比例,重点收紧 ${primarySemanticFocus.label}。` + : `基于 V${activeVersion.versionNumber} 继续细化:保留主体比例,优化局部结构和连接方式。`, + "在当前版本上增加工程约束,例如壁厚、公差、卡扣和加强筋。", + primarySemanticFocus + ? SEMANTIC_DIFF_CATEGORIES[primarySemanticFocus.key]?.followUpHint || + "如果这个方向对了,下一轮可以只针对一个局部做更明确的修改说明。" + : "如果这个方向对了,下一轮可以只针对一个局部做更明确的修改说明。", + ]; +} + +function getStructuredDirectivePlaceholder( + key: StructuredPromptDirectiveKey, + version?: GenerationVersion | null +) { + switch (key) { + case "keep": + return version + ? `V${version.versionNumber} 的主体比例、核心轮廓和装配关系` + : "主体比例、核心轮廓和主要装配关系"; + case "change": + return "局部支撑、转角、连接方式和需要细化的区域"; + case "remove": + return "冗余装饰、过厚区域和不必要的悬挑"; + case "constraints": + return "补充壁厚、公差、圆角、工艺和装配要求"; + case "material": + return "说明材料、表面处理、使用场景和手感偏好"; + default: + return ""; + } +} + +function extractStructuredPromptDirectives(prompt: string) { + return STRUCTURED_PROMPT_DIRECTIVES.flatMap((directive) => { + const pattern = new RegExp( + `(?:^|\\n)${escapeRegExp(directive.label)}[::]\\s*([^\\n]+)`, + "m" + ); + const matchedValue = prompt.match(pattern)?.[1]?.trim(); + + if (!matchedValue) { + return []; + } + + return [ + { + key: directive.key, + label: directive.label, + helper: directive.helper, + value: matchedValue, + } satisfies StructuredPromptDirective, + ]; + }); +} + +function upsertStructuredPromptDirective( + prompt: string, + directiveKey: StructuredPromptDirectiveKey, + version?: GenerationVersion | null +) { + const directive = STRUCTURED_PROMPT_DIRECTIVES.find( + (item) => item.key === directiveKey + ); + + if (!directive) { + return prompt; + } + + const nextLine = `${directive.label}:${getStructuredDirectivePlaceholder( + directiveKey, + version + )}`; + const normalizedPrompt = prompt.trimEnd(); + const hasLine = new RegExp( + `(?:^|\\n)${escapeRegExp(directive.label)}[::]\\s*`, + "m" + ).test(normalizedPrompt); + + if (hasLine) { + return prompt; + } + + return normalizedPrompt ? `${normalizedPrompt}\n${nextLine}` : nextLine; +} + +function buildStructuredPromptTemplate( + prompt: string, + version?: GenerationVersion | null +) { + return STRUCTURED_PROMPT_DIRECTIVES.reduce( + (currentPrompt, directive) => + upsertStructuredPromptDirective(currentPrompt, directive.key, version), + prompt.trimEnd() + ); +} + +function buildVersionCompareNotes(params: { + activeVersion?: GenerationVersion | null; + parentVersion?: GenerationVersion | null; + previewSourceVersion?: GenerationVersion | null; +}) { + const { activeVersion, parentVersion, previewSourceVersion } = params; + + if (!activeVersion) { + return []; + } + + const activeDirectives = extractStructuredPromptDirectives( + activeVersion.prompt + ); + const notes: string[] = []; + + if (activeDirectives.length > 0) { + notes.push( + ...activeDirectives.map( + (directive) => `${directive.label}:${directive.value}` + ) + ); + } else if (parentVersion && activeVersion.prompt !== parentVersion.prompt) { + notes.push( + `这轮需求从“${truncateText(parentVersion.prompt, 48)}”推进到了“${truncateText(activeVersion.prompt, 64)}”。` + ); + } else { + notes.push( + `当前版本的核心要求是:${truncateText(activeVersion.prompt, 72)}。` + ); + } + + if (parentVersion && activeVersion.generationMode === "iterate") { + notes.push( + `这轮沿着 V${parentVersion.versionNumber} 继续修改,适合把关注点缩小到局部结构和制造约束。` + ); + } + + if (previewSourceVersion && previewSourceVersion.id !== activeVersion.id) { + notes.push( + `当前 3D 预览仍借用了 V${previewSourceVersion.versionNumber},说明这轮更偏代码或结构迭代结果。` + ); + } + + if (parentVersion && activeVersion.provider !== parentVersion.provider) { + notes.push( + `当前版本的生成链路已经切换到 ${getProviderLabel(activeVersion.provider)}。` + ); + } + + return notes.slice(0, 5); +} + +function buildVersionChildrenMap(versions: GenerationVersion[]) { + const map = new Map(); + + for (const version of versions) { + if (!version.parentVersionId) { + continue; + } + + const currentChildren = map.get(version.parentVersionId) ?? []; + currentChildren.push(version); + map.set( + version.parentVersionId, + currentChildren.sort( + (left, right) => left.versionNumber - right.versionNumber + ) + ); + } + + return map; +} + +function buildVersionLineage( + version: GenerationVersion | null, + versions: GenerationVersion[] +) { + if (!version) { + return []; + } + + const lineage: GenerationVersion[] = []; + const visitedVersionIds = new Set(); + let currentVersion: GenerationVersion | null = version; + + while (currentVersion) { + if (visitedVersionIds.has(currentVersion.id)) { + break; + } + + visitedVersionIds.add(currentVersion.id); + lineage.unshift(currentVersion); + currentVersion = currentVersion.parentVersionId + ? findVersionById(versions, currentVersion.parentVersionId) + : null; + } + + return lineage; +} + +function getVersionDepth( + version: GenerationVersion, + versions: GenerationVersion[] +) { + let depth = 0; + let currentParentId = version.parentVersionId; + + while (currentParentId) { + depth += 1; + currentParentId = + findVersionById(versions, currentParentId)?.parentVersionId ?? null; + } + + return depth; +} + +function getStatusDotClass(status: Model3DTaskStatus, isActive: boolean) { + if (isActive) { + return "bg-white"; + } + + switch (status) { + case "success": + return "bg-emerald-300"; + case "running": + return "bg-sky-300"; + case "queued": + return "bg-amber-300"; + case "failed": + case "banned": + case "cancelled": + case "expired": + return "bg-rose-300"; + default: + return "bg-white/35"; + } +} + +function getSiblingVersionCount( + version: GenerationVersion, + versions: GenerationVersion[] +) { + return versions.filter( + (candidate) => candidate.parentVersionId === version.parentVersionId + ).length; +} + +function buildCompareCandidateVersions( + activeVersion: GenerationVersion | null, + versions: GenerationVersion[] +) { + if (!activeVersion) { + return []; + } + + const candidateVersions: GenerationVersion[] = []; + const seenVersionIds = new Set([activeVersion.id]); + const pushCandidate = (version: GenerationVersion | null) => { + if (!version || seenVersionIds.has(version.id)) { + return; + } + + seenVersionIds.add(version.id); + candidateVersions.push(version); + }; + + pushCandidate( + activeVersion.parentVersionId + ? findVersionById(versions, activeVersion.parentVersionId) + : null + ); + + const siblingCandidates = versions + .filter( + (version) => + version.parentVersionId === activeVersion.parentVersionId && + version.id !== activeVersion.id + ) + .sort((left, right) => right.versionNumber - left.versionNumber); + for (const version of siblingCandidates) { + pushCandidate(version); + } + + const childCandidates = versions + .filter((version) => version.parentVersionId === activeVersion.id) + .sort((left, right) => right.versionNumber - left.versionNumber); + for (const version of childCandidates) { + pushCandidate(version); + } + + const lineageCandidates = buildVersionLineage(activeVersion, versions) + .slice(0, -1) + .reverse(); + for (const version of lineageCandidates) { + pushCandidate(version); + } + + const fallbackCandidates = versions + .slice() + .sort((left, right) => right.versionNumber - left.versionNumber); + for (const version of fallbackCandidates) { + pushCandidate(version); + } + + return candidateVersions; +} + +function buildVersionDiffHighlights(params: { + activeVersion?: GenerationVersion | null; + compareVersion?: GenerationVersion | null; + comparePreviewSourceVersion?: GenerationVersion | null; + previewSourceVersion?: GenerationVersion | null; +}) { + const { + activeVersion, + compareVersion, + comparePreviewSourceVersion, + previewSourceVersion, + } = params; + + if (!activeVersion || !compareVersion) { + return []; + } + + const activeDirectives = extractStructuredPromptDirectives( + activeVersion.prompt + ); + const compareDirectives = extractStructuredPromptDirectives( + compareVersion.prompt + ); + const compareDirectiveMap = new Map( + compareDirectives.map((directive) => [directive.key, directive]) + ); + const activeDirectiveMap = new Map( + activeDirectives.map((directive) => [directive.key, directive]) + ); + const highlights: string[] = []; + + for (const directive of activeDirectives) { + const matchedDirective = compareDirectiveMap.get(directive.key); + if (!matchedDirective) { + highlights.push( + `当前版本新增了“${directive.label}”要求:${truncateText(directive.value, 40)}。` + ); + continue; + } + + if (matchedDirective.value !== directive.value) { + highlights.push( + `“${directive.label}”从“${truncateText(matchedDirective.value, 24)}”调整为“${truncateText(directive.value, 24)}”。` + ); + } + } + + for (const directive of compareDirectives) { + if (activeDirectiveMap.has(directive.key)) { + continue; + } + + highlights.push( + `相较于 V${compareVersion.versionNumber},当前版本不再显式强调“${directive.label}”。` + ); + } + + if ( + highlights.length === 0 && + activeVersion.prompt !== compareVersion.prompt + ) { + highlights.push( + `这轮描述从“${truncateText(compareVersion.prompt, 36)}”推进到了“${truncateText(activeVersion.prompt, 52)}”。` + ); + } + + if ( + comparePreviewSourceVersion && + comparePreviewSourceVersion.id !== compareVersion.id + ) { + highlights.push( + `对照版本的 3D 预览当前借用了 V${comparePreviewSourceVersion.versionNumber}。` + ); + } + + if (previewSourceVersion && previewSourceVersion.id !== activeVersion.id) { + highlights.push( + `当前版本的 3D 预览暂时借用了 V${previewSourceVersion.versionNumber}。` + ); + } + + if (activeVersion.provider !== compareVersion.provider) { + highlights.push( + `两版使用的生成链路不同:当前为 ${getProviderLabel(activeVersion.provider)},对照为 ${getProviderLabel(compareVersion.provider)}。` + ); + } + + return highlights.slice(0, 5); +} + +function splitCodeLines(code: string) { + if (!code.trim()) { + return []; + } + + return code.replace(/\r\n/g, "\n").split("\n"); +} + +function buildCodeDiffRows(leftCode: string, rightCode: string) { + const leftLines = splitCodeLines(leftCode); + const rightLines = splitCodeLines(rightCode); + const matrix = Array.from({ length: leftLines.length + 1 }, () => + Array.from({ length: rightLines.length + 1 }, () => 0) + ); + + for (let leftIndex = leftLines.length - 1; leftIndex >= 0; leftIndex -= 1) { + for ( + let rightIndex = rightLines.length - 1; + rightIndex >= 0; + rightIndex -= 1 + ) { + matrix[leftIndex][rightIndex] = + leftLines[leftIndex] === rightLines[rightIndex] + ? matrix[leftIndex + 1][rightIndex + 1] + 1 + : Math.max( + matrix[leftIndex + 1][rightIndex], + matrix[leftIndex][rightIndex + 1] + ); + } + } + + const rawRows: Pick< + CodeDiffRow, + "kind" | "leftLineNumber" | "leftText" | "rightLineNumber" | "rightText" + >[] = []; + let leftIndex = 0; + let rightIndex = 0; + + while (leftIndex < leftLines.length && rightIndex < rightLines.length) { + if (leftLines[leftIndex] === rightLines[rightIndex]) { + rawRows.push({ + kind: "equal", + leftLineNumber: leftIndex + 1, + leftText: leftLines[leftIndex], + rightLineNumber: rightIndex + 1, + rightText: rightLines[rightIndex], + }); + leftIndex += 1; + rightIndex += 1; + continue; + } + + if ( + matrix[leftIndex + 1][rightIndex] >= matrix[leftIndex][rightIndex + 1] + ) { + rawRows.push({ + kind: "removed", + leftLineNumber: leftIndex + 1, + leftText: leftLines[leftIndex], + rightLineNumber: null, + rightText: "", + }); + leftIndex += 1; + continue; + } + + rawRows.push({ + kind: "added", + leftLineNumber: null, + leftText: "", + rightLineNumber: rightIndex + 1, + rightText: rightLines[rightIndex], + }); + rightIndex += 1; + } + + while (leftIndex < leftLines.length) { + rawRows.push({ + kind: "removed", + leftLineNumber: leftIndex + 1, + leftText: leftLines[leftIndex], + rightLineNumber: null, + rightText: "", + }); + leftIndex += 1; + } + + while (rightIndex < rightLines.length) { + rawRows.push({ + kind: "added", + leftLineNumber: null, + leftText: "", + rightLineNumber: rightIndex + 1, + rightText: rightLines[rightIndex], + }); + rightIndex += 1; + } + + const rows: CodeDiffRow[] = []; + const summary = { + added: 0, + changed: 0, + removed: 0, + unchanged: 0, + }; + + let index = 0; + while (index < rawRows.length) { + const currentRow = rawRows[index]; + + if (currentRow.kind === "equal") { + rows.push(currentRow); + summary.unchanged += 1; + index += 1; + continue; + } + + const removedRows: typeof rawRows = []; + const addedRows: typeof rawRows = []; + + while (index < rawRows.length && rawRows[index]?.kind !== "equal") { + const row = rawRows[index]; + if (row?.kind === "removed") { + removedRows.push(row); + } else if (row?.kind === "added") { + addedRows.push(row); + } + index += 1; + } + + const sharedLength = Math.min(removedRows.length, addedRows.length); + for (let pairIndex = 0; pairIndex < sharedLength; pairIndex += 1) { + rows.push({ + kind: "changed", + leftLineNumber: removedRows[pairIndex]?.leftLineNumber ?? null, + leftText: removedRows[pairIndex]?.leftText ?? "", + rightLineNumber: addedRows[pairIndex]?.rightLineNumber ?? null, + rightText: addedRows[pairIndex]?.rightText ?? "", + }); + summary.changed += 1; + } + + for (const row of removedRows.slice(sharedLength)) { + rows.push(row); + summary.removed += 1; + } + + for (const row of addedRows.slice(sharedLength)) { + rows.push(row); + summary.added += 1; + } + } + + return { rows, summary }; +} + +function buildCodeDiffInsights(rows: CodeDiffRow[]) { + const insights: string[] = []; + let index = 0; + + while (index < rows.length && insights.length < 4) { + if (rows[index]?.kind === "equal") { + index += 1; + continue; + } + + const hunkRows: CodeDiffRow[] = []; + while (index < rows.length && rows[index]?.kind !== "equal") { + const row = rows[index]; + if (row) { + hunkRows.push(row); + } + index += 1; + } + + if (hunkRows.length === 0) { + continue; + } + + const addedCount = hunkRows.filter((row) => row.kind === "added").length; + const removedCount = hunkRows.filter( + (row) => row.kind === "removed" + ).length; + const changedCount = hunkRows.filter( + (row) => row.kind === "changed" + ).length; + const firstLeftLine = hunkRows.find( + (row) => row.leftLineNumber + )?.leftLineNumber; + const firstRightLine = hunkRows.find( + (row) => row.rightLineNumber + )?.rightLineNumber; + const focusSnippet = truncateText( + hunkRows.find((row) => row.rightText.trim())?.rightText || + hunkRows.find((row) => row.leftText.trim())?.leftText || + "", + 44 + ); + const locationLabel = + firstRightLine || firstLeftLine + ? `第 ${firstRightLine ?? firstLeftLine} 行附近` + : "当前代码块中"; + const summaryParts = [ + changedCount > 0 ? `改写 ${changedCount} 行` : null, + addedCount > 0 ? `新增 ${addedCount} 行` : null, + removedCount > 0 ? `删除 ${removedCount} 行` : null, + ].filter(Boolean); + + insights.push( + `${locationLabel}${summaryParts.length > 0 ? `主要${summaryParts.join(",")}` : "发生了调整"},重点片段:${focusSnippet}。` + ); + } + + return insights; +} + +function summarizeSemanticDiffCount(matchCount: number) { + if (matchCount >= 6) { + return "高频改动"; + } + if (matchCount >= 3) { + return "连续改动"; + } + return "局部改动"; +} + +function collectSemanticInsightsFromTexts( + texts: string[], + options?: { + limit?: number; + } +) { + const matches = new Map< + string, + { + count: number; + examples: string[]; + } + >(); + + for (const rawText of texts) { + const text = rawText.trim(); + if (!text) { + continue; + } + + for (const [key, category] of Object.entries(SEMANTIC_DIFF_CATEGORIES)) { + const hasMatchedKeyword = category.keywords.some((keyword) => + keyword.test(text) + ); + if (!hasMatchedKeyword) { + continue; + } + + const current = matches.get(key) || { + count: 0, + examples: [], + }; + current.count += 1; + if (current.examples.length < 2 && !current.examples.includes(text)) { + current.examples.push(truncateText(text, 60)); + } + matches.set(key, current); + } + } + + return Array.from(matches.entries()) + .map(([key, match]) => { + const category = SEMANTIC_DIFF_CATEGORIES[key]; + if (!category) { + return null; + } + + return { + count: match.count, + detail: `${summarizeSemanticDiffCount(match.count)},${category.description}`, + examples: match.examples, + key, + label: category.label, + shortLabel: category.shortLabel, + } satisfies SemanticDiffInsight; + }) + .filter((insight): insight is SemanticDiffInsight => Boolean(insight)) + .sort((left, right) => right.count - left.count) + .slice(0, options?.limit ?? 4); +} + +function buildSemanticDiffInsights(rows: CodeDiffRow[]) { + const diffTexts = rows.flatMap((row) => + row.kind === "equal" + ? [] + : [row.leftText, row.rightText] + .map((value) => value.trim()) + .filter(Boolean) + ); + + return collectSemanticInsightsFromTexts(diffTexts); +} + +function buildVersionSemanticInsights(version?: GenerationVersion | null) { + if (!version) { + return []; + } + + const structuredTexts = extractStructuredPromptDirectives(version.prompt).map( + (directive) => `${directive.label}:${directive.value}` + ); + + return collectSemanticInsightsFromTexts( + [version.prompt, ...structuredTexts], + { limit: 3 } + ); +} + +function getCodeDiffRowClass(kind: CodeDiffKind, side: "left" | "right") { + if (kind === "equal") { + return "bg-transparent"; + } + + if (kind === "changed") { + return "bg-amber-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 ( +
+
+
+
{title}
+

{subtitle}

+
+
+ {code ? ( + + ) : null} + {openUrl ? ( + + ) : null} +
+
+ + +
+ {loading ? ( +
+
+ + 正在加载代码差异... +
+
+ ) : error ? ( +
+ {error} +
+ ) : rows.length === 0 ? ( +
+ {rowsEmptyMessage || "当前过滤条件下没有可显示的差异行。"} +
+ ) : code ? ( +
+ {rows.map((row, index) => { + const lineNumber = + side === "left" ? row.leftLineNumber : row.rightLineNumber; + const lineText = side === "left" ? row.leftText : row.rightText; + const isPlaceholder = lineNumber === null; + + return ( +
+
+ {lineNumber ?? ""} +
+
+                      {isPlaceholder ? " " : lineText || " "}
+                    
+
+ ); + })} +
+ ) : ( +
+ {emptyMessage} +
+ )} +
+
+
+ ); +} + +function buildAssistantMessage( + versionNumber: number, + status: Model3DTaskStatus, + progress?: number | null, + errorMessage?: string | null +) { + const prefix = `V${versionNumber}`; + switch (status) { + case "queued": + return `${prefix} 已进入队列,AI-CAD 正在整理这轮设计意图。`; + case "running": + return `${prefix} 正在生成中${typeof progress === "number" ? `,当前进度 ${Math.max(0, Math.round(progress))}%` : ""}。你可以继续思考下一轮修改方向。`; + case "success": + return `${prefix} 已完成。现在可以继续描述修改意见,我会把这轮结果当成新的版本节点。`; + case "failed": + return `${prefix} 生成失败${errorMessage ? `:${errorMessage}` : "。请换一种更具体的结构描述再试一次。"}`; + case "banned": + return `${prefix} 被服务端拦截,请调整描述内容后重试。`; + case "expired": + return `${prefix} 已过期,请重新发起生成。`; + case "cancelled": + return `${prefix} 已取消。`; + default: + return `${prefix} 状态暂不可用,稍后会继续同步。`; + } +} + +function findVersionById( + versions: GenerationVersion[], + versionId?: string | null +) { + if (!versionId) { + return null; + } + + return versions.find((version) => version.id === versionId) ?? null; +} + +function getPreviewVersion( + version: GenerationVersion | null, + versions: GenerationVersion[] +) { + const visitedVersionIds = new Set(); + let currentVersion = version; + + while (currentVersion) { + if (currentVersion.modelUrl || currentVersion.previewImageUrl) { + return currentVersion; + } + + if (!currentVersion.parentVersionId) { + return null; + } + + if (visitedVersionIds.has(currentVersion.parentVersionId)) { + return null; + } + + visitedVersionIds.add(currentVersion.parentVersionId); + currentVersion = findVersionById(versions, currentVersion.parentVersionId); + } + + return null; +} + +function canIterateKittyCadVersion(version: GenerationVersion | null) { + return ( + version?.provider === "kittycad-cad" && + version.status === "success" && + Boolean(version.taskId) + ); +} + +function createWelcomeMessages(): ChatMessage[] { + return [ + { + id: createId(), + role: "assistant", + createdAt: Date.now(), + state: "success", + content: + "欢迎来到 AI-CAD。这个工作台先用对话式方式组织每一轮 3D 生成,当前 MVP 会把每轮描述落成一个新版本,方便我们继续往下迭代。", + }, + ]; +} + +function createDefaultWorkbenchState( + provider: CadGenerationProvider = "kittycad-cad" +): PersistedAiCadWorkbenchState { + return { + prompt: "", + generationProvider: provider, + modelVersion: MODEL_VERSION_OPTIONS[0], + viewerSettings: DEFAULT_VIEWER_SETTINGS, + messages: createWelcomeMessages(), + versions: [], + activeVersionId: null, + }; +} + +function normalizeWorkspaceTitle(title: string, fallback = "AI-CAD 工作台") { + const normalizedTitle = title.trim().replace(/\s+/g, " "); + return normalizedTitle || fallback; +} + +function isDefaultWorkspaceTitle(title: string) { + return DEFAULT_WORKSPACE_TITLE_RE.test(title.trim()); +} + +function buildWorkspaceTitleFromPrompt(prompt: string) { + const normalizedPrompt = prompt + .trim() + .replace(/\s+/g, " ") + .replace(/[,。;;、,.!?!?]+$/g, ""); + + if (!normalizedPrompt) { + return "AI-CAD 工作台"; + } + + return normalizedPrompt.length > 22 + ? `${normalizedPrompt.slice(0, 22).trim()}...` + : normalizedPrompt; +} + +function getWorkbenchStorageKey(workspaceId: string) { + return `${AI_CAD_WORKBENCH_STORAGE_PREFIX}:${workspaceId}`; +} + +function readPersistedActiveWorkspaceId() { + if (typeof window === "undefined") { + return null; + } + + const value = window.localStorage.getItem( + AI_CAD_ACTIVE_WORKSPACE_STORAGE_KEY + ); + return typeof value === "string" && value.trim() ? value : null; +} + +function writePersistedActiveWorkspaceId(workspaceId: string | null) { + if (typeof window === "undefined") { + return; + } + + if (!workspaceId) { + window.localStorage.removeItem(AI_CAD_ACTIVE_WORKSPACE_STORAGE_KEY); + return; + } + + window.localStorage.setItem(AI_CAD_ACTIVE_WORKSPACE_STORAGE_KEY, workspaceId); +} + +function removePersistedWorkbench(workspaceId: string) { + if (typeof window === "undefined") { + return; + } + + window.localStorage.removeItem(getWorkbenchStorageKey(workspaceId)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isCadGenerationProvider( + value: unknown +): value is CadGenerationProvider { + return value === "kittycad-cad" || value === "tripo-3d"; +} + +function isChatMessageState(value: unknown): value is ChatMessageState { + return ( + value === "thinking" || + value === "queued" || + value === "running" || + value === "success" || + value === "failed" + ); +} + +function isVersionFeedback(value: unknown): value is VersionFeedback { + return value === "positive" || value === "negative"; +} + +function isModel3DTaskStatus(value: unknown): value is Model3DTaskStatus { + return ( + typeof value === "string" && + MODEL_TASK_STATUSES.includes(value as Model3DTaskStatus) + ); +} + +function normalizeViewerSettings(value: unknown): Model3DViewerSettings { + if (!isRecord(value)) { + return DEFAULT_VIEWER_SETTINGS; + } + + const envPreset = VIEWER_ENV_PRESETS.includes( + value.envPreset as (typeof VIEWER_ENV_PRESETS)[number] + ) + ? (value.envPreset as Model3DViewerSettings["envPreset"]) + : DEFAULT_VIEWER_SETTINGS.envPreset; + + return { + ...DEFAULT_VIEWER_SETTINGS, + envPreset, + lightIntensity: + typeof value.lightIntensity === "number" && + Number.isFinite(value.lightIntensity) + ? value.lightIntensity + : DEFAULT_VIEWER_SETTINGS.lightIntensity, + autoRotate: + typeof value.autoRotate === "boolean" + ? value.autoRotate + : DEFAULT_VIEWER_SETTINGS.autoRotate, + showGrid: + typeof value.showGrid === "boolean" + ? value.showGrid + : DEFAULT_VIEWER_SETTINGS.showGrid, + wireframe: + typeof value.wireframe === "boolean" + ? value.wireframe + : DEFAULT_VIEWER_SETTINGS.wireframe, + }; +} + +function normalizeMessages(value: unknown): ChatMessage[] { + if (!Array.isArray(value)) { + return createWelcomeMessages(); + } + + const messages = value.flatMap((item) => { + if (!isRecord(item)) { + return []; + } + + const role: ChatMessage["role"] | null = + item.role === "assistant" || item.role === "user" ? item.role : null; + const content = typeof item.content === "string" ? item.content : null; + + if (!role || content === null) { + return []; + } + + return [ + { + id: + typeof item.id === "string" && item.id.trim() ? item.id : createId(), + role, + content, + createdAt: + typeof item.createdAt === "number" && Number.isFinite(item.createdAt) + ? item.createdAt + : Date.now(), + state: isChatMessageState(item.state) ? item.state : undefined, + versionId: + typeof item.versionId === "string" && item.versionId.trim() + ? item.versionId + : undefined, + }, + ]; + }); + + return messages.length > 0 ? messages : createWelcomeMessages(); +} + +function normalizeDownloadUrls(value: unknown): Record | null { + if (!isRecord(value)) { + return null; + } + + const entries = Object.entries(value).filter( + (entry): entry is [string, string] => + typeof entry[0] === "string" && + entry[0].trim().length > 0 && + typeof entry[1] === "string" && + entry[1].trim().length > 0 + ); + + return entries.length > 0 ? Object.fromEntries(entries) : null; +} + +function normalizeVersions(value: unknown): GenerationVersion[] { + if (!Array.isArray(value)) { + return []; + } + + return value.flatMap((item, index) => { + if (!isRecord(item)) { + return []; + } + + const id = typeof item.id === "string" && item.id.trim() ? item.id : null; + const taskId = + typeof item.taskId === "string" && item.taskId.trim() + ? item.taskId + : null; + const prompt = typeof item.prompt === "string" ? item.prompt : null; + const modelVersion = + typeof item.modelVersion === "string" && item.modelVersion.trim() + ? item.modelVersion + : null; + const assistantMessageId = + typeof item.assistantMessageId === "string" && + item.assistantMessageId.trim() + ? item.assistantMessageId + : null; + + if ( + !id || + !taskId || + prompt === null || + !modelVersion || + !assistantMessageId + ) { + return []; + } + + return [ + { + id, + versionNumber: + typeof item.versionNumber === "number" && + Number.isInteger(item.versionNumber) && + item.versionNumber > 0 + ? item.versionNumber + : index + 1, + provider: isCadGenerationProvider(item.provider) + ? item.provider + : "kittycad-cad", + taskId, + generationMode: + item.generationMode === "iterate" ? "iterate" : "create", + parentVersionId: + typeof item.parentVersionId === "string" && + item.parentVersionId.trim() + ? item.parentVersionId + : null, + prompt, + modelVersion, + createdAt: + typeof item.createdAt === "number" && Number.isFinite(item.createdAt) + ? item.createdAt + : Date.now(), + status: isModel3DTaskStatus(item.status) ? item.status : "queued", + progress: + typeof item.progress === "number" && Number.isFinite(item.progress) + ? item.progress + : null, + modelUrl: readNullableText(item.modelUrl), + previewImageUrl: readNullableText(item.previewImageUrl), + codeUrl: readNullableText(item.codeUrl), + errorMessage: readNullableText(item.errorMessage), + conversationId: readNullableText(item.conversationId), + downloadUrls: normalizeDownloadUrls(item.downloadUrls), + feedback: isVersionFeedback(item.feedback) ? item.feedback : null, + assistantMessageId, + }, + ]; + }); +} + +function normalizePersistedWorkbenchState( + value: unknown +): PersistedAiCadWorkbenchState | null { + if (!isRecord(value)) { + return null; + } + + const versions = normalizeVersions(value.versions); + const activeVersionId = + typeof value.activeVersionId === "string" && + versions.some((version) => version.id === value.activeVersionId) + ? value.activeVersionId + : (versions[0]?.id ?? null); + + return { + prompt: typeof value.prompt === "string" ? value.prompt : "", + generationProvider: isCadGenerationProvider(value.generationProvider) + ? value.generationProvider + : "kittycad-cad", + modelVersion: + typeof value.modelVersion === "string" && + MODEL_VERSION_OPTIONS.includes( + value.modelVersion as (typeof MODEL_VERSION_OPTIONS)[number] + ) + ? value.modelVersion + : MODEL_VERSION_OPTIONS[0], + viewerSettings: normalizeViewerSettings(value.viewerSettings), + messages: normalizeMessages(value.messages), + versions, + activeVersionId, + savedAt: + typeof value.savedAt === "number" && Number.isFinite(value.savedAt) + ? value.savedAt + : undefined, + }; +} + +function serializeWorkbenchState(state: PersistedAiCadWorkbenchState) { + const { savedAt: _savedAt, ...payload } = state; + return JSON.stringify(payload); +} + +function serializeWorkspaceSyncState(params: { + name?: string | null; + state: PersistedAiCadWorkbenchState; +}) { + return JSON.stringify({ + name: normalizeWorkspaceTitle(params.name || ""), + prompt: params.state.prompt, + generationProvider: params.state.generationProvider, + modelVersion: params.state.modelVersion, + viewerSettings: params.state.viewerSettings, + messages: params.state.messages, + versions: params.state.versions, + activeVersionId: params.state.activeVersionId, + }); +} + +function readPersistedWorkbench( + workspaceId: string +): PersistedAiCadWorkbenchState | null { + if (typeof window === "undefined") { + return null; + } + + try { + const rawValue = window.localStorage.getItem( + getWorkbenchStorageKey(workspaceId) + ); + if (!rawValue) { + return null; + } + + const parsedValue = JSON.parse(rawValue) as unknown; + return normalizePersistedWorkbenchState(parsedValue); + } catch (_error) { + return null; + } +} + +function AiCadPanelResizeHandle() { + return ( + +
+ + ); +} + +function AiCadCollapsedPanel({ + direction, + icon: Icon, + onExpand, + title, +}: { + direction: "horizontal" | "vertical"; + icon: typeof BrainCircuit; + onExpand: () => void; + title: string; +}) { + return ( +
+
+
+ +
{title}
+
+ +
+
+ ); +} + +function AiCadRailButton({ + active, + icon: Icon, + label, + onClick, + shortLabel, +}: { + active: boolean; + icon: typeof BrainCircuit; + label: string; + onClick: () => void; + shortLabel: string; +}) { + return ( +
+ + +
+ ); +} + +function AiCadToolbarGroup({ + children, + className, + label, +}: { + children: ReactNode; + className?: string; + label: string; +}) { + return ( +
+ + {label} + + +
{children}
+
+ ); +} + +function AiCadMessageActionButton({ + icon: Icon, + label, + onClick, +}: { + icon: typeof BrainCircuit; + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +function AiCadStatusChip({ + label, + tone = "neutral", + value, +}: { + label: string; + tone?: "neutral" | "pending" | "success" | "warning"; + value: string; +}) { + return ( +
+ + {label} + + + {value} + +
+ ); +} + +function getLatestWorkspacePrompt( + prompt: string, + versions: GenerationVersion[] +): string | null { + const trimmedPrompt = prompt.trim(); + if (trimmedPrompt) { + return trimmedPrompt; + } + + return ( + versions.find((version) => version.prompt.trim())?.prompt.trim() || null + ); +} + +function buildWorkspaceSummaryFromWorkbench( + workspace: WorkspaceListItem, + state: { + prompt: string; + generationProvider: CadGenerationProvider; + versions: GenerationVersion[]; + } +): WorkspaceListItem { + return { + ...workspace, + generationProvider: state.generationProvider, + versionCount: state.versions.length, + completedCount: state.versions.filter( + (version) => version.status === "success" + ).length, + hasPendingTasks: state.versions.some( + (version) => !FINAL_TASK_STATUS.has(version.status) + ), + latestPrompt: getLatestWorkspacePrompt(state.prompt, state.versions), + }; +} + +function sortWorkspaceList(workspaces: WorkspaceListItem[]) { + return [...workspaces].sort((left, right) => { + const leftUpdatedAt = left.updatedAt + ? new Date(left.updatedAt).getTime() + : 0; + const rightUpdatedAt = right.updatedAt + ? new Date(right.updatedAt).getTime() + : 0; + + return rightUpdatedAt - leftUpdatedAt; + }); +} + +export function AiCadWorkbench() { + const [prompt, setPrompt] = useState(""); + const [generationProvider, setGenerationProvider] = + useState("kittycad-cad"); + const [modelVersion, setModelVersion] = useState( + MODEL_VERSION_OPTIONS[0] + ); + const [kittycadProviderStatus, setKittycadProviderStatus] = + useState(null); + const [isLoadingKittycadProviderStatus, setIsLoadingKittycadProviderStatus] = + useState(true); + const [viewerSettings, setViewerSettings] = useState( + DEFAULT_VIEWER_SETTINGS + ); + const [isSubmitting, setIsSubmitting] = useState(false); + const [panelDirection, setPanelDirection] = useState< + "horizontal" | "vertical" + >("vertical"); + const [isConversationPanelCollapsed, setIsConversationPanelCollapsed] = + useState(false); + const [isHistoryPanelCollapsed, setIsHistoryPanelCollapsed] = useState(false); + const [isResultPanelCollapsed, setIsResultPanelCollapsed] = useState(false); + const [activeResultTab, setActiveResultTab] = useState<"preview" | "code">( + "preview" + ); + const [compareVersionId, setCompareVersionId] = useState(null); + const [showOnlyChangedCodeRows, setShowOnlyChangedCodeRows] = useState(false); + const [cachedCodeByUrl, setCachedCodeByUrl] = useState< + Record + >({}); + const [loadingCodePreviewByUrl, setLoadingCodePreviewByUrl] = useState< + Record + >({}); + const [codePreviewErrorByUrl, setCodePreviewErrorByUrl] = useState< + Record + >({}); + const [codeActionError, setCodeActionError] = useState(null); + const [messages, setMessages] = useState( + createWelcomeMessages + ); + const [versions, setVersions] = useState([]); + const [activeVersionId, setActiveVersionId] = useState(null); + const [expandedVersionDetails, setExpandedVersionDetails] = useState< + Record + >({}); + const [workspaces, setWorkspaces] = useState([]); + const [activeWorkspaceId, setActiveWorkspaceId] = useState( + null + ); + const [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( + null + ); + const versionsRef = useRef(versions); + const activeWorkspaceIdRef = useRef(null); + const conversationPanelRef = useRef(null); + const historyPanelRef = useRef(null); + const resultPanelRef = useRef(null); + const localSavedAtRef = useRef>({}); + const lastServerSavedSnapshotRef = useRef>({}); + const hasLocalEditsBeforeServerHydrationRef = useRef>( + {} + ); + const isApplyingExternalWorkspaceRef = useRef(false); + + useEffect(() => { + versionsRef.current = versions; + }, [versions]); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const mediaQuery = window.matchMedia("(min-width: 1024px)"); + const syncPanelDirection = () => { + setPanelDirection(mediaQuery.matches ? "horizontal" : "vertical"); + }; + + syncPanelDirection(); + + if (typeof mediaQuery.addEventListener === "function") { + mediaQuery.addEventListener("change", syncPanelDirection); + return () => { + mediaQuery.removeEventListener("change", syncPanelDirection); + }; + } + + mediaQuery.addListener(syncPanelDirection); + return () => { + mediaQuery.removeListener(syncPanelDirection); + }; + }, []); + + useEffect(() => { + let cancelled = false; + + const loadWorkspaces = async () => { + setIsLoadingWorkspaceList(true); + setWorkspaceSyncState("loading"); + setWorkspaceSyncError(null); + setWorkspaceOperationError(null); + + const preferredWorkspaceId = readPersistedActiveWorkspaceId(); + const result = await aiCadGenerationService.listWorkspaces(); + if (cancelled) { + return; + } + + if (!result.success) { + setWorkspaceSyncState("error"); + setWorkspaceSyncError(result.error || "读取工作台列表失败"); + setWorkspaceOperationError(result.error || "读取工作台列表失败"); + setIsLoadingWorkspaceList(false); + return; + } + + let nextWorkspaces = result.data || []; + + if (nextWorkspaces.length === 0) { + const createdResult = await aiCadGenerationService.createWorkspace(); + if (cancelled) { + return; + } + + if (!createdResult.success || !createdResult.data) { + setWorkspaceSyncState("error"); + setWorkspaceSyncError(createdResult.error || "初始化默认工作台失败"); + setWorkspaceOperationError( + createdResult.error || "初始化默认工作台失败" + ); + setIsLoadingWorkspaceList(false); + return; + } + + nextWorkspaces = [createdResult.data]; + } + + const sortedWorkspaces = sortWorkspaceList(nextWorkspaces); + setWorkspaces(sortedWorkspaces); + setActiveWorkspaceId( + sortedWorkspaces.find( + (workspace) => workspace.id === preferredWorkspaceId + )?.id || + sortedWorkspaces[0]?.id || + null + ); + setIsLoadingWorkspaceList(false); + }; + + swallowAsync(loadWorkspaces()); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + activeWorkspaceIdRef.current = activeWorkspaceId; + }, [activeWorkspaceId]); + + useEffect(() => { + 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 ? ( + + ) : ( + + ); + } + + return isCollapsed ? ( + + ) : ( + + ); + } + + 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[], + versions: workbenchPayload.versions as Record[], + activeVersionId: workbenchPayload.activeVersionId, + } + ); + + if (result.success) { + lastServerSavedSnapshotRef.current[workspaceId] = + serializedWorkspaceSyncSnapshot; + if (result.data?.summary) { + setWorkspaces((current) => + sortWorkspaceList( + current.map((workspace) => + workspace.id === workspaceId + ? result.data?.summary || workspace + : workspace + ) + ) + ); + } else { + setWorkspaces((current) => + current.map((workspace) => + workspace.id === workspaceId + ? { + ...workspace, + name: result.data?.name || workspace.name, + updatedAt: + result.data?.updatedAt || workspace.updatedAt, + } + : workspace + ) + ); + } + if (activeWorkspaceIdRef.current === workspaceId) { + setWorkspaceSyncState("synced"); + setWorkspaceSyncError(null); + } + return; + } + + if (activeWorkspaceIdRef.current === workspaceId) { + setWorkspaceSyncState("error"); + setWorkspaceSyncError(result.error || "云端同步失败"); + } + })() + ); + }, 1200); + + return () => { + window.clearTimeout(timer); + }; + }, [ + activeWorkspace?.name, + activeWorkspaceId, + hasHydratedFromStorage, + hasHydratedServerWorkspace, + serializedWorkbenchSnapshot, + ]); + + useEffect(() => { + if (!activeWorkspaceId) { + return; + } + + setWorkspaces((current) => + current.map((workspace) => + workspace.id === activeWorkspaceId + ? buildWorkspaceSummaryFromWorkbench(workspace, { + prompt, + generationProvider, + versions, + }) + : workspace + ) + ); + }, [activeWorkspaceId, generationProvider, prompt, versions]); + + useEffect(() => { + if (!activeVersion) { + setActiveResultTab("preview"); + return; + } + + if (!activePreviewVersion && activeCodeUrl) { + setActiveResultTab("code"); + return; + } + + setActiveResultTab("preview"); + }, [activeCodeUrl, activePreviewVersion, activeVersion]); + + useEffect(() => { + if (!codeContextKey) { + return; + } + + setCodeActionError(null); + }, [codeContextKey]); + + useEffect(() => { + let cancelled = false; + + const loadProviderStatus = async () => { + setIsLoadingKittycadProviderStatus(true); + const result = await aiCadGenerationService.getProviderStatus(); + if (cancelled) { + return; + } + + if (result.success && result.data) { + setKittycadProviderStatus(result.data); + } else { + setKittycadProviderStatus({ + configured: false, + source: "none", + }); + } + setIsLoadingKittycadProviderStatus(false); + }; + + swallowAsync(loadProviderStatus()); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!hasPendingTasks) { + return; + } + + let cancelled = false; + + const syncPendingTasks = async () => { + const pendingVersions = versionsRef.current.filter( + (version) => !FINAL_TASK_STATUS.has(version.status) + ); + + if (pendingVersions.length === 0) { + return; + } + + const responses = await Promise.all( + pendingVersions.map(async (version) => ({ + versionId: version.id, + taskId: version.taskId, + assistantMessageId: version.assistantMessageId, + versionNumber: version.versionNumber, + result: + version.provider === "kittycad-cad" + ? await aiCadGenerationService.getTask(version.taskId) + : await model3DGenerationService.getTask(version.taskId), + })) + ); + + if (cancelled) { + return; + } + + for (const response of responses) { + if (!response.result.success || !response.result.data) { + continue; + } + + const payload = response.result.data; + + setVersions((current) => + current.map((version) => + version.id === response.versionId + ? { + ...version, + status: payload.status ?? version.status, + progress: + typeof payload.progress === "number" + ? payload.progress + : version.progress, + modelUrl: + payload.modelUrl === undefined + ? version.modelUrl + : payload.modelUrl, + previewImageUrl: + payload.previewImageUrl === undefined + ? version.previewImageUrl + : payload.previewImageUrl, + codeUrl: + "codeUrl" in payload + ? payload.codeUrl || null + : version.codeUrl, + errorMessage: + payload.errorMessage === undefined + ? version.errorMessage + : payload.errorMessage, + conversationId: + "conversationId" in payload + ? payload.conversationId || null + : version.conversationId, + downloadUrls: + "downloadUrls" in payload + ? payload.downloadUrls || null + : version.downloadUrls, + modelVersion: + "modelVersion" in payload && + typeof payload.modelVersion === "string" && + payload.modelVersion.trim() + ? payload.modelVersion + : version.modelVersion, + } + : version + ) + ); + + setMessages((current) => + current.map((message) => + message.id === response.assistantMessageId + ? { + ...message, + state: + payload.status === "success" + ? "success" + : payload.status === "failed" || + payload.status === "banned" || + payload.status === "cancelled" || + payload.status === "expired" + ? "failed" + : payload.status === "running" + ? "running" + : "queued", + content: buildAssistantMessage( + response.versionNumber, + payload.status ?? "unknown", + payload.progress, + payload.errorMessage + ), + } + : message + ) + ); + } + }; + + swallowAsync(syncPendingTasks()); + const timer = window.setInterval(() => { + swallowAsync(syncPendingTasks()); + }, 2500); + + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [hasPendingTasks]); + + function updateActiveWorkspaceName(nextName: string) { + if (!activeWorkspaceId) { + return; + } + + const normalizedName = normalizeWorkspaceTitle(nextName); + setWorkspaces((current) => + current.map((workspace) => + workspace.id === activeWorkspaceId + ? { ...workspace, name: normalizedName } + : workspace + ) + ); + 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) { + 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 ( +
+
+
+
+
+
+ + + +
+
+
+ AI +
+ + AI-CAD Studio + +
+
+ + {normalizedWorkspaceName} / main.kcl + +
+
+ +
+ + +
+ AI +
+
+
+ +
+
+
+ AI-CAD +
+
+ commitWorkspaceName(workspaceNameDraft)} + onChange={(event) => + setWorkspaceNameDraft(event.target.value) + } + onKeyDown={handleWorkspaceNameKeyDown} + placeholder="untitled / main.kcl" + value={workspaceNameDraft} + /> +
+
+ +
+ + + {isLoadingWorkspaceList ? "正在读取" : normalizedWorkspaceName} + + + {versions.length} versions + + + {completedCount} completed + + + {workspaceSyncState === "synced" ? ( + + ) : workspaceSyncState === "syncing" || + workspaceSyncState === "loading" ? ( + + ) : ( + + )} + {workspaceSyncState === "synced" + ? "Synced" + : workspaceSyncState === "syncing" + ? "Syncing" + : workspaceSyncState === "loading" + ? "Loading" + : "Sync error"} + +
+
+ + {(workspaceSyncError || workspaceOperationError) && ( +
+ {workspaceSyncError ? ( +

+ 云端同步提示:{workspaceSyncError} +

+ ) : null} + {workspaceOperationError ? ( +

+ 工作台提示:{workspaceOperationError} +

+ ) : null} +
+ )} + +
+
+
+ + 多工作台 +
+ +
+ + + +
+
+ + +
+ {workspaces.map((workspace) => ( + + ))} +
+
+ +
+
+
+ Panel Layout +
+ + left tree / center canvas / right agent + +
+
+ + + +
+
+
+
+
+ +
+
+
+
+
+ Panels +
+
+ { + historyPanelRef.current?.expand(); + setActiveResultTab("preview"); + }} + shortLabel="Tree" + /> + { + resultPanelRef.current?.expand(); + setActiveResultTab("preview"); + }} + shortLabel="3D" + /> + { + resultPanelRef.current?.expand(); + setActiveResultTab("code"); + }} + shortLabel="KCL" + /> + conversationPanelRef.current?.expand()} + shortLabel="AI" + /> +
+
+ +
+
+ Tools +
+
+ {primaryDownloadEntry ? ( + + + + Export + + + ) : ( + + )} + +
+
+
+ +
+
+
+
+
+ Canvas Workspace +
+ + Docked Panels + +
+
+ + {activeVersion + ? `Focus V${activeVersion.versionNumber}` + : "Ready for first pass"} + + {versions.length} versions + + {hasPendingTasks + ? `${pendingTaskCount} task active` + : "Workspace stable"} + +
+
+ +
+ + + + + + + + + + + + + + {generationProvider === "kittycad-cad" + ? "KittyCAD / Zoo CAD" + : "Tripo3D"} + + + {viewerSettings.envPreset} + + {activeVersion ? ( + + V{activeVersion.versionNumber} + + ) : null} + +
+
+ +
+ + setIsConversationPanelCollapsed(true)} + onExpand={() => setIsConversationPanelCollapsed(false)} + order={3} + ref={conversationPanelRef} + > + {isConversationPanelCollapsed ? ( + conversationPanelRef.current?.expand()} + title="AI-CAD Agent" + /> + ) : ( + + +
+
+ + + AI-CAD Agent + + + Continuous design thread for the active version. + +
+
+ + Agent + + + +
+
+ +
+
+
+ + Session Context + + {activeVersion ? ( + + Focus V{activeVersion.versionNumber} + + ) : ( + + New thread + + )} +
+ {activeVersion ? ( +
+ + {getProviderLabel(activeVersion.provider)} + + + {getStatusLabel(activeVersion.status)} + + + {activeVersion.generationMode === "iterate" + ? "Iterative" + : "Initial"} + +
+ ) : null} +
+

+ {activeVersion + ? activeVersion.generationMode === "iterate" + ? `当前已经沿着 V${activeVersion.versionNumber} 进入连续修改模式。` + : `当前焦点在 V${activeVersion.versionNumber}。` + : "当前还是首轮对话阶段。"} +

+
+
+ + + +
+ {messages.map((message) => { + const linkedVersion = message.versionId + ? findVersionById(versions, message.versionId) + : null; + + return ( +
+
+
+ {message.role === "user" ? ( + <> + + 你 + + ) : ( + <> + + AI-CAD Agent + + )} + + {formatClockTime(message.createdAt)} + +
+ {message.role !== "user" && + message.state ? ( + + {getMessageStateLabel(message.state)} + + ) : null} +
+ + {linkedVersion ? ( +
+ + V{linkedVersion.versionNumber} + + + {getStatusLabel(linkedVersion.status)} + + {linkedVersion.generationMode === + "iterate" ? ( + + 连续修改 + + ) : null} + {linkedVersion.parentVersionId ? ( + + linked output + + ) : ( + + root output + + )} +
+ ) : null} + +
+ {message.content} +
+ +
+
+ {linkedVersion ? ( + + setActiveVersionId(linkedVersion.id) + } + /> + ) : null} + {linkedVersion && + canIterateKittyCadVersion( + linkedVersion + ) ? ( + + handleFocusVersionForIteration( + linkedVersion + ) + } + /> + ) : null} + + setPrompt(message.content) + } + /> +
+ + {linkedVersion + ? linkedVersion.generationMode === + "iterate" + ? "variant thread" + : "initial thread" + : message.role === "assistant" + ? "agent note" + : "prompt draft"} + +
+
+ ); + })} +
+
+ +
+
+ + 生成引擎 + + + {generationProvider === "tripo-3d" ? ( + <> + + Tripo 模型版本 + + + + ) : ( + + 输出 GLB / STEP / KCL + + )} + {activeVersion ? ( + + {willSubmitAsIteration + ? `基于 V${activeVersion.versionNumber} 连续修改` + : `参考版本 V${activeVersion.versionNumber}`} + + ) : null} +
+ + {generationProvider === "kittycad-cad" ? ( +
+
+ +
+ {isLoadingKittycadProviderStatus ? ( + + 正在检查 KittyCAD CAD 配置状态... + + ) : kittycadProviderStatus?.configured ? ( + + 当前将通过 + {kittycadProviderStatus.providerName + ? ` ${kittycadProviderStatus.providerName}` + : " KittyCAD CAD"} + 发起生成,配置来源: + {getProviderSourceLabel( + kittycadProviderStatus.source + )} + 。 + + ) : ( + + 还没有检测到 KittyCAD CAD + 可用配置。请先到“系统设置 > 本地AI”保存 + API Token,或者先切换到 Tripo3D 回退链路。 + + )} +
+
+
+ ) : null} + +
+
+
+ Suggested Next Edits +
+ {semanticFocusInsights.length > 0 ? ( +
+ {semanticFocusInsights.map((insight) => ( + + {insight.shortLabel} + + ))} +
+ ) : null} +
+
+ {followUpSuggestions.slice(0, 3).map((item) => ( + + ))} +
+ +
+ Prompt Directives +
+
+ + {STRUCTURED_PROMPT_DIRECTIVES.map((directive) => ( + + ))} +
+ {promptStructuredDirectives.length > 0 ? ( +
+ {promptStructuredDirectives.map((directive) => ( + + {directive.label}: + {truncateText(directive.value, 22)} + + ))} +
+ ) : null} +
+ +
+
+
+ + Compose + + + {willSubmitAsIteration + ? "Iterative edit" + : "New version"} + +
+
+ {promptLineCount} lines + {promptCharacterCount} chars +
+
+