Files
cadSet/cad-agent-studio/src/lib/chat.ts
T

1642 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { z } from "zod/v4";
import {
editDesignIRParameter,
executeBackendNativeGeneration,
reconstructUploadedStep,
routeNewCadRequest,
type CadGenerationResult,
type NewCadRouteDecision,
} from "@/lib/cad-generator";
import { loadLlmConfig, resolveProviderApiKey, selectedModelId, type LlmConfig } from "@/lib/config";
import {
appendConversationMessage,
conversationModelMessages,
ensureConversation,
safeConversationId,
} from "@/lib/conversation-store";
import { exportRobotDescription } from "@/lib/robot-export";
import { readManifest, taskDir } from "@/lib/task-store";
const execFileAsync = promisify(execFile);
function cadPythonExecutable() {
const configured = String(process.env.CAD_PYTHON || "").trim();
if (configured) return configured;
const workspacePython = path.join(process.cwd(), "..", "text-to-cad", ".venv", "bin", "python");
if (existsSync(workspacePython)) return workspacePython;
if (commandExists("python")) return "python";
if (commandExists("python3")) return "python3";
return "python3";
}
function commandExists(command: string) {
return String(process.env.PATH || "")
.split(path.delimiter)
.some((directory) => existsSync(path.join(directory, command)));
}
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, onEnd?: (message: UIMessage) => Promise<void>) {
const stream = createUIMessageStream<UIMessage>({
execute: ({ writer }) => {
writeTextPart(writer, text);
},
onEnd: async ({ responseMessage, isAborted }) => {
if (!isAborted) await onEnd?.(responseMessage);
},
});
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 reconstruction, call reconstruct_uploaded_step with id/taskId/path. Do not ask the model to author DesignIR from STEP text.",
].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(() => "");
if (sourcePath.endsWith(".json") && sourceText) {
try {
const payload = JSON.parse(sourceText) as Record<string, any>;
const surfaceLayer = payload.surface_layer;
if (
surfaceLayer
&& typeof surfaceLayer === "object"
&& typeof surfaceLayer.data === "string"
) {
payload.surface_layer = {
format: surfaceLayer.format,
encoding: surfaceLayer.encoding,
uncompressed_bytes: surfaceLayer.uncompressed_bytes,
compressed_bytes: surfaceLayer.compressed_bytes,
sha256: surfaceLayer.sha256,
data: "<omitted from LLM context; deterministic CAD runtime only>",
};
} else if (surfaceLayer && typeof surfaceLayer === "object") {
payload.surface_layer = {
storage: "expanded_surfaceir_omitted_from_llm_context",
solid_count: Array.isArray(surfaceLayer.solids)
? surfaceLayer.solids.length
: undefined,
free_shell_count: Array.isArray(surfaceLayer.free_shells)
? surfaceLayer.free_shells.length
: undefined,
surface_vocabulary: surfaceLayer.surface_vocabulary,
curve_vocabulary: surfaceLayer.curve_vocabulary,
};
}
sourceText = JSON.stringify(payload, null, 2);
} catch {
// Non-DesignIR JSON remains available as a bounded text excerpt.
}
}
}
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, call reconstruct_uploaded_step so CAD Router executes the deterministic DesignIR 3.0 / SurfaceIR pipeline.",
].join("\n");
}
const SIMPLECADAPI_SKILL_ROOT = path.join(process.cwd(), "..", "SimpleCADAPI", "skills", "simplecadapi");
const SIMPLECADAPI_REFERENCE_ROOT = path.join(SIMPLECADAPI_SKILL_ROOT, "references");
const SIMPLECADAPI_REQUIRED_DOCS = ["skill", "api/README.md", "stdlib/README.md"];
const TEXT_TO_CAD_SKILL_ROOT = path.join(process.cwd(), "..", "text-to-cad", "skills", "cad");
const TEXT_TO_CAD_REQUIRED_DOCS = ["skill", "references/build123d-modeling.md", "references/step-generation.md"];
type SimpleCadApiDocumentationResult =
| {
ok: true;
document: string;
path: string;
content: string;
resolvedFrom?: string;
}
| {
ok: false;
document: string;
error: string;
suggestedDocuments: string[];
};
const SIMPLECADAPI_DOC_DIRECTORIES = ["api", "stdlib", "core"] as const;
function normalizeSimpleCadApiDocument(document: string) {
const requested = String(document || "").trim().replaceAll("\\", "/").replace(/^\.\//, "");
const normalized = path.posix.normalize(requested);
if (!requested || normalized === "." || normalized.startsWith("../") || path.posix.isAbsolute(normalized)) {
return null;
}
return normalized;
}
function normalizeTextToCadDocument(document: string) {
const requested = String(document || "").trim().replaceAll("\\", "/").replace(/^\.\//, "");
const normalized = path.posix.normalize(requested);
if (
!requested
|| requested.split("/").includes("..")
|| normalized === "."
|| normalized.startsWith("../")
|| path.posix.isAbsolute(normalized)
) {
return null;
}
return normalized;
}
async function matchingSimpleCadApiDocuments(filename: string) {
if (!filename.endsWith(".md")) return [];
const matches = await Promise.all(SIMPLECADAPI_DOC_DIRECTORIES.map(async (directory) => {
const root = path.join(SIMPLECADAPI_REFERENCE_ROOT, "docs", directory);
try {
const entries = await fs.readdir(root, { withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && entry.name === filename)
.map((entry) => `${directory}/${entry.name}`);
} catch {
return [];
}
}));
return matches.flat();
}
export async function readSimpleCadApiDocumentation(document: string): Promise<SimpleCadApiDocumentationResult> {
const normalized = normalizeSimpleCadApiDocument(document);
if (!normalized) {
return {
ok: false,
document: String(document || ""),
error: "SimpleCADAPI document must be a listed base document or a relative Markdown page below references/docs/.",
suggestedDocuments: [],
};
}
const fixedDocuments: Record<string, string> = {
skill: path.join(SIMPLECADAPI_SKILL_ROOT, "SKILL.md"),
"api/README.md": path.join(SIMPLECADAPI_REFERENCE_ROOT, "docs", "api", "README.md"),
"stdlib/README.md": path.join(SIMPLECADAPI_REFERENCE_ROOT, "docs", "stdlib", "README.md"),
"SDK_SURFACES.md": path.join(SIMPLECADAPI_REFERENCE_ROOT, "SDK_SURFACES.md"),
"MODELING_WORKFLOWS.md": path.join(SIMPLECADAPI_REFERENCE_ROOT, "MODELING_WORKFLOWS.md"),
};
const fixedPath = fixedDocuments[normalized];
const referenceDocsRoot = path.resolve(SIMPLECADAPI_REFERENCE_ROOT, "docs");
let resolvedDocument = normalized;
let target = fixedPath || path.resolve(referenceDocsRoot, normalized);
const relativeTarget = path.relative(referenceDocsRoot, target);
const allowedDirectPath = fixedPath || (
normalized.endsWith(".md")
&& !relativeTarget.startsWith("..")
&& !path.isAbsolute(relativeTarget)
);
if (!allowedDirectPath || !target.endsWith(".md") || !existsSync(target)) {
const candidates = await matchingSimpleCadApiDocuments(path.posix.basename(normalized));
if (candidates.length === 1) {
resolvedDocument = candidates[0];
target = path.join(referenceDocsRoot, resolvedDocument);
} else {
return {
ok: false,
document: normalized,
error: candidates.length > 1
? "SimpleCADAPI document path is ambiguous. Use one of the suggested canonical paths."
: "SimpleCADAPI document was not found. Read api/README.md or stdlib/README.md and use its linked canonical page path.",
suggestedDocuments: candidates,
};
}
}
try {
const content = await fs.readFile(target, "utf8");
return {
ok: true,
document: resolvedDocument,
path: target,
content,
...(resolvedDocument !== normalized ? { resolvedFrom: normalized } : {}),
};
} catch (error) {
return {
ok: false,
document: normalized,
error: error instanceof Error ? error.message : "SimpleCADAPI document could not be read.",
suggestedDocuments: [],
};
}
}
type TextToCadDocumentationResult =
| { ok: true; document: string; path: string; content: string }
| { ok: false; document: string; error: string; suggestedDocuments: string[] };
export async function readTextToCadDocumentation(document: string): Promise<TextToCadDocumentationResult> {
const normalized = normalizeTextToCadDocument(document);
const fixedDocuments: Record<string, string> = {
skill: path.join(TEXT_TO_CAD_SKILL_ROOT, "SKILL.md"),
"references/build123d-modeling.md": path.join(TEXT_TO_CAD_SKILL_ROOT, "references", "build123d-modeling.md"),
"references/step-generation.md": path.join(TEXT_TO_CAD_SKILL_ROOT, "references", "step-generation.md"),
"references/inspection-and-validation.md": path.join(TEXT_TO_CAD_SKILL_ROOT, "references", "inspection-and-validation.md"),
"references/positioning.md": path.join(TEXT_TO_CAD_SKILL_ROOT, "references", "positioning.md"),
};
const cadSkillxRoot = path.resolve(TEXT_TO_CAD_SKILL_ROOT, "references", "cad-skillx");
const cadSkillxTarget = normalized
? path.resolve(TEXT_TO_CAD_SKILL_ROOT, normalized)
: "";
const cadSkillxRelative = cadSkillxTarget ? path.relative(cadSkillxRoot, cadSkillxTarget) : "";
const cadSkillxDocument = Boolean(
normalized
&& normalized.startsWith("references/cad-skillx/")
&& normalized.endsWith(".md")
&& cadSkillxRelative
&& !cadSkillxRelative.startsWith("..")
&& !path.isAbsolute(cadSkillxRelative),
);
const target = fixedDocuments[normalized || ""] || (cadSkillxDocument ? cadSkillxTarget : "");
if (!normalized || !target) {
return {
ok: false,
document: String(document || ""),
error: "Use a listed text-to-cad skill document key or a Markdown file below references/cad-skillx/.",
suggestedDocuments: [
"skill",
...Object.keys(fixedDocuments).filter((key) => key !== "skill"),
"references/cad-skillx/planning/mounting-plate.planning.md",
"references/cad-skillx/planning/flange.planning.md",
"references/cad-skillx/planning/bearing-housing-or-seat.planning.md",
],
};
}
try {
return {
ok: true,
document: normalized,
path: target,
content: await fs.readFile(target, "utf8"),
};
} catch (error) {
return {
ok: false,
document: normalized,
error: error instanceof Error ? error.message : "text-to-cad document could not be read.",
suggestedDocuments: [],
};
}
}
async function loadRequiredTextToCadDocumentation() {
const results = await Promise.all(TEXT_TO_CAD_REQUIRED_DOCS.map(readTextToCadDocumentation));
const unavailable = results.filter((result) => !result.ok);
if (unavailable.length) {
throw new Error(`Studio could not load required text-to-cad documentation: ${unavailable.map((result) => result.document).join(", ")}.`);
}
return results.filter((result): result is Extract<TextToCadDocumentationResult, { ok: true }> => result.ok);
}
type Build123dApiSymbol = {
symbol: string;
ok: boolean;
signature?: string;
documentation?: string;
error?: string;
};
function parseJsonObjectFromStdout(stdout: string) {
const lines = stdout.split(/\r?\n/);
for (let index = lines.length - 1; index >= 0; index -= 1) {
if (!lines[index]?.trim().startsWith("{")) continue;
try {
return JSON.parse(lines.slice(index).join("\n")) as Record<string, unknown>;
} catch {
// Ignore non-JSON diagnostics preceding a final JSON report.
}
}
return null;
}
export async function inspectBuild123dApi(symbols: string[]) {
const requested = [...new Set(symbols.map((symbol) => String(symbol).trim()))]
.filter((symbol) => /^[A-Za-z_]\w*$/.test(symbol))
.slice(0, 32);
if (!requested.length) {
return { symbols: [] as Build123dApiSymbol[] };
}
const script = [
"import inspect, json, sys",
"import build123d as b123d",
"result = []",
"for name in sys.argv[1:] :",
" try:",
" value = getattr(b123d, name)",
" signature = str(inspect.signature(value))",
" documentation = inspect.getdoc(value) or ''",
" result.append({'symbol': name, 'ok': True, 'signature': signature, 'documentation': documentation[:4000]})",
" except Exception as error:",
" result.append({'symbol': name, 'ok': False, 'error': str(error)})",
"print(json.dumps({'symbols': result}, ensure_ascii=False))",
].join("\n");
const { stdout } = await execFileAsync(cadPythonExecutable(), ["-c", script, ...requested], {
cwd: process.cwd(),
timeout: 30_000,
maxBuffer: 1024 * 1024,
});
const payload = parseJsonObjectFromStdout(stdout);
const values = Array.isArray(payload?.symbols) ? payload.symbols : [];
return {
symbols: values.filter((value): value is Build123dApiSymbol => Boolean(value) && typeof value === "object"),
};
}
function importedPythonNames(source: string) {
const build123dImports = new Set<string>();
const externalImports = new Set<string>();
for (const match of source.matchAll(/^\s*from\s+([A-Za-z_][\w.]*)\s+import\s+(.+)$/gm)) {
const moduleName = match[1];
const names = match[2]
.split(",")
.map((part) => part.trim().replace(/\s+#.*$/, ""))
.filter(Boolean);
for (const namePart of names) {
const localName = (/\s+as\s+([A-Za-z_]\w*)$/.exec(namePart)?.[1] || namePart.split(/\s+as\s+/)[0]).trim();
if (!/^[A-Za-z_]\w*$/.test(localName)) continue;
if (moduleName === "build123d") {
build123dImports.add(localName);
} else {
externalImports.add(localName);
}
}
}
return { build123dImports, externalImports };
}
export function build123dCallCandidates(source: string) {
const { build123dImports, externalImports } = importedPythonNames(source);
const defined = new Set(
[...source.matchAll(/^\s*def\s+([A-Za-z_]\w*)\s*\(/gm)].map((match) => match[1]),
);
const candidates = [...source.matchAll(/(?<![\w.])([A-Za-z_]\w*)\s*\(/g)]
.map((match) => match[1])
.filter((name) => !defined.has(name))
.filter((name) => !externalImports.has(name))
.filter((name) => !build123dImports.size || build123dImports.has(name));
const memberReferences = [...source.matchAll(/(?<![\w.])([A-Za-z_]\w*)\.[A-Za-z_]\w*/g)]
.map((match) => match[1])
.filter((name) => build123dImports.has(name));
return [...new Set([...candidates, ...memberReferences])].slice(0, 64);
}
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.",
"For every new text/image CAD model, call route_cad_request once before writing native source. CAD Router selects SimpleCADAPI only for catalogued standard primary parts; every other new part is text-to-cad/build123d. Use its returned sourceKind in generate_cad. Do not choose the backend yourself and do not read backend documentation for routing.",
"When route_cad_request returns simplecadapi_python, first read the original SimpleCADAPI docs through read_simplecadapi_docs: skill, api/README.md, and stdlib/README.md. Then read the exact original API or stdlib Markdown page for every SimpleCADAPI function used in the source. Follow the documented signatures literally; do not invent namespaces or API names. Documentation paths come from the README links, not Python namespaces: for example simplecadapi.ql.value is documented as api/value.md, not ql/value.md. The server rejects source that calls a nonexistent SimpleCADAPI attribute.",
"When route_cad_request returns build123d_python, Studio automatically loads the text-to-cad skill, build123d-modeling, and step-generation documents into the route result. Do not repeat those reads. If the request matches a CAD-SkillX entry listed there, use read_text_to_cad_docs only for the relevant Planning/Functional/Atomic reference. Before writing source, call read_build123d_api once with every build123d symbol you plan to use. It reads the active build123d runtime's actual signature and docstring. Follow those signatures exactly: do not guess argument order, namespaces, or keyword names. Use build123d enum members exactly, for example Align.CENTER and Mode.SUBTRACT, not strings such as \"CENTER\".",
"When STEP files are attached, the source is teacher and acceptance truth only. Use reconstruct_uploaded_step; never import the teacher as model geometry.",
"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 reconstruct an uploaded STEP/STP, call reconstruct_uploaded_step directly. Use inspect_uploaded_file only for read-only questions that do not request reconstruction.",
"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 the source of truth recorded in cad-task.json before regenerating STEP and viewer assets.",
"If the requested change matches a listed editable parameter, call edit_designir3_parameter. The server edits the backend-bound parameter without LLM participation: build123d/native Python, SimpleCADAPI source/model graph, or SurfaceIR validated parameter as recorded.",
"After uploaded-STEP reconstruction, list every perturbation-validated editable parameter by name, current value, unit, and validated range. Never report only the parameter count. Explain that unlisted inferred parameters remain in DesignIR but are not safely exposed until an executable binding and perturbation acceptance exist.",
"When the user asks to download or convert the current model to URDF or MJCF, call export_robot_description. Do not fabricate XML. The tool performs lazy conversion from the current DesignIR and returns a portable ZIP containing the robot description and mesh.",
"After export_robot_description succeeds, the UI starts the download and renders the exact package link. Say that the download has started; do not manually rewrite, shorten, or guess the returned URL.",
"A STEP-derived DesignIR without authoritative link/joint/material evidence exports honestly as one fixed base_link with inertial data omitted. Never invent articulated joints, limits, actuators, density, mass, or inertia.",
"For structural edits to generated text/image models, read the current source of truth and submit the edited backend-native source to generate_cad; preserve the current backend unless the user explicitly requests backend conversion.",
"Do not author full DesignIR JSON for new text/image CAD generation. DesignIR 3.0 is the normalized contract layer generated after the backend has produced native source/graph and STEP.",
"DesignIR records backend, source-of-truth paths, parameters, feature tree, validation, and edit bindings; it is not the default modeling language for new geometry.",
"For generated backend-native models, never pretend backend_native_feature entries are replayable DesignIR compiler operations.",
"CAD Router may use its standard-part keyword catalog only to select a backend. The server never infers geometry, dimensions, or features from keywords. generate_cad requires final backend-native source.",
"Native source must be raw Python, not markdown. It must be a CLI program with argparse parse_args(), accept --step and --metadata, call export_step(..., args.step), and write backend-metadata.json to args.metadata; SimpleCADAPI source must also accept --model-json and write the requested model JSON file. A def gen_step() that only returns a part/object is an obsolete interface and will be rejected unless a __main__ CLI exports the files. stdout JSON is optional diagnostic output, not the source of truth.",
"Do not submit API/signature/geometry probe scripts to generate_cad. nativeSource must be the final model generator for the user request, not a script that introspects the SDK, sweeps trial parameters, diagnoses topology, or intentionally raises an exception to report facts.",
"Do not use generate_cad as an API discovery or exploration tool.",
"If generate_cad returns ok=false and retryable=true, read the returned error/artifact diagnostics, revise the native source, and call generate_cad again in the same turn. Do not ask the user whether to retry unless the failure is caused by missing user requirements.",
"Use generationMode=new_model for ordinary new CAD generation, structural_edit for modifying the current backend source, and backend_conversion only when the user explicitly asks to convert backend.",
"Every generated backend-native model must expose editable parameters as a normal part of generation. Define important dimensions, counts, spacings, radii, chamfers/fillets, angles, and offsets as named numeric variables in the native source instead of burying them as magic numbers.",
"For each generated model, include several useful editable parameters whenever the geometry has several meaningful dimensions. If the model is very simple, expose all meaningful dimensions. Do not leave backend-metadata.json parameters empty for a successfully generated model unless there are genuinely no numeric modeling choices.",
"Editable parameters must be real source-bound variables used by the geometry, not decorative metadata. Native source must include a CAD_AGENT_PARAMETERS block, and backend-metadata.json parameters must include name, display_name, value, unit, editable, binding_kind, parameter_path, and regenerate_adapter for each exposed parameter.",
"Parameter names shown to the user must be concise Chinese labels generated for the model, not raw English code identifiers. Keep code variable names stable and machine-friendly; keep display_name human-friendly and Chinese.",
"Uploaded STEP reconstruction also uses DesignIR 3.0, but in deterministic surface_parametric mode. Do not substitute one mode for the other.",
"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 } = await execFileAsync(
cadPythonExecutable(),
["-c", script, absolutePath],
{
timeout: 30_000,
maxBuffer: 1024 * 1024,
},
);
const geometry = JSON.parse(stdout.trim().split(/\r?\n/).pop() || "{}");
return {
attachment,
inspectionKind: "step_geometry",
geometry,
sourcePolicy: "Teacher geometry was measured read-only; raw STEP text is not exposed to the reconstruction model.",
};
}
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,
conversationId,
}: {
messages: unknown[];
attachments: unknown[];
viewerContext: unknown[];
provider?: string;
model?: string;
selectedTaskId?: string;
conversationId?: string;
}) {
const requestedConversationId = String(conversationId || "").trim();
const normalizedConversationId = requestedConversationId ? safeConversationId(requestedConversationId) : "";
const requestMessages = messages;
const uploadedAttachments = normalizedAttachments(attachments);
let chatMessages = normalizedMessages(requestMessages);
if (normalizedConversationId) {
const latestUserMessage = [...requestMessages].reverse().find((message) => {
const record = message && typeof message === "object" ? message as Record<string, unknown> : null;
return record?.role === "user";
});
if (!latestUserMessage) {
throw new Error("A user message is required to continue a conversation.");
}
const conversation = await ensureConversation(normalizedConversationId, selectedTaskId || "");
const persisted = await appendConversationMessage({
conversationId: normalizedConversationId,
message: latestUserMessage,
currentTaskId: selectedTaskId || conversation.currentTaskId,
attachments: uploadedAttachments,
});
chatMessages = conversationModelMessages(persisted.messages) as ChatMessage[];
selectedTaskId = String(selectedTaskId || persisted.currentTaskId || "").trim() || undefined;
}
const persistAssistantMessage = async (message: UIMessage) => {
if (!normalizedConversationId) return;
await appendConversationMessage({
conversationId: normalizedConversationId,
message,
currentTaskId: selectedTaskId || "",
attachments: uploadedAttachments,
});
};
let config: LlmConfig;
try {
config = loadLlmConfig();
} catch (error) {
const message = error instanceof Error ? error.message : "无法加载模型配置。";
return uiTextResponse(`Agent 请求没有成功。\n\n原因:${message}`, persistAssistantMessage);
}
const selected = selectedModelId(provider, model, config);
const apiKey = resolveProviderApiKey(selected.providerConfig);
if (!apiKey) {
return uiTextResponse(
localAssistantReply({ messages: chatMessages, attachments: uploadedAttachments, viewerContext, selectedTaskId }),
persistAssistantMessage,
);
}
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"]);
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 simpleCadApiDocsRead = new Set<string>();
const textToCadDocsRead = new Set<string>();
const build123dApiSymbolsRead = new Set<string>();
const routeDecisions = new Map<string, NewCadRouteDecision>();
const cadTools = {
route_cad_request: tool({
description: [
"Route one new CAD request before authoring native source.",
"The CAD Router uses its exact SimpleCADAPI standard-part catalog: catalogued primary parts use SimpleCADAPI; all other new parts use text-to-cad/build123d.",
"Do not use this for an uploaded STEP or a same-backend structural edit.",
].join(" "),
inputSchema: z.object({
request: z.string().describe("The full new-model request to classify."),
}),
execute: async ({ request }) => {
writeCadProgress({
step: "route_request",
label: "CAD Router 选路",
status: "running",
message: "正在按标准零件类型目录选择生成后端。",
});
const decision = await routeNewCadRequest(request || lastUserText(chatMessages));
simpleCadApiDocsRead.clear();
textToCadDocsRead.clear();
build123dApiSymbolsRead.clear();
routeDecisions.clear();
const build123dBaseDocs = decision.selectedBackend === "build123d"
? await loadRequiredTextToCadDocumentation()
: [];
for (const document of build123dBaseDocs) {
textToCadDocsRead.add(document.document);
}
const routeToken = randomUUID();
routeDecisions.set(routeToken, decision);
writeCadProgress({
step: "route_request",
label: "CAD Router 选路",
status: "success",
message: `${decision.selectedBackend}: ${decision.rationale}`,
});
return {
...decision,
routeToken,
...(build123dBaseDocs.length ? {
backendContext: {
source: "Studio-loaded text-to-cad/build123d base documentation",
documents: build123dBaseDocs.map(({ document, content }) => ({ document, content })),
},
} : {}),
};
},
}),
read_simplecadapi_docs: tool({
description: [
"Read original SimpleCADAPI skill or API documentation before writing SimpleCADAPI source.",
"Read skill, api/README.md, and stdlib/README.md first, then the exact API or stdlib Markdown page for every function used.",
"Use the canonical paths linked from api/README.md or stdlib/README.md. Do not derive a documentation directory from a Python namespace; ql/value.md, for example, resolves to api/value.md when it is unique.",
].join(" "),
inputSchema: z.object({
document: z.string().describe("Original skill/doc key or Markdown page path."),
}),
execute: async ({ document }) => {
const result = await readSimpleCadApiDocumentation(document);
if (result.ok) simpleCadApiDocsRead.add(result.document);
return result;
},
}),
read_text_to_cad_docs: tool({
description: [
"Read an additional original text-to-cad/build123d reference when the Studio-loaded base documentation identifies a need.",
"The base skill, build123d modeling, and STEP generation references are already returned by route_cad_request for build123d routes.",
"Use this for relevant Markdown files below references/cad-skillx/ or optional positioning/inspection references.",
].join(" "),
inputSchema: z.object({
document: z.string().describe("Listed text-to-cad skill document key or references/cad-skillx/... Markdown path."),
}),
execute: async ({ document }) => {
const result = await readTextToCadDocumentation(document);
if (result.ok) textToCadDocsRead.add(result.document);
return result;
},
}),
read_build123d_api: tool({
description: [
"Read exact function/class signatures and docstrings from the active build123d runtime before writing source.",
"Pass all planned build123d symbols in one call, for example BuildPart, Locations, Cylinder, Box, chamfer, and export_step.",
"This is runtime documentation only; it does not execute or generate geometry.",
].join(" "),
inputSchema: z.object({
symbols: z.array(z.string()).min(1).max(32).describe("build123d top-level symbols used by the planned native source."),
}),
execute: async ({ symbols }) => {
const result = await inspectBuild123dApi(symbols);
for (const symbol of result.symbols) {
if (symbol.ok) build123dApiSymbolsRead.add(symbol.symbol);
}
return result;
},
}),
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,
};
}
},
}),
reconstruct_uploaded_step: tool({
description: [
"Reconstruct one uploaded STEP/STP through CAD Router's executable DesignIR 3.0 / SurfaceIR pipeline.",
"Use this instead of generate_cad for an uploaded STEP.",
"It independently rebuilds STEP, runs geometry acceptance, validates editable parameters, and publishes the preview.",
].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("STEP path inside the upload task."),
}),
execute: async ({ id, taskId, path: attachmentPath }) => {
const attachment = findAttachment(uploadedAttachments, {
id,
taskId,
path: attachmentPath,
});
if (!attachment || !isStepAttachment(attachment)) {
throw new Error(
"A STEP/STP attachment is required. Use id/taskId/path from the uploaded attachment list.",
);
}
writeCadProgress({
step: "execute_cad",
label: "DesignIR 3.0 独立重建",
status: "running",
message: "正在提取 SurfaceIR、独立重建、执行几何和参数验收。",
});
try {
cadGeneration = await reconstructUploadedStep({
prompt: lastUserText(chatMessages),
teacherTaskId: String(attachment.taskId),
teacherPath: String(attachment.path),
teacherName: attachment.name,
});
} catch (error) {
const message = error instanceof Error
? error.message
: "Uploaded STEP reconstruction failed.";
writeCadProgress({
step: "execute_cad",
label: "DesignIR 3.0 独立重建",
status: "error",
message,
});
failAgentStream(message);
throw error;
}
writeCadProgress({
step: "execute_cad",
label: "DesignIR 3.0 独立重建",
status: "success",
message: `${cadGeneration.artifactPath},参数验收已完成。`,
});
writeCadProgress({
step: "update_preview",
label: "更新右侧预览",
status: "success",
message: "独立重建模型已载入。",
});
completeAgentStream("DesignIR 3.0 重建和验收已完成。");
streamWriter?.write({
type: "data-cad-result",
data: {
taskId: cadGeneration.taskId,
sourcePath: cadGeneration.sourcePath,
sourceUrl: cadGeneration.sourceUrl,
parameters: cadGeneration.parameters,
editableParameters: cadGeneration.editableParameters,
artifactPath: cadGeneration.artifactPath,
artifactUrl: cadGeneration.artifactUrl,
featureTreePath: cadGeneration.featureTreePath,
featureTreeUrl: cadGeneration.featureTreeUrl,
parameterCatalogPath: cadGeneration.parameterCatalogPath,
parameterCatalogUrl: cadGeneration.parameterCatalogUrl,
previewPath: cadGeneration.previewPath,
previewUrl: cadGeneration.previewUrl,
viewerAssetPath: cadGeneration.viewerAssetPath,
viewerAssetUrl: cadGeneration.viewerAssetUrl,
summary: cadGeneration.summary,
},
id: `cad_result_${cadGeneration.taskId}`,
});
return cadGeneration;
},
}),
edit_designir3_parameter: tool({
description: [
"Edit one listed editable CAD parameter on the current task without LLM participation.",
"The requested value must be within the range shown in parameters.json.",
"The server uses the recorded backend binding: native Python/source for generated models or SurfaceIR acceptance for uploaded STEP reconstructions.",
].join(" "),
inputSchema: z.object({
parameter: z.string().describe("Editable parameter name from the current task."),
value: z.number().describe("New value within the parameter's declared range."),
}),
execute: async ({ parameter, value }) => {
if (!selectedTaskId) {
throw new Error("No current CAD task is selected.");
}
writeCadProgress({
step: "backend_parameter_edit",
label: "修改后端绑定参数",
status: "running",
message: `${parameter} -> ${value},正在使用记录的无 LLM adapter 重新生成。`,
});
try {
cadGeneration = await editDesignIRParameter({
prompt: lastUserText(chatMessages),
sourceTaskId: selectedTaskId,
parameter,
value,
});
} catch (error) {
const message = error instanceof Error
? error.message
: "CAD parameter edit failed.";
writeCadProgress({
step: "backend_parameter_edit",
label: "修改后端绑定参数",
status: "error",
message,
});
failAgentStream(message);
throw error;
}
writeCadProgress({
step: "backend_parameter_edit",
label: "修改后端绑定参数",
status: "success",
message: cadGeneration.summary,
});
writeCadProgress({
step: "update_preview",
label: "更新右侧预览",
status: "success",
message: "参数修改后的模型已载入。",
});
completeAgentStream("CAD 参数修改和重新生成已完成。");
streamWriter?.write({
type: "data-cad-result",
data: {
taskId: cadGeneration.taskId,
sourcePath: cadGeneration.sourcePath,
sourceUrl: cadGeneration.sourceUrl,
parameters: cadGeneration.parameters,
editableParameters: cadGeneration.editableParameters,
artifactPath: cadGeneration.artifactPath,
artifactUrl: cadGeneration.artifactUrl,
featureTreePath: cadGeneration.featureTreePath,
featureTreeUrl: cadGeneration.featureTreeUrl,
parameterCatalogPath: cadGeneration.parameterCatalogPath,
parameterCatalogUrl: cadGeneration.parameterCatalogUrl,
previewPath: cadGeneration.previewPath,
previewUrl: cadGeneration.previewUrl,
viewerAssetPath: cadGeneration.viewerAssetPath,
viewerAssetUrl: cadGeneration.viewerAssetUrl,
summary: cadGeneration.summary,
},
id: `cad_result_${cadGeneration.taskId}`,
});
return cadGeneration;
},
}),
export_robot_description: tool({
description: [
"Lazily export the current DesignIR 3.0 CAD task as a portable URDF or MJCF ZIP package.",
"The package contains the robot description, a high-resolution STL visual/collision mesh, and an export manifest.",
"Use this when the user asks to download, export, or convert the current model to URDF or MJCF.",
].join(" "),
inputSchema: z.object({
format: z.enum(["urdf", "mjcf"]),
taskId: z.string().optional().describe(
"CAD task id. Omit to use the currently selected task.",
),
}),
execute: async ({ format, taskId }) => {
const targetTaskId = String(taskId || selectedTaskId || "").trim();
if (!targetTaskId) {
throw new Error("Select or generate a CAD task before robot export.");
}
writeCadProgress({
step: `export_${format}`,
label: `生成 ${format.toUpperCase()}`,
status: "running",
message: "正在从 DesignIR 独立重建几何并打包机器人描述与网格。",
});
try {
const exported = await exportRobotDescription(
targetTaskId,
format,
);
writeCadProgress({
step: `export_${format}`,
label: `生成 ${format.toUpperCase()}`,
status: "success",
message: exported.status === "cached"
? "已复用当前 DesignIR 的现有导出包。"
: "机器人描述、网格和导出说明已生成。",
});
streamWriter?.write({
type: "data-robot-export",
data: exported,
id: `robot_export_${format}_${targetTaskId}`,
});
completeAgentStream(`${format.toUpperCase()} 按需导出已完成。`);
return {
ok: true,
...exported,
downloadStartedByUi: true,
note: "The UI starts the ZIP download and renders the exact package URL; do not rewrite the URL in prose.",
};
} catch (error) {
const message = error instanceof Error
? error.message
: `${format.toUpperCase()} export failed.`;
writeCadProgress({
step: `export_${format}`,
label: `生成 ${format.toUpperCase()}`,
status: "error",
message,
});
throw error;
}
},
}),
generate_cad: tool({
description: [
"Execute backend-native Python source submitted by the agent to generate a real CAD model.",
"Call this only when the user wants CAD generation or a model modification.",
"The server does not infer geometry, choose templates, or generate fallback models.",
"Do not call this for API discovery, topology diagnostics, radius sweeps, or smoke probes.",
"stdout JSON is optional diagnostic output; generated files are the execution source of truth.",
"If this returns ok=false and retryable=true, fix the nativeSource from the diagnostics and call generate_cad again immediately.",
].join(" "),
inputSchema: z.object({
routeToken: z.string().optional().describe("Opaque token returned by route_cad_request. Required for new_model and binds the request/backend."),
sourceKind: z.enum(["build123d_python", "simplecadapi_python"]).describe("Native source kind returned by route_cad_request, or the existing source of truth for an edit."),
generationMode: z.enum(["new_model", "structural_edit", "backend_conversion"]).default("new_model").describe("Whether this is a new model, a same-backend structural edit, or an explicit backend conversion."),
nativeSource: z.string().describe("Raw backend-native Python source. No markdown fences. Must be the final CLI generator, not an API/topology/radius probe and not a gen_step-only return-object script. Must parse required CLI args, call export_step(..., args.step), and write STEP plus metadata. Editable parameter metadata must include Chinese user-facing names/display_name values."),
summary: z.string().optional().describe("Concise user-facing summary of the requested CAD model."),
targetName: z.string().optional().describe("Optional model/task artifact name stem, e.g. mounting_bracket."),
assumptions: z.array(z.string()).default([]).describe("Assumptions made because the user did not specify every dimension."),
}),
execute: async ({
routeToken,
sourceKind,
generationMode,
nativeSource,
summary,
targetName,
assumptions,
}) => {
const routeDecision = generationMode === "new_model"
? routeDecisions.get(String(routeToken || ""))
: undefined;
if (generationMode === "new_model" && !routeDecision) {
throw new Error("New CAD generation requires the routeToken returned by route_cad_request; do not rephrase or reroute the request in generate_cad.");
}
if (
generationMode === "new_model"
&& sourceKind === "simplecadapi_python"
&& SIMPLECADAPI_REQUIRED_DOCS.some((document) => !simpleCadApiDocsRead.has(document))
) {
throw new Error(
"SimpleCADAPI generation requires original docs: skill, api/README.md, and stdlib/README.md before native source is submitted.",
);
}
if (generationMode === "new_model" && sourceKind === "build123d_python") {
const apiSymbols = await inspectBuild123dApi(build123dCallCandidates(nativeSource));
const missing = apiSymbols.symbols
.filter((symbol) => symbol.ok && !build123dApiSymbolsRead.has(symbol.symbol))
.map((symbol) => symbol.symbol);
if (missing.length) {
for (const symbol of missing) {
build123dApiSymbolsRead.add(symbol);
}
writeCadProgress({
step: "build123d_api_preflight",
label: "补全 build123d API",
status: "success",
message: `服务端已自动补读缺失 API${missing.join(", ")}。`,
});
}
}
writeCadProgress({
step: "backend_generate",
label: "后端原生生成",
status: "running",
message: "正在执行 agent 提交的原生 source,server 不生成模板或替代模型。",
});
try {
cadGeneration = await executeBackendNativeGeneration({
request: routeDecision?.request || lastUserText(chatMessages),
sourceKind,
generationMode,
routeDecision,
nativeSource,
summary,
targetName,
assumptions,
selectedTaskId,
});
} catch (error) {
const message = error instanceof Error ? error.message : "CAD generation failed.";
writeCadProgress({
step: "backend_generate",
label: "后端原生生成",
status: "error",
message,
});
return {
ok: false,
retryable: true,
stage: "backend_generate",
error: message,
instruction: "Revise nativeSource using this diagnostic and call generate_cad again in the same turn. Do not ask the user for permission to retry.",
};
}
writeCadProgress({
step: "backend_generate",
label: "后端原生生成",
status: "success",
message: cadGeneration.artifactPath,
});
writeCadProgress({
step: "normalize_designir",
label: "归一化 DesignIR",
status: "success",
message: cadGeneration.sourcePath,
});
writeCadProgress({
step: "publish_artifacts",
label: "发布产物",
status: "success",
message: "特征树、参数列表和预览已准备完成。",
});
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,
sourceUrl: cadGeneration.sourceUrl,
parameters: cadGeneration.parameters,
editableParameters: cadGeneration.editableParameters,
artifactPath: cadGeneration.artifactPath,
artifactUrl: cadGeneration.artifactUrl,
featureTreePath: cadGeneration.featureTreePath,
featureTreeUrl: cadGeneration.featureTreeUrl,
parameterCatalogPath: cadGeneration.parameterCatalogPath,
parameterCatalogUrl: cadGeneration.parameterCatalogUrl,
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(16),
messages: modelMessages,
});
writer.merge(result.toUIMessageStream({
sendReasoning: false,
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,
onEnd: async ({ responseMessage, isAborted }) => {
if (isAborted) return;
try {
await persistAssistantMessage(responseMessage);
} catch (error) {
console.error("Failed to persist CAD conversation response", error);
}
},
});
return createUIMessageStreamResponse({
stream,
headers: {
"Cache-Control": "no-store",
},
});
}