import { NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; import { KittyCadApiError, kittyCadRequest } from "@/lib/kittycad/client"; import { getKittyCadProviderConfig } from "@/lib/kittycad/server"; import { uploadFile } from "@/lib/storage/service"; export const runtime = "nodejs"; export const maxDuration = 60; type KittyCadTaskResponse = { id?: string; status?: string; outputs?: Record | null; error?: string | null; code?: string | null; prompt?: string | null; conversation_id?: string | null; model_version?: string | null; output_format?: string | null; }; type ArchivedFile = { sourceHash: string; archivedUrl: string; }; type RouteParams = { params: Promise<{ taskId: string }>; }; function getArchiveCache() { const globalState = globalThis as Record; if (!globalState.__kittyCadAssetArchiveCache) { globalState.__kittyCadAssetArchiveCache = new Map(); } return globalState.__kittyCadAssetArchiveCache as Map; } function getArchiveInflight() { const globalState = globalThis as Record; if (!globalState.__kittyCadAssetArchiveInflight) { globalState.__kittyCadAssetArchiveInflight = new Map< string, Promise >(); } return globalState.__kittyCadAssetArchiveInflight as Map< string, Promise >; } function mapKittyCadStatus(status: unknown) { const normalized = String(status || "") .trim() .toLowerCase(); if (!normalized) { return "queued" as const; } if ( normalized === "queued" || normalized === "pending" || normalized === "created" || normalized === "submitted" ) { return "queued" as const; } if ( normalized === "running" || normalized === "processing" || normalized === "in_progress" || normalized === "working" || normalized === "generating" ) { return "running" as const; } if ( normalized === "completed" || normalized === "success" || normalized === "succeeded" || normalized === "done" || normalized === "finished" ) { return "success" as const; } if (normalized === "cancelled" || normalized === "canceled") { return "cancelled" as const; } if (normalized === "expired" || normalized === "timed_out") { return "expired" as const; } if (normalized === "banned" || normalized === "blocked") { return "banned" as const; } if ( normalized === "failed" || normalized === "error" || normalized === "errored" ) { return "failed" as const; } return "unknown" as const; } function getFileExtension(filename: string) { const normalized = String(filename || "") .trim() .toLowerCase(); const cleanName = normalized.split("?")[0]?.split("#")[0] || normalized; const segments = cleanName.split("."); return segments.length > 1 ? segments.at(-1) || "" : ""; } function isImageExtension(extension: string) { return ["png", "jpg", "jpeg", "webp", "gif"].includes(extension); } function pickPreferredModelFile(files: Array<{ name: string; url: string }>) { const preferredOrder = ["glb", "gltf", "obj", "stl", "step", "stp"]; for (const extension of preferredOrder) { const matched = files.find( (item) => getFileExtension(item.name) === extension ); if (matched) { return matched; } } return null; } function pickPreviewImageFile(files: Array<{ name: string; url: string }>) { return ( files.find((item) => isImageExtension(getFileExtension(item.name))) || null ); } async function archiveOutputIfNeeded(params: { taskId: string; filename: string; base64: string; }) { const normalizedFilename = String(params.filename || "").trim(); const normalizedBase64 = String(params.base64 || "").trim(); if (!normalizedFilename || !normalizedBase64) { return null; } const cacheKey = `${params.taskId}:${normalizedFilename}`; const cache = getArchiveCache(); const cached = cache.get(cacheKey); if (cached && cached.sourceHash === normalizedBase64) { return cached.archivedUrl; } const inflight = getArchiveInflight(); const existingTask = inflight.get(cacheKey); if (existingTask) { return (await existingTask) || null; } const archiveTask = (async () => { try { const binary = Buffer.from(normalizedBase64, "base64"); if (binary.byteLength <= 0) { throw new Error("KittyCAD 输出文件为空"); } const arrayBuffer = Uint8Array.from(binary).buffer; const safeFilename = normalizedFilename.replace(/[^\w.-]+/g, "-") || "kittycad-output.bin"; const uploaded = await uploadFile( `kittycad-${params.taskId}-${safeFilename}`, arrayBuffer ); cache.set(cacheKey, { sourceHash: normalizedBase64, archivedUrl: uploaded.url, }); return uploaded.url; } catch (error) { console.error("[KittyCADArchive] failed", { taskId: params.taskId, filename: normalizedFilename, error: error instanceof Error ? error.message : String(error), }); return null; } finally { inflight.delete(cacheKey); } })(); inflight.set(cacheKey, archiveTask); return await archiveTask; } async function archiveTextOutputIfNeeded(params: { taskId: string; filename: string; content: string; }) { const normalizedFilename = String(params.filename || "").trim(); const normalizedContent = String(params.content || ""); if (!normalizedFilename || !normalizedContent.trim()) { return null; } const cacheKey = `${params.taskId}:${normalizedFilename}`; const cache = getArchiveCache(); const cached = cache.get(cacheKey); if (cached && cached.sourceHash === normalizedContent) { return cached.archivedUrl; } const inflight = getArchiveInflight(); const existingTask = inflight.get(cacheKey); if (existingTask) { return (await existingTask) || null; } const archiveTask = (async () => { try { const arrayBuffer = Uint8Array.from( new TextEncoder().encode(normalizedContent) ).buffer; const safeFilename = normalizedFilename.replace(/[^\w.-]+/g, "-") || "kittycad-output.kcl"; const uploaded = await uploadFile( `kittycad-${params.taskId}-${safeFilename}`, arrayBuffer ); cache.set(cacheKey, { sourceHash: normalizedContent, archivedUrl: uploaded.url, }); return uploaded.url; } catch (error) { console.error("[KittyCADArchive:text] failed", { taskId: params.taskId, filename: normalizedFilename, error: error instanceof Error ? error.message : String(error), }); return null; } finally { inflight.delete(cacheKey); } })(); inflight.set(cacheKey, archiveTask); return await archiveTask; } export async function GET(_request: Request, { params }: RouteParams) { try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json( { success: false, error: "未登录" }, { status: 401 } ); } const { taskId } = await params; const normalizedTaskId = String(taskId || "").trim(); if (!normalizedTaskId) { return NextResponse.json( { success: false, error: "缺少 taskId" }, { status: 400 } ); } const config = await getKittyCadProviderConfig(); const result = await kittyCadRequest(config, { method: "GET", path: `/user/text-to-cad/${encodeURIComponent(normalizedTaskId)}`, }); const status = mapKittyCadStatus(result.status); if (status !== "success") { return NextResponse.json({ success: true, data: { taskId: normalizedTaskId, type: "cad", status, progress: status === "running" ? 65 : null, errorMessage: result.error || null, conversationId: result.conversation_id || null, modelVersion: result.model_version || null, outputFormat: result.output_format || null, output: result, }, }); } const outputEntries = Object.entries(result.outputs || {}).filter( (entry): entry is [string, string] => typeof entry[0] === "string" && entry[0].trim().length > 0 && typeof entry[1] === "string" && entry[1].trim().length > 0 ); const archivedEntries = await Promise.all( outputEntries.map(async ([name, base64]) => { const archivedUrl = await archiveOutputIfNeeded({ taskId: normalizedTaskId, filename: name, base64, }); return archivedUrl ? { name, url: archivedUrl } : null; }) ); const archivedFiles = archivedEntries.filter( (item): item is { name: string; url: string } => Boolean(item?.name && item?.url) ); const hasArchivedKclFile = archivedFiles.some( (item) => getFileExtension(item.name) === "kcl" ); if ( !hasArchivedKclFile && typeof result.code === "string" && result.code.trim() ) { const archivedCodeUrl = await archiveTextOutputIfNeeded({ taskId: normalizedTaskId, filename: "main.kcl", content: result.code, }); if (archivedCodeUrl) { archivedFiles.push({ name: "main.kcl", url: archivedCodeUrl, }); } } const modelFile = pickPreferredModelFile(archivedFiles); const previewImageFile = pickPreviewImageFile(archivedFiles); const downloadUrls = Object.fromEntries( archivedFiles.map((item) => [item.name, item.url]) ); const codeFile = archivedFiles.find((item) => getFileExtension(item.name) === "kcl") || null; return NextResponse.json({ success: true, data: { taskId: normalizedTaskId, type: "cad", status: "success", progress: 100, modelUrl: modelFile && ["glb", "gltf"].includes(getFileExtension(modelFile.name)) ? modelFile.url : null, previewImageUrl: previewImageFile?.url || null, errorMessage: result.error || null, conversationId: result.conversation_id || null, modelVersion: result.model_version || null, outputFormat: result.output_format || null, codeUrl: codeFile?.url || null, downloadUrls, output: result, }, }); } catch (error) { if (error instanceof KittyCadApiError) { return NextResponse.json( { success: false, error: error.message, payload: error.payload, }, { status: error.httpStatus || 502 } ); } return NextResponse.json( { success: false, error: error instanceof Error ? error.message : "查询 AI-CAD 任务失败", }, { status: 500 } ); } }