diff --git a/SECURITY_IMPROVEMENTS_SUMMARY.md b/SECURITY_IMPROVEMENTS_SUMMARY.md index ee74ff3d0..0cbac6b41 100644 --- a/SECURITY_IMPROVEMENTS_SUMMARY.md +++ b/SECURITY_IMPROVEMENTS_SUMMARY.md @@ -144,18 +144,10 @@ log.error("Operation failed", error, { userId: "123" }); #### 7. 修复 service 层错误吞没 **风险等级**: 🟡 中等风险 **改动**: -- `services/ai-cad/index.ts`: 完全重写,所有 catch 块现在记录错误 - `services/canvas/index.ts`: 添加 logger 导入和辅助函数(未全部迁移) **潜在问题**: - `handleError(err, context, fallbackMessage)` 参数顺序与之前不同 -- 如果有其他代码依赖 ai-cad service 的错误格式,可能受影响 - -**测试建议**: -```bash -# 触发一个 AI-CAD 错误(如无效的 workspace ID) -# 检查日志是否包含完整错误堆栈 -``` --- diff --git a/app/(admin)/settings/_components/local-ai-sections/model3d-sections.tsx b/app/(admin)/settings/_components/local-ai-sections/model3d-sections.tsx index 9d4ed9987..53fc5119d 100644 --- a/app/(admin)/settings/_components/local-ai-sections/model3d-sections.tsx +++ b/app/(admin)/settings/_components/local-ai-sections/model3d-sections.tsx @@ -916,8 +916,8 @@ export function LocalAiModel3dSections({ - 配置 AI-CAD 页面后续接入的 KittyCAD / Zoo Text-to-CAD - 服务,保存后写入数据库 AI 提供商配置。 + 配置官方 HiCAD 使用的 KittyCAD / Zoo 服务,保存后写入数据库 AI + 提供商配置。 } expanded={isLocalAiSectionExpanded("kittycad-cad")} @@ -1003,7 +1003,7 @@ export function LocalAiModel3dSections({
- 关闭后 AI-CAD 页面将无法切换到外部 CAD 服务 + 关闭后 HiCAD 将无法连接 Zoo 建模和 AI 助手服务
diff --git a/app/(chat)/ai-cad/page.tsx b/app/(chat)/ai-cad/page.tsx index b902b2794..514994dd6 100644 --- a/app/(chat)/ai-cad/page.tsx +++ b/app/(chat)/ai-cad/page.tsx @@ -3,7 +3,6 @@ import path from "node:path"; import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { auth } from "@/app/(auth)/auth"; -import { ZooDesignStudioFrame } from "@/components/ai-cad/zoo-design-studio-frame"; const ZOO_MODELING_APP_SRC = "/vendor/zoo-modeling-app/index.html"; @@ -28,9 +27,17 @@ export default async function AiCadPage() { } return ( - +
+
+

HiCAD 官方前端尚未构建

+

+ 请运行 + + pnpm zoo-modeling-app:vendor + + 生成官方前端,然后刷新本页。 +

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

{subtitle}

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

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

- ) : null} - {workspaceOperationError ? ( -

- 工作台提示:{workspaceOperationError} -

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