feat: integrate SimpleCADAPI 2.0.2 CAD workflows

This commit is contained in:
Jerry
2026-08-03 11:17:05 +08:00
parent b5738e9109
commit c3a0f269b7
481 changed files with 110229 additions and 12826 deletions
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import {
exportRobotDescription,
type RobotExportFormat,
} from "@/lib/robot-export";
export const runtime = "nodejs";
export async function POST(
_request: NextRequest,
context: {
params: Promise<{ taskId: string; format: string }>;
},
) {
const { taskId, format } = await context.params;
if (format !== "urdf" && format !== "mjcf") {
return NextResponse.json(
{ error: "Export format must be urdf or mjcf." },
{ status: 400 },
);
}
try {
return NextResponse.json(
await exportRobotDescription(taskId, format as RobotExportFormat),
{
headers: {
"Cache-Control": "no-store",
},
},
);
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error
? error.message
: "Robot description export failed.",
},
{ status: 500 },
);
}
}
+291 -13
View File
@@ -22,6 +22,7 @@ import {
Check,
CircleAlert,
FileImage,
FileJson2,
FileUp,
Loader2,
MessageSquare,
@@ -60,6 +61,8 @@ type ViewerContext = {
type CadResult = {
taskId: string;
sourcePath?: string;
sourceUrl?: string;
editableParameters?: Array<Record<string, unknown>>;
artifactPath?: string;
artifactUrl?: string;
previewPath?: string;
@@ -81,6 +84,19 @@ type CadError = {
message: string;
};
type RobotExportFormat = "urdf" | "mjcf";
type RobotExportResult = {
status: "generated" | "cached";
format: RobotExportFormat;
taskId: string;
packagePath: string;
packageUrl: string;
descriptionPath: string;
descriptionUrl: string;
warnings: string[];
};
type PublicConfig = {
defaultProvider: string;
defaultModel: string;
@@ -131,10 +147,27 @@ function extractCadResult(text: string) {
}
}
function taskArtifactUrl(taskId: string, relativePath: string) {
if (!taskId || !relativePath) return "";
return `/api/tasks/${encodeURIComponent(taskId)}/artifacts/${relativePath
.split(/[\\/]+/)
.map((part) => encodeURIComponent(part))
.join("/")}`;
}
function setIfChanged(setter: (value: string | ((current: string) => string)) => void, nextValue = "") {
setter((current) => (current === nextValue ? current : nextValue));
}
function triggerArtifactDownload(url: string) {
const link = document.createElement("a");
link.href = url;
link.download = "";
document.body.appendChild(link);
link.click();
link.remove();
}
function UploadedAttachmentCard({ attachment }: { attachment: Attachment }) {
return (
<div className="flex min-w-0 items-center gap-2 px-1 py-1 text-[11px] text-[#a9b0b6]">
@@ -169,6 +202,11 @@ function CadProgressCard({ data }: DataMessagePartProps<CadProgress>) {
}
function CadArtifactCard({ data }: DataMessagePartProps<CadResult>) {
const editableParameters: Array<Record<string, unknown>> = Array.isArray(data.editableParameters)
? data.editableParameters
: [];
const sourceDownloadUrl = data.sourceUrl
|| taskArtifactUrl(data.taskId, data.sourcePath || "");
return (
<div className="my-2 pl-1 text-[12px]">
<div className="flex items-center gap-2 font-medium text-[#dff5f6]">
@@ -180,10 +218,46 @@ function CadArtifactCard({ data }: DataMessagePartProps<CadResult>) {
{data.sourcePath ? <div>{data.sourcePath}</div> : null}
{data.artifactPath ? <div>{data.artifactPath}</div> : null}
</div>
{data.artifactUrl ? (
<a className="ml-5 mt-1 inline-flex text-[11px] text-[#8bd5dc] underline-offset-2 hover:underline" href={data.artifactUrl} download>
STEP
</a>
<div className="ml-5 mt-1 flex flex-wrap gap-3">
{data.artifactUrl ? (
<a className="inline-flex text-[11px] text-[#8bd5dc] underline-offset-2 hover:underline" href={data.artifactUrl} download>
STEP
</a>
) : null}
{sourceDownloadUrl ? (
<a className="inline-flex text-[11px] text-[#d8b66c] underline-offset-2 hover:underline" href={sourceDownloadUrl} download>
DesignIR JSON
</a>
) : null}
</div>
{editableParameters.length ? (
<div className="ml-5 mt-2 rounded border border-[#2d3339] bg-[#15181b] p-2">
<div className="mb-1 text-[11px] font-medium text-[#d7dcdd]">
{editableParameters.length}
</div>
<div className="grid gap-1 text-[10px] leading-4 text-[#a9b0b6]">
{editableParameters.map((parameter) => {
const range = (
parameter.validated_range
&& typeof parameter.validated_range === "object"
? parameter.validated_range
: {}
) as Record<string, unknown>;
const minimum = range.minimum_tested_inclusive;
const maximum = range.maximum_tested_inclusive;
const unit = String(parameter.unit || "");
return (
<div key={String(parameter.name)}>
<span className="text-[#dff5f6]">{String(parameter.name)}</span>
{` = ${String(parameter.value)}${unit ? ` ${unit}` : ""}`}
{typeof minimum === "number" && typeof maximum === "number"
? `(已验证 ${minimum}${maximum}${unit ? ` ${unit}` : ""}`
: ""}
</div>
);
})}
</div>
</div>
) : null}
</div>
);
@@ -201,6 +275,32 @@ function CadErrorCard({ data }: DataMessagePartProps<CadError>) {
);
}
function RobotExportCard({ data }: DataMessagePartProps<RobotExportResult>) {
return (
<div className="my-2 ml-1 rounded border border-[#2d3339] bg-[#15181b] p-2 text-[11px]">
<div className="flex items-center gap-2 text-[#dff5f6]">
<Check className="size-3 text-[#9cd67a]" />
<span>{data.format.toUpperCase()} </span>
<span className="text-[10px] text-[#777e84]">
{data.status === "cached" ? "已复用" : "新生成"}
</span>
</div>
<a
className="mt-1 inline-flex text-[#8bd5dc] underline-offset-2 hover:underline"
href={data.packageUrl}
download
>
{data.format.toUpperCase()}
</a>
{data.warnings?.length ? (
<div className="mt-1 text-[10px] leading-4 text-[#969b9f]">
{data.warnings.join(" ")}
</div>
) : null}
</div>
);
}
function CadToolCallCard(props: ToolCallMessagePartProps) {
const status = String(props.status?.type || props.status || "");
return (
@@ -236,6 +336,7 @@ function MessageText() {
"cad-progress": CadProgressCard,
"cad-result": CadArtifactCard,
"cad-error": CadErrorCard,
"robot-export": RobotExportCard,
},
Fallback: DataFallback,
},
@@ -292,6 +393,9 @@ export function AgentStudio() {
const [currentTaskId, setCurrentTaskId] = useState("");
const [previewUrl, setPreviewUrl] = useState("");
const [artifactUrl, setArtifactUrl] = useState("");
const [sourceUrl, setSourceUrl] = useState("");
const [editableParameters, setEditableParameters] = useState<Array<Record<string, unknown>>>([]);
const [robotExporting, setRobotExporting] = useState<RobotExportFormat | "">("");
const [artifactPath, setArtifactPath] = useState("");
const [viewerAssetUrl, setViewerAssetUrl] = useState("");
const [activeEditToolId, setActiveEditToolId] = useState("");
@@ -307,8 +411,20 @@ export function AgentStudio() {
setIfChanged(setCurrentTaskId, result.taskId);
setIfChanged(setArtifactPath, result.artifactPath || "");
setIfChanged(setArtifactUrl, result.artifactUrl || "");
setIfChanged(
setSourceUrl,
result.sourceUrl || taskArtifactUrl(
result.taskId,
result.sourcePath || "",
),
);
setIfChanged(setPreviewUrl, result.previewUrl || "");
setIfChanged(setViewerAssetUrl, result.viewerAssetUrl || "");
setEditableParameters(
Array.isArray(result.editableParameters)
? result.editableParameters
: [],
);
setActiveEditToolId("");
setAiSelectionMode("point");
}, []);
@@ -322,9 +438,41 @@ export function AgentStudio() {
if (dataPart.type === "data-cad-error") {
const data = dataPart.data as CadError;
setError(data.message || "CAD 生成失败");
return;
}
if (dataPart.type === "data-robot-export") {
const data = dataPart.data as RobotExportResult;
if (data.packageUrl) {
triggerArtifactDownload(data.packageUrl);
}
}
}, [applyCadResult]);
const handleRobotExport = useCallback(async (format: RobotExportFormat) => {
if (!currentTaskId || robotExporting) return;
setError("");
setRobotExporting(format);
try {
const response = await fetch(
`/api/tasks/${encodeURIComponent(currentTaskId)}/exports/${format}`,
{ method: "POST" },
);
const result = await response.json() as RobotExportResult & { error?: string };
if (!response.ok || !result.packageUrl) {
throw new Error(result.error || `${format.toUpperCase()} 转换失败`);
}
triggerArtifactDownload(result.packageUrl);
} catch (nextError) {
setError(
nextError instanceof Error
? nextError.message
: `${format.toUpperCase()} 转换失败`,
);
} finally {
setRobotExporting("");
}
}, [currentTaskId, robotExporting]);
const transport = useMemo(() => new AssistantChatTransport<UIMessage>({
api: "/api/chat",
body: {
@@ -359,6 +507,64 @@ export function AgentStudio() {
});
}, []);
useEffect(() => {
const taskId = new URLSearchParams(window.location.search)
.get("taskId")
?.trim();
if (taskId && /^[a-zA-Z0-9_-]{4,80}$/.test(taskId)) {
setCurrentTaskId(taskId);
}
}, []);
useEffect(() => {
if (!currentTaskId) return;
const url = new URL(window.location.href);
if (url.searchParams.get("taskId") !== currentTaskId) {
url.searchParams.set("taskId", currentTaskId);
window.history.replaceState({}, "", url);
}
}, [currentTaskId]);
useEffect(() => {
if (!currentTaskId) {
setEditableParameters([]);
setSourceUrl("");
return;
}
void fetch(`/api/tasks/${encodeURIComponent(currentTaskId)}`)
.then((response) => response.json())
.then((task) => {
const manifest = (
task?.manifest && typeof task.manifest === "object"
? task.manifest
: {}
) as Record<string, any>;
const sourcePath = String(manifest.source?.path || "");
if (sourcePath) {
setSourceUrl(taskArtifactUrl(currentTaskId, sourcePath));
}
const latestArtifact = task?.latestArtifact;
if (latestArtifact?.path && latestArtifact?.url) {
setArtifactPath(String(latestArtifact.path));
setArtifactUrl(String(latestArtifact.url));
setPreviewUrl(String(latestArtifact.url));
}
const latestViewerAsset = task?.latestViewerAsset;
if (latestViewerAsset?.url) {
setViewerAssetUrl(String(latestViewerAsset.url));
}
setEditableParameters(
Array.isArray(manifest.parameters?.parameters)
? manifest.parameters.parameters
: [],
);
})
.catch(() => {
// The generation result still supplies the artifact links when task
// metadata is temporarily unavailable.
});
}, [currentTaskId]);
const providerModels = useMemo(() => {
const models = config?.providers?.[DEEPSEEK_PROVIDER_ID]?.models || {};
return Object.entries(models);
@@ -634,15 +840,87 @@ export function AgentStudio() {
</div>
</div>
)}
{artifactUrl ? (
<a
className="absolute right-24 top-4 z-30 inline-flex h-7 items-center gap-1 rounded-md border border-[#2d3339] bg-[#15181b]/95 px-2 text-[11px] text-[#c9cccf] hover:bg-[#20252a]"
href={artifactUrl}
download
>
<FileUp className="size-3.5" />
STEP
</a>
{artifactUrl || sourceUrl ? (
<div className="absolute right-4 top-4 z-30 flex items-center gap-1">
{sourceUrl ? (
<a
className="inline-flex h-7 items-center gap-1 rounded-md border border-[#2d3339] bg-[#15181b]/95 px-2 text-[11px] text-[#c9cccf] hover:bg-[#20252a]"
href={sourceUrl}
download
title="下载可独立重建和参数修改的 DesignIR JSON"
>
<FileJson2 className="size-3.5" />
JSON
</a>
) : null}
{artifactUrl ? (
<a
className="inline-flex h-7 items-center gap-1 rounded-md border border-[#2d3339] bg-[#15181b]/95 px-2 text-[11px] text-[#c9cccf] hover:bg-[#20252a]"
href={artifactUrl}
download
title="下载当前 STEP"
>
<FileUp className="size-3.5" />
STEP
</a>
) : null}
<button
className="inline-flex h-7 items-center gap-1 rounded-md border border-[#2d3339] bg-[#15181b]/95 px-2 text-[11px] text-[#c9cccf] hover:bg-[#20252a] disabled:cursor-wait disabled:opacity-60"
disabled={!currentTaskId || Boolean(robotExporting)}
onClick={() => void handleRobotExport("urdf")}
title="点击后按需生成并下载包含网格的 URDF 包"
type="button"
>
{robotExporting === "urdf"
? <Loader2 className="size-3.5 animate-spin" />
: <FileUp className="size-3.5" />}
URDF
</button>
<button
className="inline-flex h-7 items-center gap-1 rounded-md border border-[#2d3339] bg-[#15181b]/95 px-2 text-[11px] text-[#c9cccf] hover:bg-[#20252a] disabled:cursor-wait disabled:opacity-60"
disabled={!currentTaskId || Boolean(robotExporting)}
onClick={() => void handleRobotExport("mjcf")}
title="点击后按需生成并下载包含网格的 MJCF 包"
type="button"
>
{robotExporting === "mjcf"
? <Loader2 className="size-3.5 animate-spin" />
: <FileUp className="size-3.5" />}
MJCF
</button>
</div>
) : null}
{editableParameters.length ? (
<details className="absolute right-4 top-14 z-30 w-[310px] rounded-md border border-[#2d3339] bg-[#15181b]/95 text-[11px] shadow-xl">
<summary className="cursor-pointer px-3 py-2 text-[#dff5f6]">
{editableParameters.length}
</summary>
<div className="max-h-64 overflow-auto border-t border-[#2d3339] px-3 py-2 text-[#a9b0b6]">
{editableParameters.map((parameter) => {
const range = (
parameter.validated_range
&& typeof parameter.validated_range === "object"
? parameter.validated_range
: {}
) as Record<string, unknown>;
const minimum = range.minimum_tested_inclusive;
const maximum = range.maximum_tested_inclusive;
const unit = String(parameter.unit || "");
return (
<div className="py-0.5" key={String(parameter.name)}>
<span className="text-[#dff5f6]">{String(parameter.name)}</span>
{` = ${String(parameter.value)}${unit ? ` ${unit}` : ""}`}
{typeof minimum === "number" && typeof maximum === "number"
? `,范围 ${minimum}${maximum}${unit ? ` ${unit}` : ""}`
: ""}
</div>
);
})}
<div className="mt-2 border-t border-[#2d3339] pt-2 text-[10px] leading-4 text-[#777e84]">
DesignIR JSON
</div>
</div>
</details>
) : null}
</section>
</div>
+148 -16
View File
@@ -24,6 +24,8 @@ export type CadGenerationResult = {
viewerAssetPath: string;
viewerAssetUrl: string;
sourcePath: string;
sourceUrl: string;
editableParameters: Array<Record<string, unknown>>;
summary: string;
};
@@ -56,6 +58,59 @@ function surfaceirScript() {
return enginePath("designir-pipeline", "scripts", "surfaceir_pipeline.py");
}
function semanticEditableParameters(
designir: Record<string, any>,
): Array<Record<string, unknown>> {
const parameters = designir.semantic_layer?.parameters;
if (!parameters || typeof parameters !== "object") return [];
return Object.entries(parameters)
.filter(([, value]) => (
value
&& typeof value === "object"
&& (value as Record<string, unknown>).editable === true
))
.map(([name, value]) => ({
name,
...(value as Record<string, unknown>),
}));
}
function catalogEditableParameters(
catalog: Record<string, unknown>,
): Array<Record<string, unknown>> {
return Array.isArray(catalog.parameters)
? catalog.parameters.filter(
(value): value is Record<string, unknown> => (
value !== null && typeof value === "object"
),
)
: [];
}
function parameterSummary(
parameters: Array<Record<string, unknown>>,
) {
if (!parameters.length) return "当前没有通过验收的可编辑参数。";
const rows = parameters.map((parameter) => {
const range = (
parameter.validated_range
&& typeof parameter.validated_range === "object"
? parameter.validated_range
: {}
) as Record<string, unknown>;
const minimum = range.minimum_tested_inclusive;
const maximum = range.maximum_tested_inclusive;
const unit = String(parameter.unit || "");
const rangeText = (
typeof minimum === "number" && typeof maximum === "number"
? `,已验证范围 ${minimum}${maximum}${unit ? ` ${unit}` : ""}`
: ""
);
return `${String(parameter.name)}=${String(parameter.value)}${unit ? ` ${unit}` : ""}${rangeText}`;
});
return `已验证可修改参数:${rows.join("")}`;
}
async function runJson(
command: string,
args: string[],
@@ -81,6 +136,16 @@ async function runJson(
throw new Error("CAD worker did not return a valid JSON result.");
}
function selectedBackendFromRoute(route: Record<string, unknown>) {
const selected = String(route.selected_backend || "");
if (selected !== "build123d" && selected !== "simplecadapi") {
throw new Error(
`CAD Router did not return a supported backend: ${selected || "<missing>"}`,
);
}
return selected;
}
export async function executeDesignIR({
prompt,
summary,
@@ -118,7 +183,7 @@ export async function executeDesignIR({
[routerScript(), prompt],
task.dir,
);
const selectedBackend = String(route.selected_backend || "build123d");
const selectedBackend = selectedBackendFromRoute(route);
const normalizedDesignIR = {
...designir,
backend_hint: selectedBackend,
@@ -243,6 +308,11 @@ export async function executeDesignIR({
project: String(route.project || "text-to-cad"),
fallback_order: Array.isArray(route.fallback_order) ? route.fallback_order : [],
workflow_profiles: Array.isArray(route.workflow_profiles) ? route.workflow_profiles : [],
routing_policy: (
route.routing_policy && typeof route.routing_policy === "object"
? route.routing_policy
: {}
),
},
source: {
path: sourcePath,
@@ -263,6 +333,7 @@ export async function executeDesignIR({
});
const stepUrl = artifactUrl(task.taskId, artifactPath, artifactVersion);
const editableParameters = semanticEditableParameters(normalizedDesignIR);
return {
handled: true,
taskId: task.taskId,
@@ -273,7 +344,12 @@ export async function executeDesignIR({
viewerAssetPath,
viewerAssetUrl: artifactUrl(task.taskId, viewerAssetPath, artifactVersion),
sourcePath,
summary: summary || "CAD model generated from semantic DesignIR 3.0.",
sourceUrl: artifactUrl(task.taskId, sourcePath, artifactVersion),
editableParameters,
summary: [
summary || "CAD model generated from semantic DesignIR 3.0.",
parameterSummary(editableParameters),
].join(" "),
};
}
@@ -314,6 +390,7 @@ export async function reconstructUploadedStep({
if (
execution.status !== "accepted"
&& execution.status !== "accepted_empty_source"
&& execution.status !== "review_required"
) {
throw new Error(
`DesignIR 3.0 reconstruction was not accepted: ${JSON.stringify(execution)}`,
@@ -333,6 +410,9 @@ export async function reconstructUploadedStep({
const parametersPath = relativeArtifact(execution.parameters);
const geometryReportPath = relativeArtifact(execution.geometry_report);
const editReportPath = relativeArtifact(execution.edit_report);
const brepReportPath = execution.brep_report
? relativeArtifact(execution.brep_report)
: null;
const parameterCatalog = JSON.parse(
await fs.readFile(path.join(task.dir, parametersPath), "utf8"),
) as Record<string, unknown>;
@@ -350,10 +430,36 @@ export async function reconstructUploadedStep({
? execution.experience
: {}
) as Record<string, unknown>;
const strictGeometryAccepted = execution.geometry_accepted === true;
const usableGeometry = (
strictGeometryAccepted || execution.geometry_usable === true
);
if (!usableGeometry) {
throw new Error(
`DesignIR 3.0 reconstruction did not produce usable geometry: ${JSON.stringify(execution)}`,
);
}
const qualityTier = String(execution.quality_tier || "review_required");
const geometryValidationSummary = qualityTier === "brep_exact"
? "Independent geometry and strict B-Rep topology acceptance passed."
: strictGeometryAccepted
? `Independent geometry acceptance passed; B-Rep quality tier is ${qualityTier}.`
: "Independent reconstruction passed the usable-geometry contract and requires visual review; strict 1:1 acceptance did not pass.";
const reconstructionArtifacts = [
{ path: sourcePath, role: "editable_contract", kind: "designir-3.0" },
{ path: artifactPath, role: "primary", kind: "step" },
{ path: parametersPath, role: "validated_parameters", kind: "json" },
{ path: geometryReportPath, role: "geometry_acceptance", kind: "json" },
{ path: editReportPath, role: "parameter_acceptance", kind: "json" },
...(brepReportPath
? [{ path: brepReportPath, role: "strict_brep_acceptance", kind: "json" }]
: []),
{ path: viewerAssetPath, role: "viewer", kind: "glb" },
];
await upsertManifest(task.taskId, {
request: prompt,
route: {
selected_backend: String(route.selected_backend || "build123d"),
selected_backend: selectedBackendFromRoute(route),
runner_skill: "cad-router",
project: "text-to-cad",
execution_adapter: "DesignIR 3.0 / SurfaceIR",
@@ -363,6 +469,11 @@ export async function reconstructUploadedStep({
workflow_profiles: Array.isArray(route.workflow_profiles)
? route.workflow_profiles
: [],
routing_policy: (
route.routing_policy && typeof route.routing_policy === "object"
? route.routing_policy
: {}
),
},
source: {
path: sourcePath,
@@ -372,14 +483,7 @@ export async function reconstructUploadedStep({
generated_by: "cad-router_uploaded_step_execute",
teacher_embedded: false,
},
artifacts: [
{ path: sourcePath, role: "editable_contract", kind: "designir-3.0" },
{ path: artifactPath, role: "primary", kind: "step" },
{ path: parametersPath, role: "validated_parameters", kind: "json" },
{ path: geometryReportPath, role: "geometry_acceptance", kind: "json" },
{ path: editReportPath, role: "parameter_acceptance", kind: "json" },
{ path: viewerAssetPath, role: "viewer", kind: "glb" },
],
artifacts: reconstructionArtifacts,
parameters: parameterCatalog,
experience: {
context_kind: "promoted_surfaceir_runtime",
@@ -390,7 +494,7 @@ export async function reconstructUploadedStep({
"Uploaded STEP was used only as extraction and acceptance truth.",
"DesignIR 3.0 anti-cheat validation passed.",
"STEP was independently rebuilt without passing the teacher path to the rebuild worker.",
"Independent geometry acceptance passed.",
geometryValidationSummary,
`${Number(execution.validated_parameter_count || 0)} editable parameters passed isolated perturbation acceptance.`,
],
studio: {
@@ -409,6 +513,7 @@ export async function reconstructUploadedStep({
});
const stepUrl = artifactUrl(task.taskId, artifactPath, artifactVersion);
const editableParameters = catalogEditableParameters(parameterCatalog);
return {
handled: true,
taskId: task.taskId,
@@ -423,9 +528,16 @@ export async function reconstructUploadedStep({
artifactVersion,
),
sourcePath,
sourceUrl: artifactUrl(task.taskId, sourcePath, artifactVersion),
editableParameters,
summary: [
`${teacherName || path.basename(teacherPath)} 已通过 DesignIR 3.0 / SurfaceIR 独立重建。`,
`几何验收通过,已验证可修改参数 ${Number(execution.validated_parameter_count || 0)} 个。`,
strictGeometryAccepted
? qualityTier === "brep_exact"
? "严格几何和 B-Rep 拓扑验收通过。"
: `几何验收通过,B-Rep 等级为 ${qualityTier},仍需复核拓扑。`
: "实体几何可用,但未达到严格 1:1,需在预览中复核。",
parameterSummary(editableParameters),
].join(" "),
};
}
@@ -553,14 +665,24 @@ export async function editDesignIR3Parameter({
stepPath: outputStepPath,
});
const artifactVersion = Date.now().toString(36);
const inheritedBackend = String(
sourceManifest.route?.selected_backend || "",
);
const semanticBackend = (
inheritedBackend === "simplecadapi" || inheritedBackend === "build123d"
? inheritedBackend
: "build123d"
);
await upsertManifest(task.taskId, {
request: prompt,
route: {
selected_backend: "build123d",
selected_backend: semanticBackend,
runner_skill: "cad-router",
project: "text-to-cad",
execution_adapter: "DesignIR 3.0 / SurfaceIR parameter edit",
fallback_order: ["simplecadapi"],
fallback_order: [
semanticBackend === "simplecadapi" ? "build123d" : "simplecadapi",
],
workflow_profiles: [],
},
source: {
@@ -603,6 +725,7 @@ export async function editDesignIR3Parameter({
},
});
const stepUrl = artifactUrl(task.taskId, outputStepPath, artifactVersion);
const editableParameters = catalogEditableParameters(parameterCatalog);
return {
handled: true,
taskId: task.taskId,
@@ -617,7 +740,16 @@ export async function editDesignIR3Parameter({
artifactVersion,
),
sourcePath: outputDesignIRPath,
summary: `${parameter} 已修改为 ${value},并通过独立参数验收。`,
sourceUrl: artifactUrl(
task.taskId,
outputDesignIRPath,
artifactVersion,
),
editableParameters,
summary: [
`${parameter} 已修改为 ${value},并通过独立参数验收。`,
parameterSummary(editableParameters),
].join(" "),
};
}
+108
View File
@@ -22,6 +22,7 @@ import {
type CadGenerationResult,
} from "@/lib/cad-generator";
import { loadLlmConfig, resolveProviderApiKey, selectedModelId, type LlmConfig } from "@/lib/config";
import { exportRobotDescription } from "@/lib/robot-export";
import { readManifest, taskDir } from "@/lib/task-store";
const execFileAsync = promisify(execFile);
@@ -285,6 +286,41 @@ async function buildTaskContext(selectedTaskId?: string) {
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}`,
@@ -333,6 +369,10 @@ function buildSystemPrompt(viewerContext: unknown[], attachments: unknown[], tas
"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.",
"If the current task is an uploaded-STEP DesignIR 3.0 surface_parametric reconstruction and the requested change matches a validated parameter, call edit_designir3_parameter.",
"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.",
"If the current task is a text/image DesignIR 3.0 fully_semantic_parametric or hybrid_semantic_surface_parametric model, modify its authoritative semantic_layer and call generate_cad again; preserve unrelated parameters, constraints, and features.",
"For every new text/image CAD generation, author complete DesignIR 3.0 with designir_kind=independent_parametric_cad, reconstruction_mode=fully_semantic_parametric, authoring_mode=semantic_feature_program, and semantic_layer containing reconstruction_status, coordinate_system, datums, parameters, expressions, sketches, constraints, features, patterns, attachments, and construction_stages.",
"For semantic DesignIR 3.0, edit_interface must contain semantic_parameters, surface_parameter_groups=[], modification_levels=[semantic_feature], and preserved_interfaces. validation_contract must contain source_independence=true, geometry_checks, edit_checks, invariants, perturbations, and thresholds.",
@@ -759,6 +799,8 @@ export async function streamAgentResponse({
data: {
taskId: cadGeneration.taskId,
sourcePath: cadGeneration.sourcePath,
sourceUrl: cadGeneration.sourceUrl,
editableParameters: cadGeneration.editableParameters,
artifactPath: cadGeneration.artifactPath,
artifactUrl: cadGeneration.artifactUrl,
previewPath: cadGeneration.previewPath,
@@ -830,6 +872,8 @@ export async function streamAgentResponse({
data: {
taskId: cadGeneration.taskId,
sourcePath: cadGeneration.sourcePath,
sourceUrl: cadGeneration.sourceUrl,
editableParameters: cadGeneration.editableParameters,
artifactPath: cadGeneration.artifactPath,
artifactUrl: cadGeneration.artifactUrl,
previewPath: cadGeneration.previewPath,
@@ -843,6 +887,68 @@ export async function streamAgentResponse({
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: [
"Generate a real CAD model from agent-authored semantic DesignIR 3.0.",
@@ -920,6 +1026,8 @@ export async function streamAgentResponse({
data: {
taskId: cadGeneration.taskId,
sourcePath: cadGeneration.sourcePath,
sourceUrl: cadGeneration.sourceUrl,
editableParameters: cadGeneration.editableParameters,
artifactPath: cadGeneration.artifactPath,
artifactUrl: cadGeneration.artifactUrl,
previewPath: cadGeneration.previewPath,
@@ -47,6 +47,10 @@ export function contentTypeForArtifact(relativePath: string) {
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".json")) return "application/json";
if (lower.endsWith(".urdf")) return "application/xml; charset=utf-8";
if (lower.endsWith(".xml")) return "application/xml; charset=utf-8";
if (lower.endsWith(".stl")) return "model/stl";
if (lower.endsWith(".zip")) return "application/zip";
if (lower.endsWith(".py")) return "text/x-python; charset=utf-8";
return "application/octet-stream";
}
+231
View File
@@ -0,0 +1,231 @@
import crypto from "node:crypto";
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { artifactUrl, safeArtifactPath } from "@/lib/preview-artifacts";
import { enginePath } from "@/lib/paths";
import {
readManifest,
safeTaskId,
taskDir,
upsertManifest,
} from "@/lib/task-store";
const execFileAsync = promisify(execFile);
export type RobotExportFormat = "urdf" | "mjcf";
export type RobotExportResult = {
status: "generated" | "cached";
format: RobotExportFormat;
taskId: string;
packagePath: string;
packageUrl: string;
descriptionPath: string;
descriptionUrl: string;
meshPath: string;
warnings: string[];
robotStructure: "static_single_link";
};
type ExportState = {
schema_version: "1.0";
source_sha256: string;
format: RobotExportFormat;
package_path: string;
description_path: string;
mesh_path: string;
warnings: string[];
};
function pythonExecutable() {
return process.env.CAD_PYTHON
|| enginePath("text-to-cad", ".venv", "bin", "python");
}
function exporterScript() {
return enginePath(
"designir-pipeline",
"scripts",
"robot_description_export.py",
);
}
function relativeArtifact(root: string, absolutePath: string) {
const relative = path.relative(root, path.resolve(absolutePath));
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`Robot exporter returned an artifact outside the task: ${absolutePath}`);
}
return safeArtifactPath(relative);
}
async function fileExists(absolutePath: string) {
return fs.access(absolutePath).then(() => true).catch(() => false);
}
async function readCachedState(
statePath: string,
sourceSha256: string,
format: RobotExportFormat,
root: string,
) {
try {
const state = JSON.parse(await fs.readFile(statePath, "utf8")) as ExportState;
if (
state.source_sha256 !== sourceSha256
|| state.format !== format
) {
return null;
}
const required = [
state.package_path,
state.description_path,
state.mesh_path,
].map((value) => path.join(root, safeArtifactPath(value)));
return (await Promise.all(required.map(fileExists))).every(Boolean)
? state
: null;
} catch {
return null;
}
}
function resultFromState(
taskId: string,
state: ExportState,
status: "generated" | "cached",
): RobotExportResult {
const version = state.source_sha256.slice(0, 16);
return {
status,
format: state.format,
taskId,
packagePath: state.package_path,
packageUrl: artifactUrl(taskId, state.package_path, version),
descriptionPath: state.description_path,
descriptionUrl: artifactUrl(taskId, state.description_path, version),
meshPath: state.mesh_path,
warnings: state.warnings,
robotStructure: "static_single_link",
};
}
export async function exportRobotDescription(
requestedTaskId: string,
format: RobotExportFormat,
): Promise<RobotExportResult> {
const taskId = safeTaskId(requestedTaskId);
if (format !== "urdf" && format !== "mjcf") {
throw new Error("Robot export format must be urdf or mjcf.");
}
const manifest = await readManifest(taskId);
if (!manifest) {
throw new Error("The selected CAD task has no manifest.");
}
const sourcePath = safeArtifactPath(String(manifest.source?.path || ""));
if (!sourcePath.toLowerCase().endsWith(".json")) {
throw new Error("Robot export requires a DesignIR 3.0 JSON source.");
}
const root = taskDir(taskId);
const sourceAbsolutePath = path.join(root, sourcePath);
const sourceData = await fs.readFile(sourceAbsolutePath);
const sourceSha256 = crypto
.createHash("sha256")
.update(sourceData)
.digest("hex");
const exportDir = path.join(root, "exports", format);
const statePath = path.join(exportDir, "export-state.json");
const cached = await readCachedState(
statePath,
sourceSha256,
format,
root,
);
if (cached) {
return resultFromState(taskId, cached, "cached");
}
await fs.mkdir(exportDir, { recursive: true });
const { stdout } = await execFileAsync(
pythonExecutable(),
[
exporterScript(),
"--designir",
sourceAbsolutePath,
"--output-dir",
exportDir,
"--format",
format,
],
{
cwd: root,
timeout: 900_000,
maxBuffer: 8 * 1024 * 1024,
},
);
const jsonLine = stdout
.trim()
.split(/\r?\n/)
.reverse()
.find((line) => line.trim().startsWith("{"));
if (!jsonLine) {
throw new Error("Robot exporter did not return a JSON result.");
}
const worker = JSON.parse(jsonLine) as Record<string, unknown>;
const packagePath = relativeArtifact(root, String(worker.package || ""));
const descriptionPath = relativeArtifact(
root,
String(worker.description || ""),
);
const meshPath = relativeArtifact(root, String(worker.mesh || ""));
const warnings = Array.isArray(worker.warnings)
? worker.warnings.map(String)
: [];
const state: ExportState = {
schema_version: "1.0",
source_sha256: sourceSha256,
format,
package_path: packagePath,
description_path: descriptionPath,
mesh_path: meshPath,
warnings,
};
await fs.writeFile(
statePath,
`${JSON.stringify(state, null, 2)}\n`,
"utf8",
);
const existingArtifacts = manifest.artifacts || [];
const nextArtifacts = existingArtifacts.filter(
(artifact) => artifact.role !== `${format}_package`
&& artifact.role !== `${format}_description`
&& artifact.role !== `${format}_mesh`,
);
nextArtifacts.push(
{ path: packagePath, role: `${format}_package`, kind: "zip" },
{
path: descriptionPath,
role: `${format}_description`,
kind: format,
},
{ path: meshPath, role: `${format}_mesh`, kind: "stl" },
);
await upsertManifest(taskId, {
artifacts: nextArtifacts,
studio: {
...(manifest.studio || {}),
robotExports: {
...(
manifest.studio?.robotExports
&& typeof manifest.studio.robotExports === "object"
? manifest.studio.robotExports
: {}
),
[format]: state,
},
},
});
return resultFromState(taskId, state, "generated");
}