refactor: add DesignIR distillation pipeline

This commit is contained in:
Jerry
2026-07-27 17:24:04 +08:00
parent 0adaac2b2a
commit 550b339bcc
1128 changed files with 10248 additions and 426666 deletions
+841
View File
@@ -0,0 +1,841 @@
import { createOpenAI } from "@ai-sdk/openai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import {
createUIMessageStream,
createUIMessageStreamResponse,
isStepCount,
streamText,
tool,
type ModelMessage,
type UIMessage,
type UserContent,
} from "ai";
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { z } from "zod/v4";
import { executeDesignIR, type CadGenerationResult } from "@/lib/cad-generator";
import { loadLlmConfig, resolveProviderApiKey, selectedModelId, type LlmConfig } from "@/lib/config";
import { readManifest, taskDir } from "@/lib/task-store";
const execFileAsync = promisify(execFile);
type ChatMessage = {
role: "user" | "assistant" | "system";
content: string;
};
type CadProgressStatus = "pending" | "running" | "success" | "error";
type CadProgressPayload = {
step: string;
label: string;
status: CadProgressStatus;
message?: string;
};
type AttachmentRecord = {
id?: string;
taskId?: string;
name?: string;
kind?: string;
path?: string;
size?: number;
sha256?: string;
mime?: string;
};
function writeTextPart(writer: { write: (part: any) => void }, text: string) {
const id = `text_${Math.random().toString(36).slice(2, 10)}`;
writer.write({ type: "text-start", id });
for (const token of text.match(/.{1,48}/g) || []) {
writer.write({ type: "text-delta", id, delta: token });
}
writer.write({ type: "text-end", id });
}
function uiTextResponse(text: string) {
const stream = createUIMessageStream<UIMessage>({
execute: ({ writer }) => {
writeTextPart(writer, text);
},
});
return createUIMessageStreamResponse({
stream,
headers: {
"Cache-Control": "no-store",
},
});
}
function compactJson(value: unknown, maxLength: number) {
const source = JSON.stringify(value, null, 2);
return source.length > maxLength ? `${source.slice(0, maxLength)}\n...<truncated>` : source;
}
function normalizeAttachment(value: unknown): AttachmentRecord | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const taskId = String(record.taskId || "").trim();
const filePath = String(record.path || "").trim();
if (!taskId || !filePath) return null;
return {
id: String(record.id || "").trim(),
taskId,
name: String(record.name || path.basename(filePath)).trim(),
kind: String(record.kind || "").trim(),
path: filePath,
size: Number(record.size || 0),
sha256: String(record.sha256 || "").trim(),
mime: String(record.mime || "").trim() || "application/octet-stream",
};
}
function normalizedAttachments(attachments: unknown[]) {
return attachments.map(normalizeAttachment).filter((attachment): attachment is AttachmentRecord => Boolean(attachment));
}
function messageContent(value: unknown) {
if (!value || typeof value !== "object") return "";
const record = value as Record<string, unknown>;
if (typeof record.content === "string") return record.content;
if (Array.isArray(record.parts)) {
return record.parts.map((part) => {
if (!part || typeof part !== "object") return "";
const partRecord = part as Record<string, unknown>;
if (partRecord.type === "text") return String(partRecord.text || "");
if (typeof partRecord.text === "string") return partRecord.text;
return "";
}).join("");
}
return "";
}
function normalizedMessages(messages: unknown[]): ChatMessage[] {
return messages.map((message) => {
const record = (message && typeof message === "object" ? message : {}) as Record<string, unknown>;
const role: ChatMessage["role"] = record.role === "assistant" || record.role === "system" ? record.role : "user";
return {
role,
content: messageContent(record),
};
}).filter((message) => message.content.trim() || message.role !== "user");
}
function attachmentKey(attachment: AttachmentRecord) {
return [attachment.id, attachment.taskId, attachment.path].filter(Boolean).join(":");
}
function findAttachment(attachments: AttachmentRecord[], query: { id?: string; taskId?: string; path?: string }) {
const id = String(query.id || "").trim();
const task = String(query.taskId || "").trim();
const filePath = String(query.path || "").trim();
return attachments.find((attachment) => (
(id && attachment.id === id) ||
(task && filePath && attachment.taskId === task && attachment.path === filePath) ||
(filePath && attachment.path === filePath)
)) || null;
}
function attachmentAbsolutePath(attachment: AttachmentRecord) {
if (!attachment.taskId || !attachment.path) {
throw new Error("Attachment is missing taskId or path.");
}
const root = path.resolve(taskDir(attachment.taskId));
const absolutePath = path.resolve(root, attachment.path);
if (absolutePath !== root && !absolutePath.startsWith(`${root}${path.sep}`)) {
throw new Error("Attachment path escapes task directory.");
}
return absolutePath;
}
function attachmentMediaType(attachment: AttachmentRecord) {
const mime = String(attachment.mime || "").trim().toLowerCase();
if (mime && mime !== "application/octet-stream") return mime;
const lowerName = String(attachment.name || attachment.path || "").toLowerCase();
if (lowerName.endsWith(".png")) return "image/png";
if (lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg")) return "image/jpeg";
if (lowerName.endsWith(".webp")) return "image/webp";
if (lowerName.endsWith(".pdf")) return "application/pdf";
if (lowerName.endsWith(".json")) return "application/json";
if (lowerName.endsWith(".csv")) return "text/csv";
if (lowerName.endsWith(".md")) return "text/markdown";
if (lowerName.endsWith(".txt") || lowerName.endsWith(".py") || lowerName.endsWith(".scad")) return "text/plain";
if (lowerName.endsWith(".step") || lowerName.endsWith(".stp")) return "model/step";
return "application/octet-stream";
}
function isImageAttachment(attachment: AttachmentRecord) {
return attachment.kind === "image" || attachmentMediaType(attachment).startsWith("image/");
}
function isStepAttachment(attachment: AttachmentRecord) {
const lowerName = String(attachment.name || attachment.path || "").toLowerCase();
return attachment.kind === "step" || lowerName.endsWith(".step") || lowerName.endsWith(".stp");
}
function isTextLikeAttachment(attachment: AttachmentRecord) {
const mediaType = attachmentMediaType(attachment);
const lowerName = String(attachment.name || attachment.path || "").toLowerCase();
return mediaType.startsWith("text/") ||
mediaType === "application/json" ||
mediaType === "application/xml" ||
mediaType === "application/x-yaml" ||
lowerName.endsWith(".json") ||
lowerName.endsWith(".yaml") ||
lowerName.endsWith(".yml") ||
lowerName.endsWith(".csv") ||
lowerName.endsWith(".txt") ||
lowerName.endsWith(".md") ||
lowerName.endsWith(".py") ||
lowerName.endsWith(".scad");
}
async function readTextExcerpt(attachment: AttachmentRecord, maxBytes = 80_000) {
const absolutePath = attachmentAbsolutePath(attachment);
const data = await fs.readFile(absolutePath);
const truncated = data.byteLength > maxBytes;
return {
text: data.subarray(0, maxBytes).toString("utf8"),
truncated,
bytesRead: Math.min(data.byteLength, maxBytes),
totalBytes: data.byteLength,
};
}
async function buildAttachmentUserContent(lastUserContent: string, attachments: AttachmentRecord[]): Promise<UserContent> {
const parts: Exclude<UserContent, string> = [{ type: "text", text: lastUserContent }];
const attachedFileLines = [];
const seen = new Set<string>();
for (const attachment of attachments) {
const key = attachmentKey(attachment);
if (seen.has(key)) continue;
seen.add(key);
const mediaType = attachmentMediaType(attachment);
attachedFileLines.push(`- ${attachment.name || attachment.path} (${mediaType}, ${attachment.size || "unknown"} bytes), id=${attachment.id || ""}, taskId=${attachment.taskId}, path=${attachment.path}`);
if (isImageAttachment(attachment)) {
const data = await fs.readFile(attachmentAbsolutePath(attachment));
parts.push({
type: "file",
data,
mediaType,
filename: attachment.name || path.basename(String(attachment.path || "image")),
});
} else if (isTextLikeAttachment(attachment) && !isStepAttachment(attachment)) {
const excerpt = await readTextExcerpt(attachment);
parts.push({
type: "text",
text: [
`\nAttached text file: ${attachment.name || attachment.path}`,
`id=${attachment.id || ""}, taskId=${attachment.taskId}, path=${attachment.path}`,
excerpt.truncated ? `Showing first ${excerpt.bytesRead} of ${excerpt.totalBytes} bytes.` : `Full file content (${excerpt.totalBytes} bytes):`,
"```",
excerpt.text,
"```",
].join("\n"),
});
}
}
if (attachedFileLines.length) {
parts.push({
type: "text",
text: [
"\nUploaded files available to this turn:",
...attachedFileLines,
"Images are attached as model-visible image parts when the selected provider/model supports vision.",
"For STEP/STP or binary files, call inspect_uploaded_file with id/taskId/path before using their contents.",
].join("\n"),
});
}
return parts.length === 1 ? lastUserContent : parts;
}
async function buildModelMessages(messages: ChatMessage[], attachments: AttachmentRecord[]): Promise<ModelMessage[]> {
const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
return Promise.all(messages.map(async (message, index) => {
if (message.role === "user" && index === lastUserIndex && attachments.length) {
return {
role: "user",
content: await buildAttachmentUserContent(message.content, attachments),
};
}
return {
role: message.role,
content: message.content,
} as ModelMessage;
}));
}
async function buildTaskContext(selectedTaskId?: string) {
if (!selectedTaskId) {
return "";
}
const manifest = await readManifest(selectedTaskId).catch(() => null);
if (!manifest) {
return `Current task id: ${selectedTaskId}\nNo cad-task.json was found.`;
}
const sourcePath = String(manifest.source?.path || "");
let sourceText = "";
if (sourcePath && (sourcePath.endsWith(".py") || sourcePath.endsWith(".scad") || sourcePath.endsWith(".json"))) {
const absoluteSourcePath = path.join(taskDir(selectedTaskId), sourcePath);
sourceText = await fs.readFile(absoluteSourcePath, "utf8").catch(() => "");
}
return [
`Current task id: ${selectedTaskId}`,
"Current cad-task.json:",
compactJson(manifest, 10000),
sourceText ? "Current editable source of truth:" : "",
sourceText ? sourceText.slice(0, 18000) : "",
].filter(Boolean).join("\n");
}
function viewerContextSummary(viewerContext: unknown[]) {
if (!viewerContext.length) {
return "";
}
return [
"Current viewer context JSON from the right-side CAD preview:",
compactJson(viewerContext, 14000),
].join("\n");
}
function attachmentSummary(attachments: unknown[]) {
if (!attachments.length) {
return "";
}
return [
"Current uploaded attachment JSON available to the agent:",
compactJson(attachments, 14000),
"If a STEP/STP attachment is present, inspect its deterministic evidence, then author DesignIR without reading or importing the teacher STEP.",
].join("\n");
}
function buildSystemPrompt(viewerContext: unknown[], attachments: unknown[], taskContext = "") {
return [
"You are the CAD Agent Studio assistant.",
"You help create and modify CAD models through a real server-side CAD generation tool.",
"Strict honesty rule: never claim that a CAD file/model was generated unless the generate_cad tool has succeeded and returned artifact URLs.",
"Never return only code when the user asks to generate CAD. Use the generate_cad tool instead.",
"Do not rely on hardcoded templates, canned examples, mock data, fake filenames, or imaginary viewer state.",
"The generate_cad tool validates executable DesignIR, selects a backend through CAD Router, and rebuilds STEP in a teacher-free sandbox.",
"When STEP files are attached, the source is teacher and acceptance truth only. Reconstruction consumes deterministic evidence, never source B-Rep.",
"All uploaded files are available to you. Images are attached directly to the latest user message when the selected provider/model supports vision. STEP/STP and binary files are available through inspect_uploaded_file.",
"When the user asks to model from an uploaded image, inspect the image directly from the image part. If the selected provider/model cannot process images, say that clearly and ask the user to switch to a vision-capable OpenAI-compatible model.",
"When the user asks to use an uploaded STEP/STP or other non-image file, call inspect_uploaded_file before making claims about its geometry or contents.",
"When viewer context is present, use its cad-edit-intent.v1 or cad-ai-geometry-selection.v1 payload as precise geometry context.",
"If viewer context selection.scope is selected_reference_only, modify only that selected topology/feature. Do not change a global pattern parameter or all repeated/symmetric features unless the user explicitly asks for all of them.",
"For selected holes, use the selected reference center/surface/bbox/adjacent selectors to identify the individual feature. If the editable source models a repeated pattern with one shared diameter variable, split out or override the selected instance instead of changing the shared variable.",
"Prefer editing the DesignIR recorded in cad-task.json before regenerating STEP and viewer assets.",
"For CAD generation, author complete DesignIR 2.0 with parameters, datums, features, constraints, edit interface, and validation perturbations.",
"Never embed STEP/B-Rep/mesh data or source topology references in DesignIR.",
"If CAD generation is requested and the generate_cad tool is available, call generate_cad.",
"For modifications, use the current editable source of truth when provided. Preserve unrelated geometry and change only the selected/requested feature.",
"",
`Viewer context count: ${viewerContext.length}`,
`Attachment count: ${attachments.length}`,
"",
attachmentSummary(attachments),
"",
viewerContextSummary(viewerContext),
"",
taskContext,
].join("\n");
}
function lastUserText(messages: ChatMessage[]) {
return [...messages].reverse().find((message) => message.role === "user")?.content || "";
}
function hostnameForUrl(value: string) {
try {
return new URL(value).hostname.toLowerCase();
} catch {
return "";
}
}
function isOfficialOpenAIBaseURL(value: string) {
const hostname = hostnameForUrl(value);
return hostname === "api.openai.com";
}
function buildLanguageModel({
apiKey,
provider,
providerConfig,
model,
}: {
apiKey: string;
provider: string;
providerConfig: LlmConfig["providers"][string];
model: string;
}) {
if (providerConfig.type === "openai" && isOfficialOpenAIBaseURL(providerConfig.baseURL)) {
return createOpenAI({ apiKey, baseURL: providerConfig.baseURL })(model);
}
return createOpenAICompatible({
name: provider,
apiKey,
baseURL: providerConfig.baseURL,
})(model);
}
function errorDetail(error: unknown) {
if (!error || typeof error !== "object") {
return "";
}
const record = error as Record<string, unknown>;
const statusCode = record.statusCode || record.status || record.responseStatus;
const data = record.data || record.responseBody || record.body;
const parts = [];
if (statusCode) {
parts.push(`status=${String(statusCode)}`);
}
if (typeof data === "string") {
parts.push(data.slice(0, 500));
} else if (data && typeof data === "object") {
const maybeError = (data as { error?: { message?: unknown } }).error;
const message = maybeError?.message || (data as { message?: unknown }).message;
if (message) {
parts.push(String(message));
}
}
return parts.join("; ");
}
function formatModelError({
error,
provider,
model,
providerConfig,
}: {
error: unknown;
provider: string;
model: string;
providerConfig: LlmConfig["providers"][string];
}) {
const baseHost = hostnameForUrl(providerConfig.baseURL) || "unknown-host";
const message = error instanceof Error ? error.message : "unknown error";
const detail = errorDetail(error);
return [
`provider: ${provider}`,
`model: ${model}`,
`baseURL host: ${baseHost}`,
detail ? `${message} (${detail})` : message,
].join("\n");
}
function localAssistantReply({
messages,
attachments,
viewerContext,
selectedTaskId,
}: {
messages: ChatMessage[];
attachments: unknown[];
viewerContext: unknown[];
selectedTaskId?: string;
}) {
const lastUser = [...messages].reverse().find((message) => message.role === "user")?.content || "";
const lines = [
"已收到请求。我现在可以读取上传文件、当前 task、以及 CAD Viewer 工具栏发送过来的结构化几何上下文。",
selectedTaskId ? `当前任务: ${selectedTaskId}` : "当前还没有绑定 CAD task。",
attachments.length ? `本次消息附件: ${attachments.length} 个。` : "本次消息没有新附件。",
viewerContext.length ? `Viewer 上下文: ${viewerContext.length} 条,下一步会优先按这些选区/编辑 intent 修改源文件。` : "Viewer 尚未发送选区或编辑 intent。",
"",
lastUser ? `用户指令: ${lastUser}` : "请描述要生成或修改的 CAD 模型。",
"",
"提示: 配置 OPENAI_API_KEY 或 DEEPSEEK_API_KEY 后,这里会切换为真实模型 streaming 回复。",
];
return lines.join("\n");
}
async function inspectStepAttachment(attachment: AttachmentRecord) {
const absolutePath = attachmentAbsolutePath(attachment);
const script = [
"from build123d import import_step",
"import json, sys",
"path = sys.argv[1]",
"shape = import_step(path)",
"def vec(value):",
" if value is None:",
" return None",
" for names in (('X','Y','Z'), ('x','y','z')):",
" try:",
" return [float(getattr(value, name)) for name in names]",
" except Exception:",
" pass",
" try:",
" return [float(value[i]) for i in range(3)]",
" except Exception:",
" return str(value)",
"def count(method_name):",
" try:",
" return len(getattr(shape, method_name)())",
" except Exception:",
" return None",
"bbox = None",
"try:",
" bb = shape.bounding_box()",
" center = getattr(bb, 'center', None)",
" center = center() if callable(center) else center",
" bbox = {'min': vec(bb.min), 'max': vec(bb.max), 'size': vec(bb.size), 'center': vec(center)}",
"except Exception as exc:",
" bbox = {'error': str(exc)}",
"payload = {",
" 'type': str(type(shape)),",
" 'bbox': bbox,",
" 'volume': float(getattr(shape, 'volume')) if getattr(shape, 'volume', None) is not None else None,",
" 'solids': count('solids'),",
" 'faces': count('faces'),",
" 'edges': count('edges'),",
" 'vertices': count('vertices'),",
"}",
"print(json.dumps(payload, ensure_ascii=False))",
].join("\n");
const [{ stdout }, excerpt] = await Promise.all([
execFileAsync("python3", ["-c", script, absolutePath], {
timeout: 30_000,
maxBuffer: 1024 * 1024,
}),
readTextExcerpt(attachment, 24_000).catch(() => null),
]);
const geometry = JSON.parse(stdout.trim().split(/\r?\n/).pop() || "{}");
return {
attachment,
inspectionKind: "step_geometry",
geometry,
stepTextExcerpt: excerpt ? {
text: excerpt.text,
truncated: excerpt.truncated,
bytesRead: excerpt.bytesRead,
totalBytes: excerpt.totalBytes,
} : null,
};
}
async function inspectGenericAttachment(attachment: AttachmentRecord, includeBase64 = false) {
if (isStepAttachment(attachment)) {
return inspectStepAttachment(attachment);
}
if (isTextLikeAttachment(attachment)) {
const excerpt = await readTextExcerpt(attachment, 120_000);
return {
attachment,
inspectionKind: "text",
text: excerpt.text,
truncated: excerpt.truncated,
bytesRead: excerpt.bytesRead,
totalBytes: excerpt.totalBytes,
};
}
const absolutePath = attachmentAbsolutePath(attachment);
const data = await fs.readFile(absolutePath);
const prefix = data.subarray(0, Math.min(data.byteLength, 64_000));
return {
attachment,
inspectionKind: isImageAttachment(attachment) ? "image_binary" : "binary",
mediaType: attachmentMediaType(attachment),
totalBytes: data.byteLength,
note: isImageAttachment(attachment)
? "This image should also be attached directly as an image part in the user message for vision-capable models."
: "Binary content is not directly human-readable. Use metadata and request user clarification unless the format is explicitly supported.",
base64Prefix: includeBase64 ? prefix.toString("base64") : undefined,
prefixBytes: includeBase64 ? prefix.byteLength : undefined,
};
}
export async function streamAgentResponse({
messages,
attachments,
viewerContext,
provider,
model,
selectedTaskId,
}: {
messages: unknown[];
attachments: unknown[];
viewerContext: unknown[];
provider?: string;
model?: string;
selectedTaskId?: string;
}) {
const config = loadLlmConfig();
const selected = selectedModelId(provider, model, config);
const apiKey = resolveProviderApiKey(selected.providerConfig);
const chatMessages = normalizedMessages(messages);
const uploadedAttachments = normalizedAttachments(attachments);
if (!apiKey) {
return uiTextResponse(localAssistantReply({ messages: chatMessages, attachments: uploadedAttachments, viewerContext, selectedTaskId }));
}
const languageModel = buildLanguageModel({
apiKey,
provider: selected.provider,
providerConfig: selected.providerConfig,
model: selected.model,
});
let cadGeneration: CadGenerationResult | null = null;
let streamWriter: { write: (part: any) => void } | null = null;
const transientProgressSteps = new Set(["analyze_request", "agent_stream"]);
const writeCadProgress = (payload: CadProgressPayload) => {
streamWriter?.write({
type: "data-cad-progress",
data: payload,
id: `cad_progress_${payload.step}`,
transient: transientProgressSteps.has(payload.step),
});
};
const writeCadError = (stage: string, message: string) => {
streamWriter?.write({
type: "data-cad-error",
data: { stage, message },
id: `cad_error_${stage}`,
});
};
let agentStreamFailed = false;
let agentStreamCompleted = false;
const completeAgentStream = (message: string) => {
if (agentStreamFailed || agentStreamCompleted) return;
agentStreamCompleted = true;
writeCadProgress({
step: "agent_stream",
label: "调用模型和工具",
status: "success",
message,
});
};
const failAgentStream = (message: string) => {
agentStreamFailed = true;
writeCadProgress({
step: "agent_stream",
label: "调用模型和工具",
status: "error",
message,
});
writeCadError("agent_stream", message);
};
const taskContext = await buildTaskContext(selectedTaskId);
const cadTools = {
inspect_uploaded_file: tool({
description: [
"Inspect a user-uploaded file before using it for CAD generation.",
"Use this for STEP/STP, text, source, PDF, or binary attachments.",
"For STEP/STP this returns build123d geometry facts such as bbox, volume, and topology counts plus a STEP text excerpt.",
].join(" "),
inputSchema: z.object({
id: z.string().optional().describe("Attachment id from the uploaded file list."),
taskId: z.string().optional().describe("Attachment taskId."),
path: z.string().optional().describe("Attachment path inside the task directory."),
includeBase64: z.boolean().default(false).describe("For binary files, include a base64 prefix. Avoid unless needed."),
}),
execute: async ({ id, taskId, path: attachmentPath, includeBase64 }) => {
writeCadProgress({
step: "inspect_uploaded_file",
label: "检查上传文件",
status: "running",
message: "正在读取附件元数据和可解析内容。",
});
const attachment = findAttachment(uploadedAttachments, { id, taskId, path: attachmentPath });
if (!attachment) {
writeCadProgress({
step: "inspect_uploaded_file",
label: "检查上传文件",
status: "error",
message: "没有找到模型请求引用的附件。",
});
return {
ok: false,
error: "Attachment not found. Use an id/taskId/path from the uploaded attachment JSON.",
availableAttachments: uploadedAttachments.map((item) => ({
id: item.id,
taskId: item.taskId,
path: item.path,
name: item.name,
kind: item.kind,
mime: item.mime,
size: item.size,
})),
};
}
try {
const inspected = {
ok: true,
...(await inspectGenericAttachment(attachment, includeBase64)),
};
writeCadProgress({
step: "inspect_uploaded_file",
label: "检查上传文件",
status: "success",
message: `${attachment.name || attachment.path} 已读取。`,
});
return inspected;
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to inspect uploaded file.";
writeCadProgress({
step: "inspect_uploaded_file",
label: "检查上传文件",
status: "error",
message,
});
return {
ok: false,
attachment,
error: message,
};
}
},
}),
generate_cad: tool({
description: [
"Generate a real CAD model from agent-authored executable DesignIR 2.0.",
"Call this only when the user wants CAD generation or a model modification.",
"CAD Router selects the backend and the server rebuilds STEP without teacher geometry.",
].join(" "),
inputSchema: z.object({
summary: z.string().describe("Concise user-facing summary of the generated CAD model."),
designirFilename: z.string().describe("DesignIR filename, e.g. model.designir.json."),
stepFilename: z.string().describe("STEP output filename, e.g. model.step."),
designir: z.record(z.string(), z.unknown()).describe("Complete executable DesignIR 2.0 JSON object."),
teacherTaskId: z.string().optional().describe("Uploaded teacher STEP task id for independent acceptance only."),
teacherPath: z.string().optional().describe("Uploaded teacher STEP path for independent acceptance only."),
assumptions: z.array(z.string()).default([]).describe("Assumptions made because the user did not specify every dimension."),
}),
execute: async ({
summary,
designirFilename,
stepFilename,
designir,
teacherTaskId,
teacherPath,
assumptions,
}) => {
writeCadProgress({
step: "generate_designir",
label: "生成 DesignIR",
status: "success",
message: designirFilename,
});
writeCadProgress({
step: "execute_cad",
label: "隔离重建 STEP",
status: "running",
message: "正在进行防作弊检查、CAD Router 选路和无教师重建。",
});
try {
cadGeneration = await executeDesignIR({
prompt: lastUserText(chatMessages),
summary,
designirFilename,
stepFilename,
designir,
teacherTaskId,
teacherPath,
assumptions,
selectedTaskId,
});
} catch (error) {
const message = error instanceof Error ? error.message : "CAD generation failed.";
writeCadProgress({
step: "execute_cad",
label: "隔离重建 STEP",
status: "error",
message,
});
failAgentStream(message);
throw error;
}
writeCadProgress({
step: "execute_cad",
label: "隔离重建 STEP",
status: "success",
message: cadGeneration.artifactPath,
});
writeCadProgress({
step: "update_preview",
label: "更新右侧预览",
status: "success",
message: cadGeneration.viewerAssetUrl ? "预览网格已准备完成。" : "CAD 文件已生成。",
});
completeAgentStream("模型和 CAD 工具执行已完成。");
streamWriter?.write({
type: "data-cad-result",
data: {
taskId: cadGeneration.taskId,
sourcePath: cadGeneration.sourcePath,
artifactPath: cadGeneration.artifactPath,
artifactUrl: cadGeneration.artifactUrl,
previewPath: cadGeneration.previewPath,
previewUrl: cadGeneration.previewUrl,
viewerAssetPath: cadGeneration.viewerAssetPath,
viewerAssetUrl: cadGeneration.viewerAssetUrl,
summary: cadGeneration.summary,
},
id: `cad_result_${cadGeneration.taskId}`,
});
return cadGeneration;
},
}),
};
const formatCurrentModelError = (error: unknown) => formatModelError({
error,
provider: selected.provider,
model: selected.model,
providerConfig: selected.providerConfig,
});
const stream = createUIMessageStream<UIMessage>({
execute: async ({ writer }) => {
streamWriter = writer;
writeCadProgress({
step: "analyze_request",
label: "分析需求",
status: "running",
message: "正在整理消息、附件、当前 task 和 Viewer 上下文。",
});
try {
const modelMessages = await buildModelMessages(chatMessages, uploadedAttachments);
writeCadProgress({
step: "analyze_request",
label: "分析需求",
status: "success",
message: `${modelMessages.length} 条上下文消息已准备。`,
});
writeCadProgress({
step: "agent_stream",
label: "调用模型和工具",
status: "running",
message: `${selected.provider}:${selected.model}`,
});
const result = streamText({
model: languageModel,
system: buildSystemPrompt(viewerContext, uploadedAttachments, taskContext),
tools: cadTools,
stopWhen: isStepCount(3),
messages: modelMessages,
});
writer.merge(result.toUIMessageStream({
onEnd: () => {
completeAgentStream(cadGeneration ? "模型和 CAD 工具执行已完成。" : "模型回复已完成。");
},
onError: (error) => {
const message = formatCurrentModelError(error);
failAgentStream(message);
return message;
},
}));
} catch (error) {
const message = formatCurrentModelError(error);
failAgentStream(message);
writeTextPart(writer, `Agent 请求没有成功,未写入任何可预览结果。\n\n原因:${message}`);
}
},
onError: formatCurrentModelError,
});
return createUIMessageStreamResponse({
stream,
headers: {
"Cache-Control": "no-store",
},
});
}