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 } ); } }