refactor: add DesignIR distillation pipeline
This commit is contained in:
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user