332 lines
9.3 KiB
TypeScript

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<string, unknown>[];
};
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<string, string> | 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<string, unknown> =>
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<KittyCadSourceTaskResponse>(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<KittyCadIterationResponse>(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<KittyCadCreateResponse>(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 }
);
}
}