feat: add official AI-CAD iteration and modeling tools
This commit is contained in:
parent
b542a80f89
commit
5763ed1c94
@ -1,5 +1,7 @@
|
||||
import { AiCadWorkbench } from "@/components/ai-cad/ai-cad-workbench";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function AiCadPage() {
|
||||
return <AiCadWorkbench />;
|
||||
}
|
||||
|
||||
122
app/api/ai-cad/modeling/command/route.ts
Normal file
122
app/api/ai-cad/modeling/command/route.ts
Normal file
@ -0,0 +1,122 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
48
app/api/ai-cad/modeling/session/[sessionId]/route.ts
Normal file
48
app/api/ai-cad/modeling/session/[sessionId]/route.ts
Normal file
@ -0,0 +1,48 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
33
app/api/ai-cad/modeling/session/route.ts
Normal file
33
app/api/ai-cad/modeling/session/route.ts
Normal file
@ -0,0 +1,33 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,8 @@ type CreateAiCadTaskRequest = {
|
||||
outputFormat?: string;
|
||||
projectName?: string;
|
||||
iterationSourceTaskId?: string;
|
||||
sourceCode?: string;
|
||||
sourceRanges?: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
type KittyCadCreateResponse = {
|
||||
@ -148,12 +150,6 @@ export async function POST(request: Request) {
|
||||
|
||||
const body = (await request.json()) as CreateAiCadTaskRequest;
|
||||
const prompt = String(body.prompt || "").trim();
|
||||
if (!prompt) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请输入 CAD 描述" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3DModel))
|
||||
@ -196,50 +192,76 @@ export async function POST(request: Request) {
|
||||
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 ? "iterate" : "create";
|
||||
const result = iterationSourceTaskId
|
||||
? await (async () => {
|
||||
const sourceTask = await kittyCadRequest<KittyCadSourceTaskResponse>(
|
||||
config,
|
||||
{
|
||||
method: "GET",
|
||||
path: `/user/text-to-cad/${encodeURIComponent(iterationSourceTaskId)}`,
|
||||
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,
|
||||
});
|
||||
}
|
||||
);
|
||||
const originalSourceCode = 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 }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return kittyCadRequest<KittyCadIterationResponse>(config, {
|
||||
})()
|
||||
: await kittyCadRequest<KittyCadCreateResponse>(config, {
|
||||
method: "POST",
|
||||
path: "/ml/text-to-cad/iteration",
|
||||
path: `/ai/text-to-cad/${encodeURIComponent(outputFormat)}?kcl=true`,
|
||||
json: {
|
||||
original_source_code: originalSourceCode,
|
||||
prompt,
|
||||
source_ranges: [],
|
||||
...(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) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
391
lib/kittycad/modeling-session.ts
Normal file
391
lib/kittycad/modeling-session.ts
Normal file
@ -0,0 +1,391 @@
|
||||
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);
|
||||
}
|
||||
@ -59,6 +59,7 @@
|
||||
"@xenova/transformers": "^2.17.0",
|
||||
"ai": "^5.0.52",
|
||||
"bcrypt-ts": "^5.0.2",
|
||||
"bson": "^6.10.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"classnames": "^2.5.1",
|
||||
"clsx": "^2.1.1",
|
||||
@ -116,6 +117,7 @@
|
||||
"tokenlens": "1.3.0",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"ws": "^8.18.3",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.25.76",
|
||||
"zustand": "5.0.4"
|
||||
@ -135,6 +137,7 @@
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/sharp": "^0.32.0",
|
||||
"@types/three": "^0.182.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"drizzle-kit": "^0.25.0",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^4.1.13",
|
||||
|
||||
31
pnpm-lock.yaml
generated
31
pnpm-lock.yaml
generated
@ -125,6 +125,9 @@ importers:
|
||||
bcrypt-ts:
|
||||
specifier: ^5.0.2
|
||||
version: 5.0.3
|
||||
bson:
|
||||
specifier: ^6.10.4
|
||||
version: 6.10.4
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@ -296,6 +299,9 @@ importers:
|
||||
usehooks-ts:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.1(react@19.0.0-rc-45804af1-20241021)
|
||||
ws:
|
||||
specifier: ^8.18.3
|
||||
version: 8.20.0(bufferutil@4.0.9)
|
||||
xlsx:
|
||||
specifier: ^0.18.5
|
||||
version: 0.18.5
|
||||
@ -348,6 +354,9 @@ importers:
|
||||
'@types/three':
|
||||
specifier: ^0.182.0
|
||||
version: 0.182.0
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
drizzle-kit:
|
||||
specifier: ^0.25.0
|
||||
version: 0.25.0
|
||||
@ -3216,6 +3225,9 @@ packages:
|
||||
'@types/webxr@0.5.24':
|
||||
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@typespec/ts-http-runtime@0.3.4':
|
||||
resolution: {integrity: sha512-CI0NhTrz4EBaa0U+HaaUZrJhPoso8sG7ZFya8uQoBA57fjzrjRSv87ekCjLZOFExN+gXE/z0xuN2QfH4H2HrLQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@ -3455,6 +3467,10 @@ packages:
|
||||
brace-expansion@2.0.2:
|
||||
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
|
||||
|
||||
bson@6.10.4:
|
||||
resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==}
|
||||
engines: {node: '>=16.20.1'}
|
||||
|
||||
buffer-crc32@0.2.13:
|
||||
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
||||
|
||||
@ -6067,8 +6083,8 @@ packages:
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
ws@8.18.1:
|
||||
resolution: {integrity: sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==}
|
||||
ws@8.20.0:
|
||||
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
@ -9298,6 +9314,10 @@ snapshots:
|
||||
|
||||
'@types/webxr@0.5.24': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 22.13.10
|
||||
|
||||
'@typespec/ts-http-runtime@0.3.4':
|
||||
dependencies:
|
||||
http-proxy-agent: 7.0.2
|
||||
@ -9356,7 +9376,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@neondatabase/serverless': 0.9.5
|
||||
bufferutil: 4.0.9
|
||||
ws: 8.18.1(bufferutil@4.0.9)
|
||||
ws: 8.20.0(bufferutil@4.0.9)
|
||||
transitivePeerDependencies:
|
||||
- utf-8-validate
|
||||
optional: true
|
||||
@ -9550,6 +9570,8 @@ snapshots:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
bson@6.10.4: {}
|
||||
|
||||
buffer-crc32@0.2.13: {}
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
@ -12619,10 +12641,9 @@ snapshots:
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@8.18.1(bufferutil@4.0.9):
|
||||
ws@8.20.0(bufferutil@4.0.9):
|
||||
optionalDependencies:
|
||||
bufferutil: 4.0.9
|
||||
optional: true
|
||||
|
||||
wsl-utils@0.1.0:
|
||||
dependencies:
|
||||
|
||||
@ -39,6 +39,20 @@ export type AiCadWorkspaceDocument = {
|
||||
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;
|
||||
};
|
||||
|
||||
export const aiCadGenerationService = {
|
||||
async listWorkspaces(): Promise<ApiResponse<AiCadWorkspaceSummary[]>> {
|
||||
try {
|
||||
@ -136,10 +150,12 @@ export const aiCadGenerationService = {
|
||||
},
|
||||
|
||||
async createTask(params: {
|
||||
prompt: string;
|
||||
prompt?: string;
|
||||
outputFormat?: string;
|
||||
projectName?: string;
|
||||
iterationSourceTaskId?: string;
|
||||
sourceCode?: string;
|
||||
sourceRanges?: Record<string, unknown>[];
|
||||
}): Promise<
|
||||
ApiResponse<{
|
||||
taskId: string;
|
||||
@ -165,6 +181,55 @@ export const aiCadGenerationService = {
|
||||
}
|
||||
},
|
||||
|
||||
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 (_error) {
|
||||
return { success: false, error: "创建官方建模会话失败" };
|
||||
}
|
||||
},
|
||||
|
||||
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 (_error) {
|
||||
return { success: false, error: "关闭官方建模会话失败" };
|
||||
}
|
||||
},
|
||||
|
||||
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 (_error) {
|
||||
return { success: false, error: "发送官方建模命令失败" };
|
||||
}
|
||||
},
|
||||
|
||||
async getTask(taskId: string): Promise<
|
||||
ApiResponse<{
|
||||
taskId: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user