refactor: remove legacy AI-CAD workbench
This commit is contained in:
parent
08d8a1d4af
commit
752d1f99d2
@ -144,18 +144,10 @@ log.error("Operation failed", error, { userId: "123" });
|
||||
#### 7. 修复 service 层错误吞没
|
||||
**风险等级**: 🟡 中等风险
|
||||
**改动**:
|
||||
- `services/ai-cad/index.ts`: 完全重写,所有 catch 块现在记录错误
|
||||
- `services/canvas/index.ts`: 添加 logger 导入和辅助函数(未全部迁移)
|
||||
|
||||
**潜在问题**:
|
||||
- `handleError(err, context, fallbackMessage)` 参数顺序与之前不同
|
||||
- 如果有其他代码依赖 ai-cad service 的错误格式,可能受影响
|
||||
|
||||
**测试建议**:
|
||||
```bash
|
||||
# 触发一个 AI-CAD 错误(如无效的 workspace ID)
|
||||
# 检查日志是否包含完整错误堆栈
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -916,8 +916,8 @@ export function LocalAiModel3dSections({
|
||||
<LocalAiSectionShell
|
||||
description={
|
||||
<>
|
||||
配置 AI-CAD 页面后续接入的 KittyCAD / Zoo Text-to-CAD
|
||||
服务,保存后写入数据库 AI 提供商配置。
|
||||
配置官方 HiCAD 使用的 KittyCAD / Zoo 服务,保存后写入数据库 AI
|
||||
提供商配置。
|
||||
</>
|
||||
}
|
||||
expanded={isLocalAiSectionExpanded("kittycad-cad")}
|
||||
@ -1003,7 +1003,7 @@ export function LocalAiModel3dSections({
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm">启用 KittyCAD CAD</Label>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
关闭后 AI-CAD 页面将无法切换到外部 CAD 服务
|
||||
关闭后 HiCAD 将无法连接 Zoo 建模和 AI 助手服务
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@ -3,7 +3,6 @@ import path from "node:path";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { ZooDesignStudioFrame } from "@/components/ai-cad/zoo-design-studio-frame";
|
||||
|
||||
const ZOO_MODELING_APP_SRC = "/vendor/zoo-modeling-app/index.html";
|
||||
|
||||
@ -28,9 +27,17 @@ export default async function AiCadPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<ZooDesignStudioFrame
|
||||
isReady={false}
|
||||
src={ZOO_MODELING_APP_SRC}
|
||||
/>
|
||||
<main className="flex h-svh w-full items-center justify-center bg-[#10100e] p-6 text-[#f8f6ef]">
|
||||
<section className="w-full max-w-xl rounded-lg border border-[#3a352c] bg-[#171510] p-6 shadow-2xl">
|
||||
<h1 className="font-semibold text-lg">HiCAD 官方前端尚未构建</h1>
|
||||
<p className="mt-2 text-[#d3cabb] text-sm leading-6">
|
||||
请运行
|
||||
<code className="mx-1 rounded bg-white/10 px-1.5 py-0.5">
|
||||
pnpm zoo-modeling-app:vendor
|
||||
</code>
|
||||
生成官方前端,然后刷新本页。
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,122 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
getKittyCadModelingCommandError,
|
||||
isKittyCadModelingCommandSuccessful,
|
||||
sendKittyCadModelingCommand,
|
||||
takeKittyCadModelingSnapshot,
|
||||
} from "@/lib/kittycad/modeling-session";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
type ModelingCommandRequest = {
|
||||
captureSnapshot?: boolean;
|
||||
cmd?: Record<string, unknown>;
|
||||
cmdId?: string;
|
||||
sessionId?: string;
|
||||
snapshotFormat?: "jpeg" | "png";
|
||||
};
|
||||
|
||||
function readOptionalRecord(value: unknown) {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
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 ModelingCommandRequest;
|
||||
const sessionId = String(body.sessionId || "").trim();
|
||||
if (!sessionId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "缺少官方建模会话 ID" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const cmd = readOptionalRecord(body.cmd);
|
||||
const cmdType = String(cmd?.type || "").trim();
|
||||
if (!cmd || !cmdType) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "缺少有效的官方建模命令" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const cmdId = String(body.cmdId || "").trim() || randomUUID();
|
||||
const response = await sendKittyCadModelingCommand({
|
||||
cmd: {
|
||||
...cmd,
|
||||
type: cmdType,
|
||||
},
|
||||
cmdId,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
if (!isKittyCadModelingCommandSuccessful(response)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getKittyCadModelingCommandError(response),
|
||||
payload: response.errors || null,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let snapshotUrl: string | null = null;
|
||||
let snapshotError: string | null = null;
|
||||
|
||||
const shouldCaptureSnapshot =
|
||||
body.captureSnapshot === true && cmdType !== "take_snapshot";
|
||||
|
||||
if (cmdType === "take_snapshot") {
|
||||
const contents = String(response.resp?.data?.contents || "").trim();
|
||||
const snapshotFormat = body.snapshotFormat || "png";
|
||||
snapshotUrl = contents
|
||||
? `data:${snapshotFormat === "jpeg" ? "image/jpeg" : "image/png"};base64,${contents}`
|
||||
: null;
|
||||
} else if (shouldCaptureSnapshot) {
|
||||
try {
|
||||
const snapshot = await takeKittyCadModelingSnapshot({
|
||||
format: body.snapshotFormat || "png",
|
||||
sessionId,
|
||||
});
|
||||
snapshotUrl = snapshot.snapshotUrl;
|
||||
} catch (error) {
|
||||
snapshotError =
|
||||
error instanceof Error ? error.message : "获取官方建模快照失败";
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
raw: response,
|
||||
requestId: response.request_id || cmdId,
|
||||
responseData: response.resp?.data || null,
|
||||
responseType: response.resp?.type || cmdType,
|
||||
snapshotError,
|
||||
snapshotUrl,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "发送官方建模命令失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { closeKittyCadModelingSession } from "@/lib/kittycad/modeling-session";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
type RouteParams = {
|
||||
params: Promise<{
|
||||
sessionId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function DELETE(_request: Request, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { sessionId } = await params;
|
||||
const normalizedSessionId = String(sessionId || "").trim();
|
||||
if (!normalizedSessionId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "缺少 sessionId" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await closeKittyCadModelingSession(normalizedSessionId);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "关闭官方建模会话失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { createKittyCadModelingSession } from "@/lib/kittycad/modeling-session";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const modelingSession = await createKittyCadModelingSession();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: modelingSession,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "创建官方建模会话失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { findKittyCadProviderConfig } from "@/lib/kittycad/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const config = await findKittyCadProviderConfig();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
configured: Boolean(config),
|
||||
source: config?.source || "none",
|
||||
providerName: config?.providerName || null,
|
||||
baseUrl: config?.baseUrl || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -1,467 +0,0 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,331 +0,0 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,195 +0,0 @@
|
||||
import type { AiCadWorkspace } from "@/lib/db/schema";
|
||||
|
||||
type CadGenerationProvider = "kittycad-cad" | "tripo-3d";
|
||||
|
||||
const FINAL_TASK_STATUS = new Set([
|
||||
"success",
|
||||
"failed",
|
||||
"banned",
|
||||
"expired",
|
||||
"cancelled",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
const DEFAULT_VIEWER_SETTINGS = {
|
||||
cameraPreset: "iso",
|
||||
envPreset: "studio",
|
||||
lightIntensity: 1.1,
|
||||
autoRotate: true,
|
||||
showGrid: true,
|
||||
wireframe: false,
|
||||
} as const;
|
||||
|
||||
type WorkspaceVersionLike = {
|
||||
status?: unknown;
|
||||
prompt?: unknown;
|
||||
};
|
||||
|
||||
export type UpdateAiCadWorkspaceRequest = {
|
||||
name?: string;
|
||||
prompt?: string;
|
||||
generationProvider?: string;
|
||||
modelVersion?: string;
|
||||
viewerSettings?: unknown;
|
||||
messages?: unknown;
|
||||
versions?: unknown;
|
||||
activeVersionId?: string | null;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getWorkspaceVersions(value: unknown): WorkspaceVersionLike[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.filter((item): item is WorkspaceVersionLike => isRecord(item));
|
||||
}
|
||||
|
||||
export function normalizeViewerSettings(
|
||||
value: unknown
|
||||
): Record<string, unknown> {
|
||||
if (!isRecord(value)) {
|
||||
return { ...DEFAULT_VIEWER_SETTINGS };
|
||||
}
|
||||
|
||||
return {
|
||||
cameraPreset:
|
||||
value.cameraPreset === "top" ||
|
||||
value.cameraPreset === "front" ||
|
||||
value.cameraPreset === "right" ||
|
||||
value.cameraPreset === "iso"
|
||||
? value.cameraPreset
|
||||
: DEFAULT_VIEWER_SETTINGS.cameraPreset,
|
||||
envPreset:
|
||||
typeof value.envPreset === "string" && value.envPreset.trim()
|
||||
? value.envPreset
|
||||
: DEFAULT_VIEWER_SETTINGS.envPreset,
|
||||
lightIntensity:
|
||||
typeof value.lightIntensity === "number" &&
|
||||
Number.isFinite(value.lightIntensity)
|
||||
? value.lightIntensity
|
||||
: DEFAULT_VIEWER_SETTINGS.lightIntensity,
|
||||
autoRotate:
|
||||
typeof value.autoRotate === "boolean"
|
||||
? value.autoRotate
|
||||
: DEFAULT_VIEWER_SETTINGS.autoRotate,
|
||||
showGrid:
|
||||
typeof value.showGrid === "boolean"
|
||||
? value.showGrid
|
||||
: DEFAULT_VIEWER_SETTINGS.showGrid,
|
||||
wireframe:
|
||||
typeof value.wireframe === "boolean"
|
||||
? value.wireframe
|
||||
: DEFAULT_VIEWER_SETTINGS.wireframe,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeJsonRecordArray(
|
||||
value: unknown
|
||||
): Record<string, unknown>[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.filter((item): item is Record<string, unknown> =>
|
||||
isRecord(item)
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceName(
|
||||
value: unknown,
|
||||
fallbackName = "AI-CAD 工作台"
|
||||
) {
|
||||
return typeof value === "string" && value.trim()
|
||||
? value.trim()
|
||||
: fallbackName;
|
||||
}
|
||||
|
||||
export function normalizeWorkspacePayload(body: UpdateAiCadWorkspaceRequest): {
|
||||
name?: string;
|
||||
prompt: string;
|
||||
generationProvider: CadGenerationProvider;
|
||||
modelVersion: string;
|
||||
viewerSettings: Record<string, unknown>;
|
||||
messages: Record<string, unknown>[];
|
||||
versions: Record<string, unknown>[];
|
||||
activeVersionId: string | null;
|
||||
} {
|
||||
return {
|
||||
...(typeof body.name === "string" && body.name.trim()
|
||||
? { name: body.name.trim() }
|
||||
: {}),
|
||||
prompt: String(body.prompt || ""),
|
||||
generationProvider:
|
||||
body.generationProvider === "tripo-3d" ? "tripo-3d" : "kittycad-cad",
|
||||
modelVersion:
|
||||
typeof body.modelVersion === "string" && body.modelVersion.trim()
|
||||
? body.modelVersion
|
||||
: "Turbo-v1.0-20250506",
|
||||
viewerSettings: normalizeViewerSettings(body.viewerSettings),
|
||||
messages: normalizeJsonRecordArray(body.messages),
|
||||
versions: normalizeJsonRecordArray(body.versions),
|
||||
activeVersionId:
|
||||
typeof body.activeVersionId === "string" && body.activeVersionId.trim()
|
||||
? body.activeVersionId
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDefaultWorkspaceName(index: number) {
|
||||
return index > 1 ? `AI-CAD 工作台 ${index}` : "AI-CAD 工作台";
|
||||
}
|
||||
|
||||
export function createWorkspaceSummary(workspace: AiCadWorkspace) {
|
||||
const versions = getWorkspaceVersions(workspace.versions);
|
||||
const versionCount = versions.length;
|
||||
const completedCount = versions.filter(
|
||||
(version) => version.status === "success"
|
||||
).length;
|
||||
const hasPendingTasks = versions.some(
|
||||
(version) =>
|
||||
typeof version.status !== "string" ||
|
||||
!FINAL_TASK_STATUS.has(version.status)
|
||||
);
|
||||
const latestPrompt =
|
||||
typeof workspace.prompt === "string" && workspace.prompt.trim()
|
||||
? workspace.prompt.trim()
|
||||
: versions.find(
|
||||
(version) =>
|
||||
typeof version.prompt === "string" && version.prompt.trim()
|
||||
)?.prompt || null;
|
||||
|
||||
return {
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
generationProvider: workspace.generationProvider,
|
||||
versionCount,
|
||||
completedCount,
|
||||
hasPendingTasks,
|
||||
latestPrompt:
|
||||
typeof latestPrompt === "string" && latestPrompt.trim()
|
||||
? latestPrompt.trim()
|
||||
: null,
|
||||
createdAt: workspace.createdAt?.toISOString() || null,
|
||||
updatedAt: workspace.updatedAt?.toISOString() || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createWorkspaceDocument(workspace: AiCadWorkspace) {
|
||||
return {
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
prompt: workspace.prompt,
|
||||
generationProvider: workspace.generationProvider,
|
||||
modelVersion: workspace.modelVersion,
|
||||
viewerSettings: workspace.viewerSettings,
|
||||
messages: workspace.messages,
|
||||
versions: workspace.versions,
|
||||
activeVersionId: workspace.activeVersionId,
|
||||
createdAt: workspace.createdAt?.toISOString() || null,
|
||||
updatedAt: workspace.updatedAt?.toISOString() || null,
|
||||
};
|
||||
}
|
||||
@ -1,89 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
createWorkspaceDocument,
|
||||
normalizeWorkspacePayload,
|
||||
type UpdateAiCadWorkspaceRequest,
|
||||
} from "@/app/api/ai-cad/workspace-utils";
|
||||
import {
|
||||
createAiCadWorkspace,
|
||||
listAiCadWorkspacesByUserId,
|
||||
updateAiCadWorkspace,
|
||||
} from "@/lib/db/queries";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const workspaces = await listAiCadWorkspacesByUserId({
|
||||
userId: session.user.id,
|
||||
});
|
||||
const workspace = workspaces[0] ?? null;
|
||||
|
||||
if (!workspace) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: createWorkspaceDocument(workspace),
|
||||
});
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await request.json()) as UpdateAiCadWorkspaceRequest;
|
||||
const existingWorkspaces = await listAiCadWorkspacesByUserId({
|
||||
userId: session.user.id,
|
||||
});
|
||||
const targetWorkspace =
|
||||
existingWorkspaces[0] ??
|
||||
(await createAiCadWorkspace({
|
||||
userId: session.user.id,
|
||||
name: "AI-CAD 工作台",
|
||||
}));
|
||||
|
||||
if (!targetWorkspace) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "初始化 AI-CAD 工作台失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const savedWorkspace = await updateAiCadWorkspace({
|
||||
workspaceId: targetWorkspace.id,
|
||||
userId: session.user.id,
|
||||
...normalizeWorkspacePayload(body),
|
||||
});
|
||||
|
||||
if (!savedWorkspace) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "保存 AI-CAD 工作台失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
updatedAt: savedWorkspace.updatedAt?.toISOString() || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -1,114 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
createWorkspaceDocument,
|
||||
createWorkspaceSummary,
|
||||
normalizeWorkspacePayload,
|
||||
type UpdateAiCadWorkspaceRequest,
|
||||
} from "@/app/api/ai-cad/workspace-utils";
|
||||
import {
|
||||
deleteAiCadWorkspaceById,
|
||||
getAiCadWorkspaceById,
|
||||
updateAiCadWorkspace,
|
||||
} from "@/lib/db/queries";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
workspaceId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { workspaceId } = await context.params;
|
||||
const workspace = await getAiCadWorkspaceById({
|
||||
userId: session.user.id,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "AI-CAD 工作台不存在" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: createWorkspaceDocument(workspace),
|
||||
});
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, context: RouteContext) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { workspaceId } = await context.params;
|
||||
const body = (await request.json()) as UpdateAiCadWorkspaceRequest;
|
||||
const savedWorkspace = await updateAiCadWorkspace({
|
||||
workspaceId,
|
||||
userId: session.user.id,
|
||||
...normalizeWorkspacePayload(body),
|
||||
});
|
||||
|
||||
if (!savedWorkspace) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "AI-CAD 工作台不存在或保存失败" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: savedWorkspace.id,
|
||||
name: savedWorkspace.name,
|
||||
updatedAt: savedWorkspace.updatedAt?.toISOString() || null,
|
||||
summary: createWorkspaceSummary(savedWorkspace),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, context: RouteContext) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { workspaceId } = await context.params;
|
||||
const deletedWorkspace = await deleteAiCadWorkspaceById({
|
||||
workspaceId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
if (!deletedWorkspace) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "AI-CAD 工作台不存在" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: deletedWorkspace.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -1,69 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
buildDefaultWorkspaceName,
|
||||
createWorkspaceSummary,
|
||||
normalizeWorkspaceName,
|
||||
} from "@/app/api/ai-cad/workspace-utils";
|
||||
import {
|
||||
createAiCadWorkspace,
|
||||
listAiCadWorkspacesByUserId,
|
||||
} from "@/lib/db/queries";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const workspaces = await listAiCadWorkspacesByUserId({
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: workspaces.map(createWorkspaceSummary),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { name?: unknown };
|
||||
const existingWorkspaces = await listAiCadWorkspacesByUserId({
|
||||
userId: session.user.id,
|
||||
});
|
||||
const workspace = await createAiCadWorkspace({
|
||||
userId: session.user.id,
|
||||
name: normalizeWorkspaceName(
|
||||
body.name,
|
||||
buildDefaultWorkspaceName(existingWorkspaces.length + 1)
|
||||
),
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "创建 AI-CAD 工作台失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: createWorkspaceSummary(workspace),
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,47 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
type ZooDesignStudioFrameProps = {
|
||||
isReady: boolean;
|
||||
src: string;
|
||||
};
|
||||
|
||||
export function ZooDesignStudioFrame({ isReady, src }: ZooDesignStudioFrameProps) {
|
||||
if (!isReady) {
|
||||
return (
|
||||
<div className="flex h-svh w-full items-center justify-center overflow-hidden bg-[#10100e] p-6 text-[#f8f6ef]">
|
||||
<div className="w-full max-w-xl rounded-lg border border-[#3a352c] bg-[#171510] p-6 shadow-2xl">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-300" />
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-semibold text-lg">
|
||||
HiCAD 官方前端尚未构建
|
||||
</h1>
|
||||
<p className="mt-2 text-[#d3cabb] text-sm leading-6">
|
||||
请运行
|
||||
<code className="mx-1 rounded bg-white/10 px-1.5 py-0.5">
|
||||
pnpm zoo-modeling-app:vendor
|
||||
</code>
|
||||
生成官方 Zoo Design Studio 前端。生成后刷新本页即可使用。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-svh w-full overflow-hidden bg-[#10100e]">
|
||||
<iframe
|
||||
allow="clipboard-read; clipboard-write; fullscreen; gamepad; pointer-lock; web-share; xr-spatial-tracking"
|
||||
className="h-full w-full border-0"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
sandbox="allow-downloads allow-forms allow-modals allow-pointer-lock allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
src={src}
|
||||
title="HiCAD"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -192,7 +192,7 @@ export function TopNavbar({
|
||||
if (trimmed === "商品策划") {
|
||||
return "策划";
|
||||
}
|
||||
if (trimmed === "AI-CAD") {
|
||||
if (trimmed === "HiCAD") {
|
||||
return "CAD";
|
||||
}
|
||||
if (trimmed === "建筑编辑") {
|
||||
@ -432,10 +432,10 @@ export function TopNavbar({
|
||||
e.preventDefault();
|
||||
window.location.href = "/ai-cad";
|
||||
}}
|
||||
title="AI-CAD"
|
||||
title="HiCAD"
|
||||
>
|
||||
<Box className="h-5 w-5" />
|
||||
<span className={navLabelClass}>{getShortLabel("AI-CAD")}</span>
|
||||
<span className={navLabelClass}>{getShortLabel("HiCAD")}</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
|
||||
@ -47,8 +47,6 @@ import {
|
||||
type User,
|
||||
user,
|
||||
vote,
|
||||
aiCadWorkspace,
|
||||
type AiCadWorkspace,
|
||||
flowBoardDraft,
|
||||
type FlowBoardDraft,
|
||||
type FlowBoardSnapshotData,
|
||||
@ -796,156 +794,6 @@ export async function getConversationWorkingStateByChatId({
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAiCadWorkspacesByUserId({
|
||||
userId,
|
||||
}: {
|
||||
userId: string;
|
||||
}): Promise<AiCadWorkspace[]> {
|
||||
try {
|
||||
return await db
|
||||
.select()
|
||||
.from(aiCadWorkspace)
|
||||
.where(eq(aiCadWorkspace.userId, userId))
|
||||
.orderBy(desc(aiCadWorkspace.updatedAt));
|
||||
} catch (_error) {
|
||||
throw new ChatSDKError(
|
||||
"bad_request:database",
|
||||
"Failed to list AI-CAD workspaces"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiCadWorkspaceById(params: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<AiCadWorkspace | null> {
|
||||
try {
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(aiCadWorkspace)
|
||||
.where(
|
||||
and(
|
||||
eq(aiCadWorkspace.userId, params.userId),
|
||||
eq(aiCadWorkspace.id, params.workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return result ?? null;
|
||||
} catch (_error) {
|
||||
throw new ChatSDKError(
|
||||
"bad_request:database",
|
||||
"Failed to get AI-CAD workspace by id"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAiCadWorkspace(params: {
|
||||
userId: string;
|
||||
name: string;
|
||||
}): Promise<AiCadWorkspace | null> {
|
||||
try {
|
||||
const now = new Date();
|
||||
const [result] = await db
|
||||
.insert(aiCadWorkspace)
|
||||
.values({
|
||||
userId: params.userId,
|
||||
name: params.name,
|
||||
prompt: "",
|
||||
generationProvider: "kittycad-cad",
|
||||
modelVersion: "Turbo-v1.0-20250506",
|
||||
viewerSettings: {
|
||||
envPreset: "studio",
|
||||
lightIntensity: 1.1,
|
||||
autoRotate: true,
|
||||
showGrid: true,
|
||||
wireframe: false,
|
||||
},
|
||||
messages: [],
|
||||
versions: [],
|
||||
activeVersionId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return result ?? null;
|
||||
} catch (_error) {
|
||||
throw new ChatSDKError(
|
||||
"bad_request:database",
|
||||
"Failed to create AI-CAD workspace"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAiCadWorkspace(params: {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
name?: string;
|
||||
prompt: string;
|
||||
generationProvider: "kittycad-cad" | "tripo-3d";
|
||||
modelVersion: string;
|
||||
viewerSettings: Record<string, unknown>;
|
||||
messages: Record<string, unknown>[];
|
||||
versions: Record<string, unknown>[];
|
||||
activeVersionId?: string | null;
|
||||
}): Promise<AiCadWorkspace | null> {
|
||||
try {
|
||||
const now = new Date();
|
||||
const [result] = await db
|
||||
.update(aiCadWorkspace)
|
||||
.set({
|
||||
...(params.name ? { name: params.name } : {}),
|
||||
prompt: params.prompt,
|
||||
generationProvider: params.generationProvider,
|
||||
modelVersion: params.modelVersion,
|
||||
viewerSettings: params.viewerSettings,
|
||||
messages: params.messages,
|
||||
versions: params.versions,
|
||||
activeVersionId: params.activeVersionId ?? null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(aiCadWorkspace.id, params.workspaceId),
|
||||
eq(aiCadWorkspace.userId, params.userId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
return result ?? null;
|
||||
} catch (_error) {
|
||||
throw new ChatSDKError(
|
||||
"bad_request:database",
|
||||
"Failed to update AI-CAD workspace"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAiCadWorkspaceById(params: {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
}): Promise<AiCadWorkspace | null> {
|
||||
try {
|
||||
const [result] = await db
|
||||
.delete(aiCadWorkspace)
|
||||
.where(
|
||||
and(
|
||||
eq(aiCadWorkspace.id, params.workspaceId),
|
||||
eq(aiCadWorkspace.userId, params.userId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
return result ?? null;
|
||||
} catch (_error) {
|
||||
throw new ChatSDKError(
|
||||
"bad_request:database",
|
||||
"Failed to delete AI-CAD workspace"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFlowBoardDraftByUserId({
|
||||
userId,
|
||||
}: {
|
||||
|
||||
@ -1163,6 +1163,7 @@ export const conversationWorkingState = pgTable("ConversationWorkingState", {
|
||||
|
||||
export type ConversationWorkingState = InferSelectModel<typeof conversationWorkingState>;
|
||||
|
||||
// 仅为数据库迁移兼容保留;旧版 AI-CAD 页面和接口已移除。
|
||||
export const aiCadWorkspace = pgTable("AiCadWorkspace", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
userId: uuid("userId")
|
||||
|
||||
@ -38,8 +38,8 @@ export const DEFAULT_LOVART_HOME_SIDEBAR_ITEMS = [
|
||||
},
|
||||
{
|
||||
key: "aiCad",
|
||||
label: "AI-CAD",
|
||||
description: "跳转 AI-CAD 页面",
|
||||
label: "HiCAD",
|
||||
description: "进入官方 HiCAD 工作区",
|
||||
},
|
||||
{
|
||||
key: "model3d",
|
||||
@ -155,9 +155,13 @@ export function normalizeLovartHomeSidebarConfig(
|
||||
.slice(0, 2000);
|
||||
const defaultEnabled =
|
||||
"defaultEnabled" in item ? item.defaultEnabled : true;
|
||||
const normalizedLabel =
|
||||
item.key === "aiCad" && label === "AI-CAD"
|
||||
? item.label
|
||||
: label || item.label;
|
||||
|
||||
accumulator[item.key] = {
|
||||
label: label || item.label,
|
||||
label: normalizedLabel,
|
||||
iconUrl,
|
||||
enabled:
|
||||
typeof itemRaw.enabled === "boolean"
|
||||
|
||||
@ -1,391 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { deserialize } from "bson";
|
||||
import WebSocket, { type RawData } from "ws";
|
||||
import { getKittyCadProviderConfig } from "./server";
|
||||
|
||||
type KittyCadModelingCommand = Record<string, unknown> & {
|
||||
type: string;
|
||||
};
|
||||
|
||||
type KittyCadSuccessResponse = {
|
||||
request_id?: string | null;
|
||||
resp?: {
|
||||
data?: Record<string, unknown> | null;
|
||||
type?: string | null;
|
||||
} | null;
|
||||
success: true;
|
||||
};
|
||||
|
||||
type KittyCadFailureResponse = {
|
||||
errors?: Record<string, unknown>[];
|
||||
request_id?: string | null;
|
||||
success: false;
|
||||
};
|
||||
|
||||
export type KittyCadModelingSocketResponse =
|
||||
| KittyCadSuccessResponse
|
||||
| KittyCadFailureResponse;
|
||||
|
||||
type PendingRequest = {
|
||||
reject: (error: Error) => void;
|
||||
resolve: (value: KittyCadModelingSocketResponse) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
type ModelingSessionState = {
|
||||
createdAt: string;
|
||||
id: string;
|
||||
lastUsedAt: number;
|
||||
pending: Map<string, PendingRequest>;
|
||||
pingTimer: NodeJS.Timeout;
|
||||
socket: WebSocket;
|
||||
};
|
||||
|
||||
const MODELING_COMMAND_TIMEOUT_MS = 20_000;
|
||||
const MODELING_PING_INTERVAL_MS = 20_000;
|
||||
const DEFAULT_WS_PATH = "/ws/modeling/commands";
|
||||
|
||||
function getModelingSessions() {
|
||||
const globalState = globalThis as Record<string, unknown>;
|
||||
if (!globalState.__kittyCadModelingSessions) {
|
||||
globalState.__kittyCadModelingSessions = new Map<
|
||||
string,
|
||||
ModelingSessionState
|
||||
>();
|
||||
}
|
||||
return globalState.__kittyCadModelingSessions as Map<
|
||||
string,
|
||||
ModelingSessionState
|
||||
>;
|
||||
}
|
||||
|
||||
function toWebSocketUrl(baseUrl: string) {
|
||||
const parsed = new URL(baseUrl);
|
||||
parsed.protocol = parsed.protocol === "http:" ? "ws:" : "wss:";
|
||||
parsed.pathname = DEFAULT_WS_PATH;
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function toBuffer(raw: RawData): Buffer {
|
||||
if (typeof raw === "string") {
|
||||
return Buffer.from(raw, "utf8");
|
||||
}
|
||||
|
||||
if (Buffer.isBuffer(raw)) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
if (raw instanceof ArrayBuffer) {
|
||||
return Buffer.from(raw);
|
||||
}
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
return Buffer.concat(raw.map((chunk) => toBuffer(chunk)));
|
||||
}
|
||||
|
||||
return Buffer.from(raw);
|
||||
}
|
||||
|
||||
function parseSocketMessage(raw: RawData): KittyCadModelingSocketResponse {
|
||||
const buffer = toBuffer(raw);
|
||||
const text = buffer.toString("utf8").trim();
|
||||
|
||||
if (text) {
|
||||
try {
|
||||
return JSON.parse(text) as KittyCadModelingSocketResponse;
|
||||
} catch (_error) {
|
||||
// Fall through to BSON parsing.
|
||||
}
|
||||
}
|
||||
|
||||
return deserialize(buffer) as KittyCadModelingSocketResponse;
|
||||
}
|
||||
|
||||
function getSocketErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : "官方建模连接异常断开";
|
||||
}
|
||||
|
||||
function formatModelingErrors(errors: unknown) {
|
||||
if (!Array.isArray(errors) || errors.length === 0) {
|
||||
return "官方建模命令执行失败";
|
||||
}
|
||||
|
||||
return (
|
||||
errors
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = item as Record<string, unknown>;
|
||||
if (typeof record.message === "string" && record.message.trim()) {
|
||||
return record.message.trim();
|
||||
}
|
||||
if (typeof record.error === "string" && record.error.trim()) {
|
||||
return record.error.trim();
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is string => Boolean(item))
|
||||
.join(";") || "官方建模命令执行失败"
|
||||
);
|
||||
}
|
||||
|
||||
function rejectAllPendingRequests(
|
||||
state: ModelingSessionState,
|
||||
message: string
|
||||
) {
|
||||
for (const [requestId, pending] of state.pending.entries()) {
|
||||
clearTimeout(pending.timeout);
|
||||
pending.reject(new Error(message));
|
||||
state.pending.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
function destroySession(sessionId: string, reason: string) {
|
||||
const sessions = getModelingSessions();
|
||||
const state = sessions.get(sessionId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(state.pingTimer);
|
||||
rejectAllPendingRequests(state, reason);
|
||||
sessions.delete(sessionId);
|
||||
|
||||
if (
|
||||
state.socket.readyState === WebSocket.OPEN ||
|
||||
state.socket.readyState === WebSocket.CONNECTING
|
||||
) {
|
||||
state.socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
function getRequiredSession(sessionId: string) {
|
||||
const sessions = getModelingSessions();
|
||||
const state = sessions.get(sessionId);
|
||||
if (!state) {
|
||||
throw new Error("官方建模会话不存在或已过期,请重新连接");
|
||||
}
|
||||
if (state.socket.readyState !== WebSocket.OPEN) {
|
||||
destroySession(sessionId, "官方建模会话已断开,请重新连接");
|
||||
throw new Error("官方建模会话已断开,请重新连接");
|
||||
}
|
||||
state.lastUsedAt = Date.now();
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function createKittyCadModelingSession() {
|
||||
const config = await getKittyCadProviderConfig();
|
||||
const sessionId = randomUUID();
|
||||
const createdAt = new Date().toISOString();
|
||||
const socketUrl = toWebSocketUrl(config.baseUrl);
|
||||
|
||||
const sessionState = await new Promise<ModelingSessionState>(
|
||||
(resolve, reject) => {
|
||||
const socket = new WebSocket(socketUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${String(config.apiKey || "").replace(/\s+/g, "")}`,
|
||||
},
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
socket.removeListener("error", handleError);
|
||||
socket.removeListener("unexpected-response", handleUnexpectedResponse);
|
||||
socket.removeListener("open", handleOpen);
|
||||
};
|
||||
|
||||
const handleError = (error: Error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
const handleUnexpectedResponse = (
|
||||
_request: unknown,
|
||||
response: {
|
||||
on: (event: string, listener: (chunk?: Buffer) => void) => void;
|
||||
statusCode?: number;
|
||||
statusMessage?: string;
|
||||
}
|
||||
) => {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on("data", (chunk) => {
|
||||
if (chunk) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
});
|
||||
response.on("end", () => {
|
||||
cleanup();
|
||||
const responseText = Buffer.concat(chunks).toString("utf8").trim();
|
||||
reject(
|
||||
new Error(
|
||||
responseText ||
|
||||
`官方建模连接失败(HTTP ${response.statusCode || 502}${response.statusMessage ? ` ${response.statusMessage}` : ""})`
|
||||
)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpen = () => {
|
||||
cleanup();
|
||||
|
||||
const pingTimer = setInterval(() => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
}, MODELING_PING_INTERVAL_MS);
|
||||
|
||||
resolve({
|
||||
createdAt,
|
||||
id: sessionId,
|
||||
lastUsedAt: Date.now(),
|
||||
pending: new Map(),
|
||||
pingTimer,
|
||||
socket,
|
||||
});
|
||||
};
|
||||
|
||||
socket.once("error", handleError);
|
||||
socket.once("unexpected-response", handleUnexpectedResponse);
|
||||
socket.once("open", handleOpen);
|
||||
}
|
||||
);
|
||||
|
||||
sessionState.socket.on("message", (raw: RawData) => {
|
||||
try {
|
||||
const response = parseSocketMessage(raw);
|
||||
const requestId = String(response.request_id || "").trim();
|
||||
if (!requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = sessionState.pending.get(requestId);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(pending.timeout);
|
||||
sessionState.pending.delete(requestId);
|
||||
pending.resolve(response);
|
||||
} catch (error) {
|
||||
console.error("[KittyCADModeling] failed to parse socket message", error);
|
||||
}
|
||||
});
|
||||
|
||||
sessionState.socket.on("close", () => {
|
||||
destroySession(sessionId, "官方建模会话已关闭,请重新连接");
|
||||
});
|
||||
|
||||
sessionState.socket.on("error", (error: Error) => {
|
||||
destroySession(sessionId, getSocketErrorMessage(error));
|
||||
});
|
||||
|
||||
getModelingSessions().set(sessionId, sessionState);
|
||||
|
||||
return {
|
||||
createdAt,
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendKittyCadModelingCommand(params: {
|
||||
cmd: KittyCadModelingCommand;
|
||||
cmdId?: string;
|
||||
sessionId: string;
|
||||
}) {
|
||||
const state = getRequiredSession(params.sessionId);
|
||||
const requestId = String(params.cmdId || "").trim() || randomUUID();
|
||||
|
||||
if (state.pending.has(requestId)) {
|
||||
throw new Error("官方建模命令 ID 冲突,请重试");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
cmd: params.cmd,
|
||||
cmd_id: requestId,
|
||||
type: "modeling_cmd_req",
|
||||
};
|
||||
|
||||
return await new Promise<KittyCadModelingSocketResponse>(
|
||||
(resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
state.pending.delete(requestId);
|
||||
reject(new Error("官方建模命令超时,请稍后重试"));
|
||||
}, MODELING_COMMAND_TIMEOUT_MS);
|
||||
|
||||
state.pending.set(requestId, {
|
||||
reject,
|
||||
resolve,
|
||||
timeout,
|
||||
});
|
||||
|
||||
state.socket.send(JSON.stringify(payload), (error?: Error) => {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
state.pending.delete(requestId);
|
||||
reject(new Error(getSocketErrorMessage(error)));
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function takeKittyCadModelingSnapshot(params: {
|
||||
format?: "jpeg" | "png";
|
||||
sessionId: string;
|
||||
}) {
|
||||
const format = params.format || "png";
|
||||
const response = await sendKittyCadModelingCommand({
|
||||
cmd: {
|
||||
format,
|
||||
type: "take_snapshot",
|
||||
},
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(formatModelingErrors(response.errors));
|
||||
}
|
||||
|
||||
const contents = String(response.resp?.data?.contents || "").trim();
|
||||
if (!contents) {
|
||||
return {
|
||||
requestId: response.request_id || null,
|
||||
snapshotUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
const mimeType = format === "jpeg" ? "image/jpeg" : "image/png";
|
||||
return {
|
||||
requestId: response.request_id || null,
|
||||
snapshotUrl: `data:${mimeType};base64,${contents}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function closeKittyCadModelingSession(sessionId: string) {
|
||||
destroySession(sessionId, "官方建模会话已手动关闭");
|
||||
return {
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
export function isKittyCadModelingCommandSuccessful(
|
||||
response: KittyCadModelingSocketResponse
|
||||
): response is KittyCadSuccessResponse {
|
||||
return response.success;
|
||||
}
|
||||
|
||||
export function getKittyCadModelingCommandError(response: unknown) {
|
||||
if (!response || typeof response !== "object") {
|
||||
return "官方建模命令执行失败";
|
||||
}
|
||||
|
||||
const record = response as KittyCadFailureResponse;
|
||||
return formatModelingErrors(record.errors);
|
||||
}
|
||||
@ -1,276 +0,0 @@
|
||||
import type { Model3DTaskStatus } from "@/services/canvas";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("AiCad");
|
||||
|
||||
type ApiResponse<T = any> = {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type AiCadProviderStatus = {
|
||||
configured: boolean;
|
||||
source: "provider" | "env" | "none";
|
||||
providerName?: string | null;
|
||||
baseUrl?: string | null;
|
||||
};
|
||||
|
||||
export type AiCadWorkspaceSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
generationProvider: "kittycad-cad" | "tripo-3d";
|
||||
versionCount: number;
|
||||
completedCount: number;
|
||||
hasPendingTasks: boolean;
|
||||
latestPrompt?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
export type AiCadWorkspaceDocument = {
|
||||
id: string;
|
||||
name: string;
|
||||
prompt: string;
|
||||
generationProvider: "kittycad-cad" | "tripo-3d";
|
||||
modelVersion: string;
|
||||
viewerSettings: Record<string, unknown>;
|
||||
messages: Record<string, unknown>[];
|
||||
versions: Record<string, unknown>[];
|
||||
activeVersionId?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
export type AiCadModelingSession = {
|
||||
sessionId: string;
|
||||
createdAt?: string | null;
|
||||
};
|
||||
|
||||
export type AiCadModelingCommandPayload = {
|
||||
requestId?: string | null;
|
||||
responseType?: string | null;
|
||||
responseData?: Record<string, any> | null;
|
||||
snapshotUrl?: string | null;
|
||||
snapshotError?: string | null;
|
||||
raw?: any;
|
||||
};
|
||||
|
||||
function handleError(
|
||||
err: unknown,
|
||||
context: string,
|
||||
fallbackMessage: string,
|
||||
extra?: Record<string, unknown>
|
||||
): ApiResponse<never> {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
log.error(`${context} failed`, error, extra);
|
||||
return { success: false, error: fallbackMessage };
|
||||
}
|
||||
|
||||
export const aiCadGenerationService = {
|
||||
async listWorkspaces(): Promise<ApiResponse<AiCadWorkspaceSummary[]>> {
|
||||
try {
|
||||
const response = await fetch("/api/ai-cad/workspaces");
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (err) {
|
||||
return handleError(err, "listWorkspaces", "读取 AI-CAD 工作台列表失败");
|
||||
}
|
||||
},
|
||||
|
||||
async createWorkspace(params?: {
|
||||
name?: string;
|
||||
}): Promise<ApiResponse<AiCadWorkspaceSummary>> {
|
||||
try {
|
||||
const response = await fetch("/api/ai-cad/workspaces", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params || {}),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (err) {
|
||||
return handleError(err, "createWorkspace", "创建 AI-CAD 工作台失败", { name: params?.name });
|
||||
}
|
||||
},
|
||||
|
||||
async getWorkspace(
|
||||
workspaceId: string
|
||||
): Promise<ApiResponse<AiCadWorkspaceDocument | null>> {
|
||||
try {
|
||||
const response = await fetch(`/api/ai-cad/workspaces/${workspaceId}`);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (err) {
|
||||
return handleError(err, "getWorkspace", "读取 AI-CAD 工作台失败", { workspaceId });
|
||||
}
|
||||
},
|
||||
|
||||
async saveWorkspace(
|
||||
workspaceId: string,
|
||||
params: {
|
||||
name?: string;
|
||||
prompt: string;
|
||||
generationProvider: "kittycad-cad" | "tripo-3d";
|
||||
modelVersion: string;
|
||||
viewerSettings: Record<string, unknown>;
|
||||
messages: Record<string, unknown>[];
|
||||
versions: Record<string, unknown>[];
|
||||
activeVersionId?: string | null;
|
||||
}
|
||||
): Promise<
|
||||
ApiResponse<{
|
||||
id: string;
|
||||
name: string;
|
||||
updatedAt?: string | null;
|
||||
summary?: AiCadWorkspaceSummary;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const response = await fetch(`/api/ai-cad/workspaces/${workspaceId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (err) {
|
||||
return handleError(err, "saveWorkspace", "保存 AI-CAD 工作台失败", { workspaceId });
|
||||
}
|
||||
},
|
||||
|
||||
async deleteWorkspace(
|
||||
workspaceId: string
|
||||
): Promise<ApiResponse<{ id: string }>> {
|
||||
try {
|
||||
const response = await fetch(`/api/ai-cad/workspaces/${workspaceId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (err) {
|
||||
return handleError(err, "deleteWorkspace", "删除 AI-CAD 工作台失败", { workspaceId });
|
||||
}
|
||||
},
|
||||
|
||||
async getProviderStatus(): Promise<ApiResponse<AiCadProviderStatus>> {
|
||||
try {
|
||||
const response = await fetch("/api/ai-cad/provider");
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (err) {
|
||||
return handleError(err, "getProviderStatus", "读取 AI-CAD 提供商状态失败");
|
||||
}
|
||||
},
|
||||
|
||||
async createTask(params: {
|
||||
prompt?: string;
|
||||
outputFormat?: string;
|
||||
projectName?: string;
|
||||
iterationSourceTaskId?: string;
|
||||
sourceCode?: string;
|
||||
sourceRanges?: Record<string, unknown>[];
|
||||
}): Promise<
|
||||
ApiResponse<{
|
||||
taskId: string;
|
||||
status: Model3DTaskStatus;
|
||||
conversationId?: string | null;
|
||||
modelVersion?: string | null;
|
||||
outputFormat?: string | null;
|
||||
generationMode?: "create" | "iterate";
|
||||
sourceTaskId?: string | null;
|
||||
raw?: any;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const response = await fetch("/api/ai-cad/tasks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (err) {
|
||||
return handleError(err, "createTask", "创建 AI-CAD 任务失败", {
|
||||
outputFormat: params.outputFormat,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async createModelingSession(): Promise<ApiResponse<AiCadModelingSession>> {
|
||||
try {
|
||||
const response = await fetch("/api/ai-cad/modeling/session", {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (err) {
|
||||
return handleError(err, "createModelingSession", "创建官方建模会话失败");
|
||||
}
|
||||
},
|
||||
|
||||
async closeModelingSession(
|
||||
sessionId: string
|
||||
): Promise<ApiResponse<{ sessionId: string }>> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/ai-cad/modeling/session/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (err) {
|
||||
return handleError(err, "closeModelingSession", "关闭官方建模会话失败", { sessionId });
|
||||
}
|
||||
},
|
||||
|
||||
async sendModelingCommand(params: {
|
||||
sessionId: string;
|
||||
cmd: Record<string, unknown>;
|
||||
cmdId?: string;
|
||||
captureSnapshot?: boolean;
|
||||
snapshotFormat?: "jpeg" | "png";
|
||||
}): Promise<ApiResponse<AiCadModelingCommandPayload>> {
|
||||
try {
|
||||
const response = await fetch("/api/ai-cad/modeling/command", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (err) {
|
||||
return handleError(err, "sendModelingCommand", "发送官方建模命令失败", {
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async getTask(taskId: string): Promise<
|
||||
ApiResponse<{
|
||||
taskId: string;
|
||||
type: string;
|
||||
status: Model3DTaskStatus;
|
||||
progress?: number | null;
|
||||
output?: Record<string, any>;
|
||||
modelUrl?: string | null;
|
||||
previewImageUrl?: string | null;
|
||||
errorMessage?: string | null;
|
||||
conversationId?: string | null;
|
||||
modelVersion?: string | null;
|
||||
outputFormat?: string | null;
|
||||
codeUrl?: string | null;
|
||||
downloadUrls?: Record<string, string>;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const response = await fetch(`/api/ai-cad/tasks/${taskId}`);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (err) {
|
||||
return handleError(err, "getTask", "查询 AI-CAD 任务失败", { taskId });
|
||||
}
|
||||
},
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user