refactor: remove legacy AI-CAD workbench

This commit is contained in:
134 2026-07-13 19:00:52 +08:00
parent 08d8a1d4af
commit 752d1f99d2
21 changed files with 26 additions and 9400 deletions

View File

@ -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)
# 检查日志是否包含完整错误堆栈
```
---

View File

@ -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">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
};
}

View File

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

View File

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

View File

@ -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

View File

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

View File

@ -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

View File

@ -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,
}: {

View File

@ -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")

View File

@ -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"

View File

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

View File

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