468 lines
13 KiB
TypeScript
468 lines
13 KiB
TypeScript
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<string, string> | 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<string, unknown>;
|
|
if (!globalState.__kittyCadAssetArchiveCache) {
|
|
globalState.__kittyCadAssetArchiveCache = new Map<string, ArchivedFile>();
|
|
}
|
|
return globalState.__kittyCadAssetArchiveCache as Map<string, ArchivedFile>;
|
|
}
|
|
|
|
function getArchiveInflight() {
|
|
const globalState = globalThis as Record<string, unknown>;
|
|
if (!globalState.__kittyCadAssetArchiveInflight) {
|
|
globalState.__kittyCadAssetArchiveInflight = new Map<
|
|
string,
|
|
Promise<string | null>
|
|
>();
|
|
}
|
|
return globalState.__kittyCadAssetArchiveInflight as Map<
|
|
string,
|
|
Promise<string | null>
|
|
>;
|
|
}
|
|
|
|
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<ReturnType<typeof getKittyCadProviderConfig>>;
|
|
task: KittyCadTaskResponse;
|
|
taskId: string;
|
|
}) {
|
|
if (extractOutputEntries(params.task).length > 0) {
|
|
return params.task;
|
|
}
|
|
|
|
try {
|
|
const asyncOperation = await kittyCadRequest<KittyCadAsyncOperationResponse>(
|
|
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<KittyCadTaskResponse>(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 }
|
|
);
|
|
}
|
|
}
|