332 lines
9.3 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
}
|