优化skill,添加特征树、编辑窗口
This commit is contained in:
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
Generated
+17
-110
@@ -12,6 +12,9 @@
|
||||
"@ai-sdk/openai-compatible": "3.0.14",
|
||||
"@assistant-ui/react": "0.14.28",
|
||||
"@assistant-ui/react-ai-sdk": "1.4.0",
|
||||
"@radix-ui/react-collapsible": "1.1.20",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.24",
|
||||
"@radix-ui/react-slider": "1.4.7",
|
||||
"ai": "7.0.37",
|
||||
"animejs": "^4.5.0",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -267,6 +270,7 @@
|
||||
"resolved": "https://registry.npmmirror.com/@assistant-ui/store/-/store-0.2.21.tgz",
|
||||
"integrity": "sha512-on2xIqLRsMij1J9WXBjKHW2XeVckypicXo7z3Fam6C9DVThZvWIevwJl962uargqq8DKNQxMySQ6Z7Z83+I5nA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"use-effect-event": "^2.0.3"
|
||||
},
|
||||
@@ -286,6 +290,7 @@
|
||||
"resolved": "https://registry.npmmirror.com/@assistant-ui/tap/-/tap-0.9.5.tgz",
|
||||
"integrity": "sha512-xmj8pbZD3QX6VB1kSq1WGIhZOpaSUbD1HuAtYDJFN/oj/Oae9uEs7c5MeoGUrlB7rytxVXKovsqh9hxtNt4u2g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^18 || ^19"
|
||||
@@ -305,10 +310,11 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
|
||||
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -888,9 +894,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -907,9 +910,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -926,9 +926,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -945,9 +942,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -964,9 +958,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -983,9 +974,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1002,9 +990,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1021,9 +1006,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1040,9 +1022,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1065,9 +1044,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1090,9 +1066,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1115,9 +1088,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1140,9 +1110,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1165,9 +1132,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1190,9 +1154,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1215,9 +1176,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1404,9 +1362,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1423,9 +1378,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1442,9 +1394,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1461,9 +1410,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3149,9 +3095,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3169,9 +3112,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3189,9 +3129,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3209,9 +3146,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3251,27 +3185,6 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"dev": true,
|
||||
@@ -3381,6 +3294,7 @@
|
||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -3391,6 +3305,7 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -3833,9 +3748,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3857,9 +3769,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3881,9 +3790,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3905,9 +3811,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4244,6 +4147,7 @@
|
||||
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -4253,6 +4157,7 @@
|
||||
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -4512,7 +4417,8 @@
|
||||
"version": "0.160.0",
|
||||
"resolved": "https://registry.npmmirror.com/three/-/three-0.160.0.tgz",
|
||||
"integrity": "sha512-DLU8lc0zNIPkM7rH5/e1Ks1Z8tWCGRq6g8mPowdDJpw1CFBJMU7UoJjC6PefXW7z//SSl0b2+GCw14LB+uDhng==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/three-mesh-bvh": {
|
||||
"version": "0.8.3",
|
||||
@@ -4707,6 +4613,7 @@
|
||||
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"@ai-sdk/openai-compatible": "3.0.14",
|
||||
"@assistant-ui/react": "0.14.28",
|
||||
"@assistant-ui/react-ai-sdk": "1.4.0",
|
||||
"@radix-ui/react-collapsible": "1.1.20",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.24",
|
||||
"@radix-ui/react-slider": "1.4.7",
|
||||
"ai": "7.0.37",
|
||||
"animejs": "^4.5.0",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -13,6 +13,7 @@ export async function POST(request: NextRequest) {
|
||||
provider: body.provider,
|
||||
model: body.model,
|
||||
selectedTaskId: body.selectedTaskId,
|
||||
conversationId: body.conversationId,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { readConversation, updateConversation } from "@/lib/conversation-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(_request: NextRequest, context: { params: Promise<{ conversationId: string }> }) {
|
||||
const { conversationId } = await context.params;
|
||||
try {
|
||||
const conversation = await readConversation(conversationId);
|
||||
if (!conversation) {
|
||||
return NextResponse.json({ error: "Conversation was not found." }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(conversation);
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
error: error instanceof Error ? error.message : "Failed to read conversation.",
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, context: { params: Promise<{ conversationId: string }> }) {
|
||||
const { conversationId } = await context.params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
try {
|
||||
return NextResponse.json(await updateConversation({
|
||||
conversationId,
|
||||
currentTaskId: String(body.currentTaskId || ""),
|
||||
attachments: Array.isArray(body.attachments) ? body.attachments : [],
|
||||
}));
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
error: error instanceof Error ? error.message : "Failed to update conversation.",
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { editDesignIRParameter } from "@/lib/cad-generator";
|
||||
import { updateConversation } from "@/lib/conversation-store";
|
||||
import { readManifest } from "@/lib/task-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function cadResultPayload(result: Awaited<ReturnType<typeof editDesignIRParameter>>) {
|
||||
return {
|
||||
taskId: result.taskId,
|
||||
sourcePath: result.sourcePath,
|
||||
sourceUrl: result.sourceUrl,
|
||||
parameters: result.parameters,
|
||||
editableParameters: result.editableParameters,
|
||||
artifactPath: result.artifactPath,
|
||||
artifactUrl: result.artifactUrl,
|
||||
featureTreePath: result.featureTreePath,
|
||||
featureTreeUrl: result.featureTreeUrl,
|
||||
parameterCatalogPath: result.parameterCatalogPath,
|
||||
parameterCatalogUrl: result.parameterCatalogUrl,
|
||||
previewPath: result.previewPath,
|
||||
previewUrl: result.previewUrl,
|
||||
viewerAssetPath: result.viewerAssetPath,
|
||||
viewerAssetUrl: result.viewerAssetUrl,
|
||||
summary: result.summary,
|
||||
};
|
||||
}
|
||||
|
||||
async function updateConversationTask(conversationId: unknown, taskId: string) {
|
||||
const id = String(conversationId || "").trim();
|
||||
if (!id) return;
|
||||
try {
|
||||
await updateConversation({ conversationId: id, currentTaskId: taskId });
|
||||
} catch (error) {
|
||||
console.error("Failed to update CAD conversation after parameter edit", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ taskId: string }> },
|
||||
) {
|
||||
const { taskId } = await context.params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parameter = String(body.parameter || body.name || "").trim();
|
||||
try {
|
||||
const manifest = await readManifest(taskId);
|
||||
if (!manifest) {
|
||||
return NextResponse.json({ error: "CAD task was not found." }, { status: 404 });
|
||||
}
|
||||
const values = (
|
||||
body.values && typeof body.values === "object" && !Array.isArray(body.values)
|
||||
? Object.entries(body.values as Record<string, unknown>)
|
||||
: []
|
||||
)
|
||||
.map(([name, rawValue]) => [String(name).trim(), Number(rawValue)] as const)
|
||||
.filter(([name, value]) => name && Number.isFinite(value));
|
||||
if (values.length) {
|
||||
let sourceTaskId = taskId;
|
||||
let result: Awaited<ReturnType<typeof editDesignIRParameter>> | null = null;
|
||||
for (const [name, value] of values) {
|
||||
result = await editDesignIRParameter({
|
||||
prompt: `参数 ${name} 恢复为默认值 ${value}`,
|
||||
sourceTaskId,
|
||||
parameter: name,
|
||||
value,
|
||||
});
|
||||
sourceTaskId = result.taskId;
|
||||
}
|
||||
if (!result) {
|
||||
throw new Error("No parameter values were supplied.");
|
||||
}
|
||||
await updateConversationTask(body.conversationId, result.taskId);
|
||||
return NextResponse.json(cadResultPayload(result));
|
||||
}
|
||||
const value = Number(body.value);
|
||||
if (!parameter || !Number.isFinite(value)) {
|
||||
throw new Error("Parameter and finite numeric value are required.");
|
||||
}
|
||||
const result = await editDesignIRParameter({
|
||||
prompt: `参数 ${parameter} 修改为 ${value}`,
|
||||
sourceTaskId: taskId,
|
||||
parameter,
|
||||
value,
|
||||
});
|
||||
await updateConversationTask(body.conversationId, result.taskId);
|
||||
return NextResponse.json(cadResultPayload(result));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error
|
||||
? error.message
|
||||
: "Failed to edit CAD parameter.";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { DragEvent } from "react";
|
||||
import type { CSSProperties, DragEvent } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AssistantRuntimeProvider,
|
||||
@@ -9,24 +9,28 @@ import {
|
||||
MessagePrimitive,
|
||||
ThreadPrimitive,
|
||||
useMessage,
|
||||
useAuiState,
|
||||
type DataMessagePartProps,
|
||||
type ToolCallMessagePartProps,
|
||||
} from "@assistant-ui/react";
|
||||
import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk";
|
||||
import type { UIMessage } from "ai";
|
||||
import { CadViewerPreview } from "@/components/cad-viewer-preview";
|
||||
import { ParameterPanel } from "@/components/parameter-panel";
|
||||
import { AiSelectionMode } from "@/lib/cad-edit-tools";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Bot,
|
||||
Box,
|
||||
Check,
|
||||
CircleAlert,
|
||||
FileImage,
|
||||
FileJson2,
|
||||
FileUp,
|
||||
ChevronsRight,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Paperclip,
|
||||
SlidersHorizontal,
|
||||
Send,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
@@ -62,9 +66,14 @@ type CadResult = {
|
||||
taskId: string;
|
||||
sourcePath?: string;
|
||||
sourceUrl?: string;
|
||||
parameters?: Array<Record<string, unknown>>;
|
||||
editableParameters?: Array<Record<string, unknown>>;
|
||||
artifactPath?: string;
|
||||
artifactUrl?: string;
|
||||
featureTreePath?: string;
|
||||
featureTreeUrl?: string;
|
||||
parameterCatalogPath?: string;
|
||||
parameterCatalogUrl?: string;
|
||||
previewPath?: string;
|
||||
previewUrl?: string;
|
||||
viewerAssetPath?: string;
|
||||
@@ -79,6 +88,19 @@ type CadProgress = {
|
||||
message?: string;
|
||||
};
|
||||
|
||||
function editableParameterRecords(
|
||||
primary?: Array<Record<string, unknown>>,
|
||||
fallback?: Array<Record<string, unknown>>,
|
||||
) {
|
||||
const primaryEditable = Array.isArray(primary)
|
||||
? primary.filter((parameter) => parameter.editable === true)
|
||||
: [];
|
||||
if (primaryEditable.length) return primaryEditable;
|
||||
return Array.isArray(fallback)
|
||||
? fallback.filter((parameter) => parameter.editable === true)
|
||||
: [];
|
||||
}
|
||||
|
||||
type CadError = {
|
||||
stage?: string;
|
||||
message: string;
|
||||
@@ -112,6 +134,21 @@ type PublicConfig = {
|
||||
};
|
||||
};
|
||||
|
||||
type ConversationRecord = {
|
||||
conversationId: string;
|
||||
currentTaskId: string;
|
||||
attachments: Attachment[];
|
||||
messages: UIMessage[];
|
||||
};
|
||||
|
||||
type ConversationBootstrap = {
|
||||
conversationId: string;
|
||||
currentTaskId: string;
|
||||
attachments: Attachment[];
|
||||
messages: UIMessage[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const initialMessages: UIMessage[] = [{
|
||||
id: "welcome",
|
||||
role: "assistant",
|
||||
@@ -121,12 +158,23 @@ const initialMessages: UIMessage[] = [{
|
||||
}],
|
||||
}];
|
||||
|
||||
const DEEPSEEK_PROVIDER_ID = "deepseek";
|
||||
const DEFAULT_PROVIDER_ID = "deepseek";
|
||||
|
||||
function uniqueId(prefix: string) {
|
||||
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function newConversationId() {
|
||||
return `conv_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
}
|
||||
|
||||
function isSafeId(value: string, prefix: "cad" | "conv") {
|
||||
const expression = prefix === "cad"
|
||||
? /^[a-zA-Z0-9_-]{4,80}$/
|
||||
: /^conv_[a-zA-Z0-9_-]{4,75}$/;
|
||||
return expression.test(value);
|
||||
}
|
||||
|
||||
function formatBytes(size: number) {
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
@@ -207,6 +255,10 @@ function CadArtifactCard({ data }: DataMessagePartProps<CadResult>) {
|
||||
: [];
|
||||
const sourceDownloadUrl = data.sourceUrl
|
||||
|| taskArtifactUrl(data.taskId, data.sourcePath || "");
|
||||
const featureTreeDownloadUrl = data.featureTreeUrl
|
||||
|| (data.featureTreePath ? taskArtifactUrl(data.taskId, data.featureTreePath) : "");
|
||||
const parameterCatalogDownloadUrl = data.parameterCatalogUrl
|
||||
|| (data.parameterCatalogPath ? taskArtifactUrl(data.taskId, data.parameterCatalogPath) : "");
|
||||
return (
|
||||
<div className="my-2 pl-1 text-[12px]">
|
||||
<div className="flex items-center gap-2 font-medium text-[#dff5f6]">
|
||||
@@ -229,6 +281,16 @@ function CadArtifactCard({ data }: DataMessagePartProps<CadResult>) {
|
||||
下载 DesignIR JSON
|
||||
</a>
|
||||
) : null}
|
||||
{featureTreeDownloadUrl ? (
|
||||
<a className="inline-flex text-[11px] text-[#9cd67a] underline-offset-2 hover:underline" href={featureTreeDownloadUrl} download>
|
||||
下载特征树 JSON
|
||||
</a>
|
||||
) : null}
|
||||
{parameterCatalogDownloadUrl ? (
|
||||
<a className="inline-flex text-[11px] text-[#c6b7ff] underline-offset-2 hover:underline" href={parameterCatalogDownloadUrl} download>
|
||||
下载参数 JSON
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
{editableParameters.length ? (
|
||||
<div className="ml-5 mt-2 rounded border border-[#2d3339] bg-[#15181b] p-2">
|
||||
@@ -384,25 +446,140 @@ function AnyMessage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function ChatRunControl() {
|
||||
const isRunning = useAuiState((state) => state.thread.isRunning);
|
||||
const buttonClassName = "inline-flex h-8 items-center gap-1.5 rounded px-3 text-[12px] font-semibold disabled:cursor-not-allowed disabled:opacity-50";
|
||||
|
||||
if (isRunning) {
|
||||
return (
|
||||
<ComposerPrimitive.Cancel
|
||||
className={cn(
|
||||
buttonClassName,
|
||||
"border border-[#7dc8cf]/45 bg-[#20272a] text-[#cdeff2] hover:bg-[#273135]",
|
||||
)}
|
||||
title="停止生成"
|
||||
aria-label="停止生成"
|
||||
>
|
||||
<Square className="size-3.5 fill-current" />
|
||||
停止
|
||||
</ComposerPrimitive.Cancel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Send
|
||||
className={cn(
|
||||
buttonClassName,
|
||||
"bg-[#7dc8cf] text-[#101315] hover:bg-[#91d4da]",
|
||||
)}
|
||||
title="发送消息"
|
||||
aria-label="发送消息"
|
||||
>
|
||||
<Send className="size-3.5" />
|
||||
发送
|
||||
</ComposerPrimitive.Send>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentStudio() {
|
||||
const [bootstrap, setBootstrap] = useState<ConversationBootstrap | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href);
|
||||
const requestedConversationId = String(url.searchParams.get("conversationId") || "").trim();
|
||||
const requestedTaskId = String(url.searchParams.get("taskId") || "").trim();
|
||||
const conversationId = isSafeId(requestedConversationId, "conv")
|
||||
? requestedConversationId
|
||||
: newConversationId();
|
||||
const currentTaskId = isSafeId(requestedTaskId, "cad") ? requestedTaskId : "";
|
||||
const finalize = (conversation?: ConversationRecord, error?: string) => {
|
||||
const nextTaskId = currentTaskId || String(conversation?.currentTaskId || "");
|
||||
url.searchParams.set("conversationId", conversationId);
|
||||
if (nextTaskId) url.searchParams.set("taskId", nextTaskId);
|
||||
else url.searchParams.delete("taskId");
|
||||
window.history.replaceState({}, "", url);
|
||||
setBootstrap({
|
||||
conversationId,
|
||||
currentTaskId: nextTaskId,
|
||||
attachments: Array.isArray(conversation?.attachments) ? conversation.attachments : [],
|
||||
messages: Array.isArray(conversation?.messages) ? conversation.messages : [],
|
||||
error,
|
||||
});
|
||||
};
|
||||
if (!requestedConversationId) {
|
||||
void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ currentTaskId }),
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(payload.error || "会话创建失败");
|
||||
}
|
||||
finalize(await response.json() as ConversationRecord);
|
||||
})
|
||||
.catch((error) => {
|
||||
finalize(undefined, error instanceof Error ? error.message : "会话创建失败");
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isSafeId(requestedConversationId, "conv")) {
|
||||
finalize(undefined, "会话链接无效,已创建新的会话。");
|
||||
return;
|
||||
}
|
||||
void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`)
|
||||
.then(async (response) => {
|
||||
if (response.status === 404) {
|
||||
finalize(undefined, "会话记录不存在,将在发送下一条消息时创建。");
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(payload.error || "会话加载失败");
|
||||
}
|
||||
finalize(await response.json() as ConversationRecord);
|
||||
})
|
||||
.catch((error) => {
|
||||
finalize(undefined, error instanceof Error ? error.message : "会话加载失败");
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!bootstrap) {
|
||||
return (
|
||||
<main className="flex h-screen items-center justify-center bg-[#111315] text-[12px] text-[#969b9f]">
|
||||
正在恢复 CAD 会话...
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return <AgentStudioSession key={bootstrap.conversationId} bootstrap={bootstrap} />;
|
||||
}
|
||||
|
||||
function AgentStudioSession({ bootstrap }: { bootstrap: ConversationBootstrap }) {
|
||||
const [config, setConfig] = useState<PublicConfig | null>(null);
|
||||
const [provider, setProvider] = useState(DEEPSEEK_PROVIDER_ID);
|
||||
const [provider, setProvider] = useState(DEFAULT_PROVIDER_ID);
|
||||
const [model, setModel] = useState("");
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [attachments, setAttachments] = useState<Attachment[]>(bootstrap.attachments);
|
||||
const [pendingViewerContexts, setPendingViewerContexts] = useState<ViewerContext[]>([]);
|
||||
const [currentTaskId, setCurrentTaskId] = useState("");
|
||||
const [currentTaskId, setCurrentTaskId] = useState(bootstrap.currentTaskId);
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [artifactUrl, setArtifactUrl] = useState("");
|
||||
const [sourceUrl, setSourceUrl] = useState("");
|
||||
const [featureTreeUrl, setFeatureTreeUrl] = useState("");
|
||||
const [parameterCatalogUrl, setParameterCatalogUrl] = useState("");
|
||||
const [parameterPanelParameters, setParameterPanelParameters] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [editableParameters, setEditableParameters] = useState<Array<Record<string, unknown>>>([]);
|
||||
const [robotExporting, setRobotExporting] = useState<RobotExportFormat | "">("");
|
||||
const [parameterPanelOpen, setParameterPanelOpen] = useState(true);
|
||||
const [parameterEditPending, setParameterEditPending] = useState("");
|
||||
const [parameterEditError, setParameterEditError] = useState("");
|
||||
const [artifactPath, setArtifactPath] = useState("");
|
||||
const [viewerAssetUrl, setViewerAssetUrl] = useState("");
|
||||
const [activeEditToolId, setActiveEditToolId] = useState("");
|
||||
const [aiSelectionMode, setAiSelectionMode] = useState<AiSelectionMode>("point");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [draggingUpload, setDraggingUpload] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [error, setError] = useState(bootstrap.error || "");
|
||||
const dragDepthRef = useRef(0);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -418,13 +595,22 @@ export function AgentStudio() {
|
||||
result.sourcePath || "",
|
||||
),
|
||||
);
|
||||
setIfChanged(
|
||||
setFeatureTreeUrl,
|
||||
result.featureTreeUrl
|
||||
|| (result.featureTreePath ? taskArtifactUrl(result.taskId, result.featureTreePath) : ""),
|
||||
);
|
||||
setIfChanged(
|
||||
setParameterCatalogUrl,
|
||||
result.parameterCatalogUrl
|
||||
|| (result.parameterCatalogPath ? taskArtifactUrl(result.taskId, result.parameterCatalogPath) : ""),
|
||||
);
|
||||
setIfChanged(setPreviewUrl, result.previewUrl || "");
|
||||
setIfChanged(setViewerAssetUrl, result.viewerAssetUrl || "");
|
||||
setEditableParameters(
|
||||
Array.isArray(result.editableParameters)
|
||||
? result.editableParameters
|
||||
: [],
|
||||
);
|
||||
const nextEditableParameters = editableParameterRecords(result.editableParameters, result.parameters);
|
||||
setEditableParameters(nextEditableParameters);
|
||||
setParameterPanelParameters(nextEditableParameters);
|
||||
setParameterEditError("");
|
||||
setActiveEditToolId("");
|
||||
setAiSelectionMode("point");
|
||||
}, []);
|
||||
@@ -448,44 +634,79 @@ export function AgentStudio() {
|
||||
}
|
||||
}, [applyCadResult]);
|
||||
|
||||
const handleRobotExport = useCallback(async (format: RobotExportFormat) => {
|
||||
if (!currentTaskId || robotExporting) return;
|
||||
const handleParameterCommit = useCallback(async (parameter: string, value: number) => {
|
||||
if (!currentTaskId || parameterEditPending) return;
|
||||
setError("");
|
||||
setRobotExporting(format);
|
||||
setParameterEditError("");
|
||||
setParameterEditPending(parameter);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/tasks/${encodeURIComponent(currentTaskId)}/exports/${format}`,
|
||||
{ method: "POST" },
|
||||
`/api/tasks/${encodeURIComponent(currentTaskId)}/parameters`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ parameter, value, conversationId: bootstrap.conversationId }),
|
||||
},
|
||||
);
|
||||
const result = await response.json() as RobotExportResult & { error?: string };
|
||||
if (!response.ok || !result.packageUrl) {
|
||||
throw new Error(result.error || `${format.toUpperCase()} 转换失败`);
|
||||
const result = await response.json() as CadResult & { error?: string };
|
||||
if (!response.ok || !result.taskId) {
|
||||
throw new Error(result.error || "参数修改失败");
|
||||
}
|
||||
triggerArtifactDownload(result.packageUrl);
|
||||
applyCadResult(result);
|
||||
setPendingViewerContexts([]);
|
||||
} catch (nextError) {
|
||||
setError(
|
||||
nextError instanceof Error
|
||||
? nextError.message
|
||||
: `${format.toUpperCase()} 转换失败`,
|
||||
);
|
||||
const message = nextError instanceof Error ? nextError.message : "参数修改失败";
|
||||
setParameterEditError(message);
|
||||
setError(message);
|
||||
} finally {
|
||||
setRobotExporting("");
|
||||
setParameterEditPending("");
|
||||
}
|
||||
}, [currentTaskId, robotExporting]);
|
||||
}, [applyCadResult, currentTaskId, parameterEditPending]);
|
||||
|
||||
const handleParameterReset = useCallback(async (values: Record<string, number>) => {
|
||||
if (!currentTaskId || parameterEditPending || !Object.keys(values).length) return;
|
||||
setError("");
|
||||
setParameterEditError("");
|
||||
setParameterEditPending("__reset__");
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/tasks/${encodeURIComponent(currentTaskId)}/parameters`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ values, conversationId: bootstrap.conversationId }),
|
||||
},
|
||||
);
|
||||
const result = await response.json() as CadResult & { error?: string };
|
||||
if (!response.ok || !result.taskId) {
|
||||
throw new Error(result.error || "恢复默认参数失败");
|
||||
}
|
||||
applyCadResult(result);
|
||||
setPendingViewerContexts([]);
|
||||
} catch (nextError) {
|
||||
const message = nextError instanceof Error ? nextError.message : "恢复默认参数失败";
|
||||
setParameterEditError(message);
|
||||
setError(message);
|
||||
} finally {
|
||||
setParameterEditPending("");
|
||||
}
|
||||
}, [applyCadResult, currentTaskId, parameterEditPending]);
|
||||
|
||||
const transport = useMemo(() => new AssistantChatTransport<UIMessage>({
|
||||
api: "/api/chat",
|
||||
body: {
|
||||
provider: DEEPSEEK_PROVIDER_ID,
|
||||
provider,
|
||||
model,
|
||||
attachments,
|
||||
viewerContext: pendingViewerContexts,
|
||||
selectedTaskId: currentTaskId,
|
||||
conversationId: bootstrap.conversationId,
|
||||
},
|
||||
}), [attachments, currentTaskId, model, pendingViewerContexts]);
|
||||
}), [attachments, bootstrap.conversationId, currentTaskId, model, pendingViewerContexts, provider]);
|
||||
|
||||
const runtime = useChatRuntime({
|
||||
messages: initialMessages,
|
||||
id: bootstrap.conversationId,
|
||||
messages: bootstrap.messages.length ? bootstrap.messages : initialMessages,
|
||||
transport,
|
||||
onData: handleData,
|
||||
onError: (nextError) => {
|
||||
@@ -498,9 +719,12 @@ export function AgentStudio() {
|
||||
.then((response) => response.json())
|
||||
.then((nextConfig: PublicConfig) => {
|
||||
setConfig(nextConfig);
|
||||
setProvider(DEEPSEEK_PROVIDER_ID);
|
||||
const deepseekProvider = nextConfig.providers[DEEPSEEK_PROVIDER_ID];
|
||||
setModel(deepseekProvider?.models?.default || "");
|
||||
const defaultProvider = nextConfig.providers[DEFAULT_PROVIDER_ID]
|
||||
? DEFAULT_PROVIDER_ID
|
||||
: nextConfig.defaultProvider;
|
||||
setProvider(defaultProvider);
|
||||
const providerConfig = nextConfig.providers[defaultProvider];
|
||||
setModel(providerConfig?.models?.default || "");
|
||||
})
|
||||
.catch((nextError) => {
|
||||
setError(nextError instanceof Error ? nextError.message : "配置加载失败");
|
||||
@@ -508,27 +732,38 @@ 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);
|
||||
const storedPanelState = window.localStorage.getItem("cad-agent-studio.parameters-panel-open");
|
||||
if (storedPanelState === "0") {
|
||||
setParameterPanelOpen(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(
|
||||
"cad-agent-studio.parameters-panel-open",
|
||||
parameterPanelOpen ? "1" : "0",
|
||||
);
|
||||
}, [parameterPanelOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTaskId) return;
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.get("conversationId") !== bootstrap.conversationId) {
|
||||
url.searchParams.set("conversationId", bootstrap.conversationId);
|
||||
}
|
||||
if (url.searchParams.get("taskId") !== currentTaskId) {
|
||||
url.searchParams.set("taskId", currentTaskId);
|
||||
window.history.replaceState({}, "", url);
|
||||
}
|
||||
}, [currentTaskId]);
|
||||
window.history.replaceState({}, "", url);
|
||||
}, [bootstrap.conversationId, currentTaskId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTaskId) {
|
||||
setEditableParameters([]);
|
||||
setParameterPanelParameters([]);
|
||||
setSourceUrl("");
|
||||
setFeatureTreeUrl("");
|
||||
setParameterCatalogUrl("");
|
||||
return;
|
||||
}
|
||||
void fetch(`/api/tasks/${encodeURIComponent(currentTaskId)}`)
|
||||
@@ -540,9 +775,17 @@ export function AgentStudio() {
|
||||
: {}
|
||||
) as Record<string, any>;
|
||||
const sourcePath = String(manifest.source?.path || "");
|
||||
if (sourcePath) {
|
||||
setSourceUrl(taskArtifactUrl(currentTaskId, sourcePath));
|
||||
}
|
||||
setSourceUrl(sourcePath ? taskArtifactUrl(currentTaskId, sourcePath) : "");
|
||||
const artifacts = Array.isArray(task?.artifacts) ? task.artifacts : [];
|
||||
const featureTreeArtifact = artifacts.find((artifact: Record<string, unknown>) => (
|
||||
artifact.role === "feature_tree" && typeof artifact.url === "string"
|
||||
));
|
||||
setFeatureTreeUrl(featureTreeArtifact?.url ? String(featureTreeArtifact.url) : "");
|
||||
const parameterArtifact = artifacts.find((artifact: Record<string, unknown>) => (
|
||||
(artifact.role === "editable_parameters" || artifact.role === "validated_parameters")
|
||||
&& typeof artifact.url === "string"
|
||||
));
|
||||
setParameterCatalogUrl(parameterArtifact?.url ? String(parameterArtifact.url) : "");
|
||||
const latestArtifact = task?.latestArtifact;
|
||||
if (latestArtifact?.path && latestArtifact?.url) {
|
||||
setArtifactPath(String(latestArtifact.path));
|
||||
@@ -553,11 +796,9 @@ export function AgentStudio() {
|
||||
if (latestViewerAsset?.url) {
|
||||
setViewerAssetUrl(String(latestViewerAsset.url));
|
||||
}
|
||||
setEditableParameters(
|
||||
Array.isArray(manifest.parameters?.parameters)
|
||||
? manifest.parameters.parameters
|
||||
: [],
|
||||
);
|
||||
const nextEditableParameters = editableParameterRecords(manifest.parameters?.parameters);
|
||||
setEditableParameters(nextEditableParameters);
|
||||
setParameterPanelParameters(nextEditableParameters);
|
||||
})
|
||||
.catch(() => {
|
||||
// The generation result still supplies the artifact links when task
|
||||
@@ -566,9 +807,27 @@ export function AgentStudio() {
|
||||
}, [currentTaskId]);
|
||||
|
||||
const providerModels = useMemo(() => {
|
||||
const models = config?.providers?.[DEEPSEEK_PROVIDER_ID]?.models || {};
|
||||
const models = config?.providers?.[provider]?.models || {};
|
||||
return Object.entries(models);
|
||||
}, [config]);
|
||||
}, [config, provider]);
|
||||
|
||||
const parameterPanelDownloads = useMemo(() => [
|
||||
...(artifactUrl ? [{ label: "STEP", description: "CAD 交换文件", url: artifactUrl }] : []),
|
||||
...(sourceUrl ? [{ label: "JSON", description: "DesignIR 源文件", url: sourceUrl }] : []),
|
||||
...(parameterCatalogUrl ? [{ label: "PARAMS", description: "参数契约", url: parameterCatalogUrl }] : []),
|
||||
...(featureTreeUrl ? [{ label: "TREE", description: "特征树", url: featureTreeUrl }] : []),
|
||||
], [artifactUrl, featureTreeUrl, parameterCatalogUrl, sourceUrl]);
|
||||
const hasModelPanel = Boolean(
|
||||
currentTaskId
|
||||
&& (
|
||||
viewerAssetUrl
|
||||
|| artifactUrl
|
||||
|| sourceUrl
|
||||
|| parameterCatalogUrl
|
||||
|| featureTreeUrl
|
||||
|| parameterPanelParameters.length
|
||||
),
|
||||
);
|
||||
|
||||
const pushViewerContext = useCallback((type: string, payload: Record<string, unknown>) => {
|
||||
if (!currentTaskId) {
|
||||
@@ -615,6 +874,8 @@ export function AgentStudio() {
|
||||
if (!files?.length) return;
|
||||
setUploading(true);
|
||||
setError("");
|
||||
const uploaded: Attachment[] = [];
|
||||
let nextTaskId = currentTaskId;
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
const form = new FormData();
|
||||
@@ -631,10 +892,11 @@ export function AgentStudio() {
|
||||
if (!response.ok) {
|
||||
throw new Error(attachment.error || `${file.name} 上传失败`);
|
||||
}
|
||||
setAttachments((current) => [attachment, ...current]);
|
||||
uploaded.push(attachment);
|
||||
if (attachment.kind === "step") {
|
||||
continue;
|
||||
}
|
||||
nextTaskId = attachment.taskId;
|
||||
setCurrentTaskId(attachment.taskId);
|
||||
if (attachment.artifactUrl) {
|
||||
setArtifactUrl(attachment.artifactUrl);
|
||||
@@ -650,6 +912,17 @@ export function AgentStudio() {
|
||||
setError(`STEP 已上传,但预览网格生成失败:${attachment.previewError}`);
|
||||
}
|
||||
}
|
||||
if (uploaded.length) {
|
||||
setAttachments((current) => {
|
||||
const next = [...uploaded.reverse(), ...current];
|
||||
void fetch(`/api/conversations/${encodeURIComponent(bootstrap.conversationId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ currentTaskId: nextTaskId, attachments: next }),
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : "上传失败");
|
||||
} finally {
|
||||
@@ -658,7 +931,7 @@ export function AgentStudio() {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
}, [currentTaskId]);
|
||||
}, [bootstrap.conversationId, currentTaskId]);
|
||||
|
||||
const handleDragEnter = useCallback((event: DragEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -697,7 +970,7 @@ export function AgentStudio() {
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<main className="flex h-screen min-h-0 flex-col bg-[#111315] text-[#e8e8e3]">
|
||||
<main className="flex h-screen min-h-0 flex-col overflow-x-hidden bg-[#111315] text-[#e8e8e3]">
|
||||
<header className="flex h-12 shrink-0 items-center justify-between border-b border-[#2d3339] bg-[#15181b] px-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Box className="size-4 text-[#7dc8cf]" aria-hidden="true" />
|
||||
@@ -711,7 +984,7 @@ export function AgentStudio() {
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 items-center gap-1.5 rounded border border-[#2d3339] bg-[#1e2226] px-2 text-[11px] text-[#c9cccf]">
|
||||
<Settings2 className="size-3.5" aria-hidden="true" />
|
||||
<span>DeepSeek</span>
|
||||
<span>{provider === "openai" ? "OpenAI" : provider}</span>
|
||||
</div>
|
||||
<label className="hidden h-8 items-center gap-1.5 rounded border border-[#2d3339] bg-[#1e2226] px-2 text-[11px] text-[#c9cccf] md:flex">
|
||||
<Sparkles className="size-3.5" aria-hidden="true" />
|
||||
@@ -728,9 +1001,14 @@ export function AgentStudio() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[420px_minmax(0,1fr)]">
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col md:flex-row"
|
||||
style={{
|
||||
"--parameter-panel-width": parameterPanelOpen && hasModelPanel ? "460px" : "0px",
|
||||
} as CSSProperties}
|
||||
>
|
||||
<section
|
||||
className="relative flex min-h-0 flex-col border-r border-[#2d3339] bg-[#171a1d]"
|
||||
className="relative flex min-h-0 flex-col border-r border-[#2d3339] bg-[#171a1d] md:w-[420px] md:shrink-0"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -761,13 +1039,6 @@ export function AgentStudio() {
|
||||
|
||||
<ThreadPrimitive.Root className="flex min-h-0 flex-1 flex-col">
|
||||
<ThreadPrimitive.Viewport className="scrollbar-thin min-h-0 flex-1 overflow-y-auto px-3 py-3">
|
||||
{error ? (
|
||||
<div className="mb-3 flex gap-2 rounded border border-[#7a3438] bg-[#2a171a] p-2 text-[12px] text-[#f2b0b2]">
|
||||
<CircleAlert className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{attachments.length ? (
|
||||
<div className="mb-3 border-b border-[#252b30] pb-2">
|
||||
{attachments.slice(0, 5).map((attachment) => (
|
||||
@@ -805,10 +1076,7 @@ export function AgentStudio() {
|
||||
{uploading ? <Loader2 className="size-3.5 animate-spin" /> : <Paperclip className="size-3.5" />}
|
||||
上传
|
||||
</button>
|
||||
<ComposerPrimitive.Send className="inline-flex h-8 items-center gap-1.5 rounded bg-[#7dc8cf] px-3 text-[12px] font-semibold text-[#101315] hover:bg-[#91d4da] disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<Send className="size-3.5" />
|
||||
发送
|
||||
</ComposerPrimitive.Send>
|
||||
<ChatRunControl />
|
||||
</div>
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
@@ -816,7 +1084,7 @@ export function AgentStudio() {
|
||||
</ThreadPrimitive.Root>
|
||||
</section>
|
||||
|
||||
<section className="relative min-h-[42vh] bg-[#0d0f11] md:min-h-0">
|
||||
<section className="relative min-h-[42vh] min-w-0 flex-1 bg-[#0d0f11] md:min-h-0">
|
||||
{viewerAssetUrl ? (
|
||||
<CadViewerPreview
|
||||
taskId={currentTaskId}
|
||||
@@ -824,6 +1092,7 @@ export function AgentStudio() {
|
||||
viewerAssetUrl={viewerAssetUrl}
|
||||
activeEditToolId={activeEditToolId}
|
||||
aiSelectionMode={aiSelectionMode}
|
||||
isParameterUpdating={Boolean(parameterEditPending)}
|
||||
onSelectTool={setActiveEditToolId}
|
||||
onSelectionModeChange={setAiSelectionMode}
|
||||
onEditIntent={handleEditIntent}
|
||||
@@ -840,89 +1109,51 @@ export function AgentStudio() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
{hasModelPanel ? (
|
||||
<button
|
||||
className="absolute right-4 top-4 z-50 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] md:hidden"
|
||||
onClick={() => setParameterPanelOpen((current) => !current)}
|
||||
title={parameterPanelOpen ? "隐藏参数面板" : "显示参数面板"}
|
||||
type="button"
|
||||
>
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
{parameterPanelOpen ? "HIDE" : "PARAM"}
|
||||
</button>
|
||||
) : 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>
|
||||
{hasModelPanel && !parameterPanelOpen ? (
|
||||
<button
|
||||
aria-label="显示参数面板"
|
||||
className="absolute right-0 top-1/2 z-50 hidden h-[140px] w-9 -translate-y-1/2 flex-col items-center rounded-l-lg border border-r-0 border-gray-200/20 bg-[#17191b] px-1.5 py-2 text-[#f2f4f4] shadow-lg transition-colors hover:bg-[#101316] md:flex"
|
||||
onClick={() => setParameterPanelOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronsRight className="mb-3 size-5 rotate-180 text-white" />
|
||||
<span className="min-w-[100px] -rotate-90 text-center text-base font-semibold text-white">
|
||||
Parameters
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
{hasModelPanel ? (
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 w-full transform-gpu overflow-hidden transition-[opacity,transform,width] duration-300 ease-out will-change-[width,transform,opacity] md:w-[var(--parameter-panel-width)] md:shrink-0",
|
||||
parameterPanelOpen
|
||||
? "translate-x-0 opacity-100"
|
||||
: "translate-x-full opacity-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<ParameterPanel
|
||||
downloads={parameterPanelDownloads}
|
||||
error={parameterEditError}
|
||||
parameters={parameterPanelParameters}
|
||||
pendingParameter={parameterEditPending}
|
||||
onClose={() => setParameterPanelOpen(false)}
|
||||
onCommit={handleParameterCommit}
|
||||
onReset={handleParameterReset}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
</AssistantRuntimeProvider>
|
||||
|
||||
@@ -638,6 +638,7 @@ export function CadViewerPreview({
|
||||
viewerAssetUrl,
|
||||
activeEditToolId,
|
||||
aiSelectionMode,
|
||||
isParameterUpdating = false,
|
||||
onSelectTool,
|
||||
onSelectionModeChange,
|
||||
onEditIntent,
|
||||
@@ -648,6 +649,7 @@ export function CadViewerPreview({
|
||||
viewerAssetUrl: string;
|
||||
activeEditToolId: string;
|
||||
aiSelectionMode: AiSelectionMode;
|
||||
isParameterUpdating?: boolean;
|
||||
onSelectTool: (toolId: string) => void;
|
||||
onSelectionModeChange: (mode: AiSelectionMode) => void;
|
||||
onEditIntent: (payload: Record<string, unknown>) => void;
|
||||
@@ -955,11 +957,11 @@ export function CadViewerPreview({
|
||||
onHoverReferenceChange={handleHoverReferenceChange}
|
||||
onActivateReference={handleActivateReference}
|
||||
/>
|
||||
{loadState.isRefreshing ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 grid place-items-center bg-[#0d0f11]/35">
|
||||
{isParameterUpdating || loadState.isRefreshing ? (
|
||||
<div className="absolute inset-0 z-20 grid place-items-center bg-[#0d0f11]/35" aria-hidden="true">
|
||||
<div className="flex items-center gap-2 rounded border border-[#2d3339] bg-[#15181b]/95 px-3 py-2 text-[12px] text-[#c9cccf]">
|
||||
<Loader2 className="size-4 animate-spin text-[#7dc8cf]" />
|
||||
加载 CAD Viewer 资产...
|
||||
{isParameterUpdating ? "正在重建参数化模型..." : "加载 CAD Viewer 资产..."}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import { ChevronDown, ChevronUp, Download, Loader2, RefreshCcw, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CadamSlider } from "@/components/ui/cadam-slider";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type EditableParameter = Record<string, unknown>;
|
||||
|
||||
type NormalizedParameter = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
group: string;
|
||||
groupDisplayName: string;
|
||||
value: number;
|
||||
defaultValue: number;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
step: number;
|
||||
precision: number;
|
||||
unit: string;
|
||||
editable: boolean;
|
||||
editState: string;
|
||||
};
|
||||
|
||||
type DownloadFormat = {
|
||||
label: string;
|
||||
description: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
function numberValue(value: unknown) {
|
||||
const numeric = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
function parameterToView(parameter: EditableParameter): NormalizedParameter | null {
|
||||
const name = String(parameter.name || parameter.id || "").trim();
|
||||
const value = numberValue(parameter.value);
|
||||
const declaredRange = Array.isArray(parameter.range) ? parameter.range : [];
|
||||
const min = numberValue(parameter.min ?? declaredRange[0]);
|
||||
const max = numberValue(parameter.max ?? declaredRange[1]);
|
||||
if (!name || value === null) return null;
|
||||
return {
|
||||
name,
|
||||
displayName: String(parameter.display_name || parameter.displayName || parameter.label || name),
|
||||
group: String(parameter.group || "dimensions"),
|
||||
groupDisplayName: String(parameter.group_display_name || parameter.groupDisplayName || "尺寸"),
|
||||
value,
|
||||
defaultValue: numberValue(parameter.default_value ?? parameter.defaultValue) ?? value,
|
||||
min,
|
||||
max,
|
||||
step: numberValue(parameter.step) ?? 1,
|
||||
precision: Math.max(0, numberValue(parameter.precision) ?? 2),
|
||||
unit: String(parameter.unit || ""),
|
||||
editable: parameter.editable === true || parameter.edit_state === "declared_unvalidated",
|
||||
editState: String(parameter.edit_state || "unvalidated"),
|
||||
};
|
||||
}
|
||||
|
||||
function formatValue(value: number, precision: number) {
|
||||
if (!Number.isFinite(value)) return "";
|
||||
if (Number.isInteger(value) && precision === 0) return String(value);
|
||||
return Number(value.toFixed(precision)).toString();
|
||||
}
|
||||
|
||||
function clampValue(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function visualRange(parameter: NormalizedParameter) {
|
||||
if (parameter.min !== null && parameter.max !== null && parameter.max > parameter.min) {
|
||||
return { min: parameter.min, max: parameter.max };
|
||||
}
|
||||
const span = Math.max(Math.abs(parameter.value), 1);
|
||||
return {
|
||||
min: Math.max(0, parameter.value - span),
|
||||
max: parameter.value + span,
|
||||
};
|
||||
}
|
||||
|
||||
function sectionDisplayName(group: { id: string; displayName: string }) {
|
||||
return group.id === "dimensions" || group.displayName === "尺寸"
|
||||
? "Dimensions"
|
||||
: group.displayName;
|
||||
}
|
||||
|
||||
export function ParameterPanel({
|
||||
parameters,
|
||||
downloads = [],
|
||||
pendingParameter,
|
||||
error,
|
||||
onClose,
|
||||
onCommit,
|
||||
onReset,
|
||||
}: {
|
||||
parameters: EditableParameter[];
|
||||
downloads?: DownloadFormat[];
|
||||
pendingParameter?: string;
|
||||
error?: string;
|
||||
onClose: () => void;
|
||||
onCommit: (parameter: string, value: number) => void;
|
||||
onReset: (values: Record<string, number>) => void;
|
||||
}) {
|
||||
const normalizedParameters = useMemo(
|
||||
() => parameters.map(parameterToView).filter((value): value is NormalizedParameter => Boolean(value)),
|
||||
[parameters],
|
||||
);
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
|
||||
const [selectedDownload, setSelectedDownload] = useState(downloads[0]?.label || "");
|
||||
|
||||
useEffect(() => {
|
||||
setDrafts(Object.fromEntries(
|
||||
normalizedParameters.map((parameter) => [
|
||||
parameter.name,
|
||||
formatValue(parameter.value, parameter.precision),
|
||||
]),
|
||||
));
|
||||
setOpenGroups((current) => {
|
||||
const next = { ...current };
|
||||
for (const parameter of normalizedParameters) {
|
||||
if (next[parameter.group] === undefined) next[parameter.group] = true;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [normalizedParameters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!downloads.some((download) => download.label === selectedDownload)) {
|
||||
setSelectedDownload(downloads[0]?.label || "");
|
||||
}
|
||||
}, [downloads, selectedDownload]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const groups = new Map<string, { id: string; displayName: string; parameters: NormalizedParameter[] }>();
|
||||
for (const parameter of normalizedParameters) {
|
||||
const group = groups.get(parameter.group) || {
|
||||
id: parameter.group,
|
||||
displayName: parameter.groupDisplayName,
|
||||
parameters: [],
|
||||
};
|
||||
group.parameters.push(parameter);
|
||||
groups.set(parameter.group, group);
|
||||
}
|
||||
return Array.from(groups.values());
|
||||
}, [normalizedParameters]);
|
||||
|
||||
const resetParameters = () => {
|
||||
const values = Object.fromEntries(
|
||||
normalizedParameters
|
||||
.filter((parameter) => parameter.editable && parameter.value !== parameter.defaultValue)
|
||||
.map((parameter) => [parameter.name, parameter.defaultValue]),
|
||||
);
|
||||
if (Object.keys(values).length) {
|
||||
onReset(values);
|
||||
return;
|
||||
}
|
||||
setDrafts(Object.fromEntries(
|
||||
normalizedParameters.map((parameter) => [
|
||||
parameter.name,
|
||||
formatValue(parameter.defaultValue, parameter.precision),
|
||||
]),
|
||||
));
|
||||
};
|
||||
|
||||
const selectedDownloadItem = downloads.find((download) => download.label === selectedDownload) || downloads[0] || null;
|
||||
|
||||
const commitValue = (parameter: NormalizedParameter, rawValue: string | number) => {
|
||||
const numeric = numberValue(rawValue);
|
||||
if (!parameter.editable) {
|
||||
setDrafts((current) => ({
|
||||
...current,
|
||||
[parameter.name]: formatValue(parameter.value, parameter.precision),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (numeric === null) {
|
||||
setDrafts((current) => ({
|
||||
...current,
|
||||
[parameter.name]: formatValue(parameter.value, parameter.precision),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const clamped = parameter.min !== null && parameter.max !== null
|
||||
? clampValue(numeric, parameter.min, parameter.max)
|
||||
: numeric;
|
||||
const formatted = formatValue(clamped, parameter.precision);
|
||||
setDrafts((current) => ({ ...current, [parameter.name]: formatted }));
|
||||
if (Number(formatted) !== parameter.value) {
|
||||
onCommit(parameter.name, Number(formatted));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="flex h-full min-h-0 w-full flex-col overflow-hidden border-l border-gray-200/20 bg-[#1f1f1f] text-[#f2f4f4] shadow-lg shadow-black/25"
|
||||
data-viewer-interaction-overlay="true"
|
||||
role="dialog"
|
||||
aria-label="可编辑参数"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b border-[#3a3a3a] bg-[#1f1f1f] px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-lg font-semibold tracking-tight text-[#f2f4f4]">Parameters</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
aria-label="恢复全部默认参数"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="size-8 rounded-full text-[#f2f4f4] hover:bg-[#2a2a2a]"
|
||||
disabled={Boolean(pendingParameter) || !normalizedParameters.length}
|
||||
onClick={resetParameters}
|
||||
>
|
||||
<RefreshCcw className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="隐藏参数面板"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="size-8 rounded-full text-[#f2f4f4] hover:bg-[#2a2a2a]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto px-6 py-6 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{error ? (
|
||||
<div className="mb-3 rounded border border-[#7e3d42] bg-[#35191c] px-3 py-2 text-[11px] leading-4 text-[#f2b0b2]">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{normalizedParameters.length ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{grouped.map((group) => (
|
||||
<Collapsible.Root
|
||||
key={group.id}
|
||||
open={openGroups[group.id] ?? true}
|
||||
onOpenChange={(open) => setOpenGroups((current) => ({ ...current, [group.id]: open }))}
|
||||
>
|
||||
<Collapsible.Trigger className="group flex w-full items-center justify-between gap-2 rounded-md py-1 text-left text-xs font-semibold text-[#f2f4f4] transition-colors focus:outline-none">
|
||||
<span className="flex items-center gap-2">
|
||||
{sectionDisplayName(group)}
|
||||
<span className="text-[10px] text-[#777e84]">{group.parameters.length}</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3.5 text-[#777e84] transition-all duration-200 group-hover:text-[#f2f4f4]",
|
||||
openGroups[group.id] !== false && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content className="mt-3 flex flex-col gap-3">
|
||||
{group.parameters.map((parameter) => {
|
||||
const draft = drafts[parameter.name] ?? formatValue(parameter.value, parameter.precision);
|
||||
const numericDraft = numberValue(draft) ?? parameter.value;
|
||||
const disabled = Boolean(pendingParameter) || !parameter.editable;
|
||||
const range = visualRange(parameter);
|
||||
const pending = pendingParameter === parameter.name;
|
||||
return (
|
||||
<div
|
||||
className="grid w-full grid-cols-[80px_minmax(0,1fr)] items-center gap-3"
|
||||
key={parameter.name}
|
||||
>
|
||||
<label
|
||||
className="min-w-0 overflow-hidden text-ellipsis text-xs font-normal leading-4 text-[#b2b5b8]"
|
||||
htmlFor={`parameter-${parameter.name}`}
|
||||
title={parameter.displayName}
|
||||
>
|
||||
<span className="block truncate">{parameter.displayName}</span>
|
||||
</label>
|
||||
<div className="flex w-full min-w-0 items-center gap-3">
|
||||
<CadamSlider
|
||||
id={`${parameter.name}-slider`}
|
||||
name={parameter.name}
|
||||
min={range.min}
|
||||
max={range.max}
|
||||
step={parameter.step}
|
||||
value={[clampValue(numericDraft, range.min, range.max)]}
|
||||
defaultValue={[clampValue(parameter.defaultValue, range.min, range.max)]}
|
||||
disabled={disabled}
|
||||
visualOnly={false}
|
||||
defaultMarkerStyle="line"
|
||||
onValueChange={([nextValue]) => {
|
||||
setDrafts((current) => ({
|
||||
...current,
|
||||
[parameter.name]: formatValue(nextValue, parameter.precision),
|
||||
}));
|
||||
}}
|
||||
onValueCommit={([nextValue]) => commitValue(parameter, nextValue)}
|
||||
/>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={`parameter-${parameter.name}`}
|
||||
autoComplete="off"
|
||||
className="h-6 w-14 rounded-lg border-0 bg-[#26282b] px-2 pr-2 text-left text-xs text-[#f2f4f4] transition-colors selection:bg-[#7dc8cf]/50 selection:text-white focus-visible:ring-0 hover:bg-[#33363a]"
|
||||
disabled={disabled}
|
||||
inputMode="decimal"
|
||||
max={range.max}
|
||||
min={range.min}
|
||||
step={parameter.step}
|
||||
type="number"
|
||||
value={draft}
|
||||
onBlur={() => commitValue(parameter, draft)}
|
||||
onChange={(event) => setDrafts((current) => ({
|
||||
...current,
|
||||
[parameter.name]: event.target.value,
|
||||
}))}
|
||||
onFocus={(event) => event.target.select()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
setDrafts((current) => ({
|
||||
...current,
|
||||
[parameter.name]: formatValue(parameter.value, parameter.precision),
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{pending ? (
|
||||
<Loader2 className="absolute right-1.5 top-1.5 size-3 animate-spin text-[#7dc8cf]" />
|
||||
) : null}
|
||||
</div>
|
||||
<span className="ml-1 w-6 text-left text-xs text-[#b2b5b8]">
|
||||
{parameter.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border border-[#3a3a3a] bg-[#26282b] px-3 py-3 text-xs leading-5 text-[#b2b5b8]">
|
||||
当前模型没有可直接编辑的参数。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-4 border-t border-[#3a3a3a] px-6 py-6">
|
||||
<div className="flex">
|
||||
<a
|
||||
aria-disabled={!selectedDownloadItem}
|
||||
className={cn(
|
||||
"inline-flex h-12 flex-1 items-center justify-center rounded-l-lg rounded-r-none bg-[#f2f4f4] text-sm font-semibold text-[#22252a] transition-colors hover:bg-white",
|
||||
!selectedDownloadItem && "pointer-events-none opacity-50",
|
||||
)}
|
||||
href={selectedDownloadItem?.url || "#"}
|
||||
download
|
||||
>
|
||||
<Download className="mr-2 size-4" />
|
||||
{selectedDownloadItem?.label || "STEP"}
|
||||
</a>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Button
|
||||
aria-label="选择下载格式"
|
||||
className="h-12 w-12 rounded-l-none rounded-r-lg border-l border-[#c4c7cb] bg-[#f2f4f4] p-0 text-[#22252a] hover:bg-white"
|
||||
disabled={!downloads.length}
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
className="z-50 w-64 rounded-md border border-[#2d3339] bg-[#26282b] p-1 shadow-md"
|
||||
>
|
||||
{downloads.map((download) => (
|
||||
<DropdownMenu.Item
|
||||
key={download.label}
|
||||
className="flex cursor-pointer items-center rounded px-3 py-2 text-[#f2f4f4] outline-none hover:bg-[#33363a]"
|
||||
onSelect={() => setSelectedDownload(download.label)}
|
||||
>
|
||||
<span className="text-sm">.{download.label}</span>
|
||||
<span className="ml-3 text-xs text-[#f2f4f4]/60">{download.description}</span>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CadamSliderProps = React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
|
||||
defaultMarkerStyle?: "dot" | "line";
|
||||
hideDefaultMarker?: boolean;
|
||||
visualOnly?: boolean;
|
||||
};
|
||||
|
||||
function roundToStep(value: number, step: number) {
|
||||
if (step <= 0) return value;
|
||||
const decimals = step >= 1 ? 0 : Math.max(0, -Math.floor(Math.log10(step)));
|
||||
const snapped = Math.round(value / step) * step;
|
||||
return Math.round(snapped * Math.pow(10, decimals)) / Math.pow(10, decimals);
|
||||
}
|
||||
|
||||
const CadamSlider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
CadamSliderProps
|
||||
>(({
|
||||
className,
|
||||
value,
|
||||
defaultValue,
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
disabled,
|
||||
visualOnly = false,
|
||||
onValueChange,
|
||||
onValueCommit,
|
||||
defaultMarkerStyle = "line",
|
||||
hideDefaultMarker = false,
|
||||
...props
|
||||
}, ref) => {
|
||||
const trackRef = React.useRef<HTMLDivElement>(null);
|
||||
const lastValueRef = React.useRef(Array.isArray(value) ? value[0] ?? min : min);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const currentValue = Array.isArray(value) ? value[0] ?? min : min;
|
||||
const defaultVal = Array.isArray(defaultValue) ? defaultValue[0] ?? currentValue : currentValue;
|
||||
const range = Math.max(max - min, 1);
|
||||
const currentPosition = ((currentValue - min) / range) * 100;
|
||||
const defaultPosition = ((defaultVal - min) / range) * 100;
|
||||
|
||||
React.useEffect(() => {
|
||||
lastValueRef.current = currentValue;
|
||||
}, [currentValue]);
|
||||
|
||||
const valueFromPointer = React.useCallback((clientX: number) => {
|
||||
const rect = trackRef.current?.getBoundingClientRect();
|
||||
if (!rect) return currentValue;
|
||||
const ratio = rect.width > 0
|
||||
? (Math.min(Math.max(clientX, rect.left), rect.right) - rect.left) / rect.width
|
||||
: 0;
|
||||
const nextValue = min + ratio * (max - min);
|
||||
return Math.min(Math.max(roundToStep(nextValue, step), min), max);
|
||||
}, [currentValue, max, min, step]);
|
||||
|
||||
const publish = React.useCallback((nextValue: number, commit = false) => {
|
||||
lastValueRef.current = nextValue;
|
||||
onValueChange?.([nextValue]);
|
||||
if (commit) onValueCommit?.([nextValue]);
|
||||
}, [onValueChange, onValueCommit]);
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (disabled || visualOnly) return;
|
||||
setIsDragging(true);
|
||||
(event.currentTarget as Element).setPointerCapture(event.pointerId);
|
||||
publish(valueFromPointer(event.clientX));
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (disabled || visualOnly || !isDragging) return;
|
||||
publish(valueFromPointer(event.clientX));
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (disabled || visualOnly || !isDragging) return;
|
||||
setIsDragging(false);
|
||||
publish(lastValueRef.current, true);
|
||||
(event.currentTarget as Element).releasePointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const handleDefaultMarkerClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (disabled || visualOnly) return;
|
||||
event.stopPropagation();
|
||||
publish(defaultVal, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("group relative flex h-8 w-full touch-none select-none items-center", className)}
|
||||
disabled={disabled}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={[currentValue]}
|
||||
onValueChange={() => {}}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
ref={trackRef}
|
||||
className={cn(
|
||||
"relative h-6 w-full grow cursor-pointer overflow-hidden rounded-lg bg-sky-500/20 transition-all",
|
||||
isDragging && "h-7",
|
||||
(disabled || visualOnly) && "cursor-default",
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={() => setIsDragging(false)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
className={cn(
|
||||
"absolute h-full rounded-l-lg bg-sky-300/20 transition-colors",
|
||||
!disabled && "group-hover:bg-sky-100/50",
|
||||
isDragging && "!bg-sky-200/50",
|
||||
)}
|
||||
style={{ width: `${Math.min(Math.max(currentPosition, 0), 100)}%` }}
|
||||
/>
|
||||
{!hideDefaultMarker && currentValue !== defaultVal ? (
|
||||
defaultMarkerStyle === "dot" ? (
|
||||
<div
|
||||
className="absolute top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-full bg-white/70 shadow-[0_0_8px_rgba(0,0,0,0.5)] transition-all hover:h-2.5 hover:w-2.5 hover:bg-white"
|
||||
style={{ left: `${Math.min(Math.max(defaultPosition, 0), 100)}%` }}
|
||||
onClick={handleDefaultMarkerClick}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="absolute bottom-[2px] top-[2px] w-[2px] -translate-x-1/2 cursor-pointer rounded-full bg-white/45 shadow-[0_0_8px_rgba(0,0,0,0.5)] transition-all hover:w-1 hover:bg-white/80"
|
||||
style={{ left: `${Math.min(Math.max(defaultPosition, 0), 100)}%` }}
|
||||
onClick={handleDefaultMarkerClick}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="hidden" />
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
});
|
||||
|
||||
CadamSlider.displayName = "CadamSlider";
|
||||
|
||||
export { CadamSlider };
|
||||
@@ -0,0 +1,366 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { build123dCallCandidates, inspectBuild123dApi, readSimpleCadApiDocumentation, readTextToCadDocumentation } from "./chat";
|
||||
import { executeBackendNativeGeneration, parseParameterBlock, replaceParameterInSource } from "./cad-generator";
|
||||
|
||||
const sourcePath = path.join(process.cwd(), "src", "lib", "cad-generator.ts");
|
||||
const chatSourcePath = path.join(process.cwd(), "src", "lib", "chat.ts");
|
||||
|
||||
test("new CAD generation rejects missing backend-native source", async () => {
|
||||
await assert.rejects(
|
||||
() => executeBackendNativeGeneration({
|
||||
request: "生成一个法兰盘",
|
||||
sourceKind: "build123d_python",
|
||||
nativeSource: "",
|
||||
}),
|
||||
/backend-native Python source/,
|
||||
);
|
||||
});
|
||||
|
||||
test("new CAD generation rejects runtime API probe scripts", async () => {
|
||||
await assert.rejects(
|
||||
() => executeBackendNativeGeneration({
|
||||
request: "生成一个齿轮",
|
||||
sourceKind: "simplecadapi_python",
|
||||
nativeSource: [
|
||||
"import inspect",
|
||||
"import simplecadapi as scad",
|
||||
"TOP_SIGS = inspect.signature(scad.model)",
|
||||
"raise RuntimeError(str(TOP_SIGS))",
|
||||
].join("\n"),
|
||||
}),
|
||||
/runtime API\/signature\/geometry probe/,
|
||||
);
|
||||
});
|
||||
|
||||
test("new CAD generation rejects topology diagnostic probe scripts", async () => {
|
||||
await assert.rejects(
|
||||
() => executeBackendNativeGeneration({
|
||||
request: "生成一个带齿根圆角的齿轮",
|
||||
sourceKind: "simplecadapi_python",
|
||||
nativeSource: [
|
||||
'"""Diagnose root fillet failure on factory spur gear."""',
|
||||
"import simplecadapi as sc",
|
||||
"def main():",
|
||||
" msg = []",
|
||||
" msg.append('ROOT_COUNT=0')",
|
||||
" msg.append('TIP_COUNT=0')",
|
||||
" raise RuntimeError(' | '.join(msg))",
|
||||
].join("\n"),
|
||||
}),
|
||||
/runtime API\/signature\/geometry probe/,
|
||||
);
|
||||
});
|
||||
|
||||
test("backend-native generation validates actual artifacts instead of source-text patterns", () => {
|
||||
const source = fs.readFileSync(sourcePath, "utf8");
|
||||
assert.equal(source.includes("validateNativeSourceArtifactContract"), false);
|
||||
assert.equal(source.includes("Backend native source did not produce a non-empty STEP"), true);
|
||||
assert.equal(source.includes("Backend native source did not produce metadata JSON"), true);
|
||||
assert.equal(source.includes("Backend metadata JSON is not readable"), true);
|
||||
assert.equal(source.includes("SimpleCADAPI native source did not produce model JSON"), true);
|
||||
});
|
||||
|
||||
test("cad-generator has no request-keyword model templates in the direct generation path", () => {
|
||||
const source = fs.readFileSync(sourcePath, "utf8");
|
||||
assert.equal(source.includes("build123dParametersForRequest"), false);
|
||||
assert.equal(source.includes("simpleCadParametersForRequest"), false);
|
||||
assert.equal(source.includes("make_bolt_rsolid"), false);
|
||||
assert.equal(source.includes("make_spur_gear_rsolid"), false);
|
||||
assert.equal(source.includes("text.includes(\"螺栓\")"), false);
|
||||
});
|
||||
|
||||
test("direct backend-native generation requires a previously bound CAD Router decision", () => {
|
||||
const source = fs.readFileSync(sourcePath, "utf8");
|
||||
const functionStart = source.indexOf("export async function executeBackendNativeGeneration");
|
||||
const legacyStart = source.indexOf("export async function executeDesignIR");
|
||||
assert.notEqual(functionStart, -1);
|
||||
assert.notEqual(legacyStart, -1);
|
||||
const directGenerationSource = source.slice(functionStart, legacyStart);
|
||||
assert.equal(directGenerationSource.includes("generationMode === \"new_model\" && !routeDecision"), true);
|
||||
assert.equal(directGenerationSource.includes("routeNewCadRequest(request)"), false);
|
||||
assert.equal(directGenerationSource.includes("submittedBackend !== selectedBackend"), true);
|
||||
});
|
||||
|
||||
test("agent uses CAD Router instead of backend capability documentation", () => {
|
||||
const source = fs.readFileSync(chatSourcePath, "utf8");
|
||||
const toolStart = source.indexOf("route_cad_request: tool");
|
||||
const nextToolStart = source.indexOf("inspect_uploaded_file: tool", toolStart);
|
||||
assert.notEqual(toolStart, -1);
|
||||
assert.notEqual(nextToolStart, -1);
|
||||
const toolSource = source.slice(toolStart, nextToolStart);
|
||||
assert.equal(toolSource.includes("routeNewCadRequest"), true);
|
||||
assert.equal(source.includes("read_cad_backend_docs: tool"), false);
|
||||
assert.equal(source.includes("cadBackendCapabilityContext"), false);
|
||||
});
|
||||
|
||||
test("backend-native source execution uses file artifacts instead of stdout JSON", () => {
|
||||
const source = fs.readFileSync(sourcePath, "utf8");
|
||||
const functionStart = source.indexOf("async function runNativeSource");
|
||||
const nextFunctionStart = source.indexOf("async function textToCadAdapter", functionStart);
|
||||
assert.notEqual(functionStart, -1);
|
||||
assert.notEqual(nextFunctionStart, -1);
|
||||
const functionSource = source.slice(functionStart, nextFunctionStart);
|
||||
assert.equal(functionSource.includes("runJson("), false);
|
||||
assert.equal(functionSource.includes("execFileAsync("), true);
|
||||
assert.equal(functionSource.includes("parseOptionalJsonReport(stdout)"), true);
|
||||
assert.equal(functionSource.includes("removeIfExists(stepPath)"), true);
|
||||
assert.equal(functionSource.includes("appendGenerationAttempt"), true);
|
||||
});
|
||||
|
||||
test("internal worker failures retain structured JSON diagnostics", () => {
|
||||
const source = fs.readFileSync(sourcePath, "utf8");
|
||||
const functionStart = source.indexOf("async function runJson");
|
||||
const nextFunctionStart = source.indexOf("export async function routeNewCadRequest", functionStart);
|
||||
assert.notEqual(functionStart, -1);
|
||||
assert.notEqual(nextFunctionStart, -1);
|
||||
const functionSource = source.slice(functionStart, nextFunctionStart);
|
||||
assert.equal(functionSource.includes("parseJsonOutput(stdout)"), true);
|
||||
assert.equal(functionSource.includes("CAD worker failed:"), true);
|
||||
});
|
||||
|
||||
test("editable parameter block parser accepts generated Python-style numeric maps", () => {
|
||||
assert.deepEqual(
|
||||
parseParameterBlock([
|
||||
"{",
|
||||
" '外径': 160,",
|
||||
" \"厚度\": 12,",
|
||||
" hole_count: 6,",
|
||||
"}",
|
||||
].join("\n")),
|
||||
{
|
||||
"外径": 160,
|
||||
"厚度": 12,
|
||||
hole_count: 6,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("editable parameter block parser accepts strict JSON with trailing commas", () => {
|
||||
assert.deepEqual(
|
||||
parseParameterBlock([
|
||||
"{",
|
||||
" \"outer_diameter\": 160,",
|
||||
" \"thickness\": 12,",
|
||||
"}",
|
||||
].join("\n")),
|
||||
{
|
||||
outer_diameter: 160,
|
||||
thickness: 12,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("backend parameter editing accepts native bindings as Python source bindings", () => {
|
||||
const source = fs.readFileSync(sourcePath, "utf8");
|
||||
const taskStoreSource = fs.readFileSync(path.join(process.cwd(), "src", "lib", "task-store.ts"), "utf8");
|
||||
assert.equal(source.includes("\"native\""), true);
|
||||
assert.equal(source.includes("\"parameter\""), true);
|
||||
assert.equal(source.includes("\"native_python\""), true);
|
||||
assert.equal(source.includes("\"source_variable\""), true);
|
||||
assert.equal(taskStoreSource.includes("\"native\""), true);
|
||||
assert.equal(taskStoreSource.includes("\"parameter\""), true);
|
||||
assert.equal(taskStoreSource.includes("\"native_python\""), true);
|
||||
assert.equal(taskStoreSource.includes("\"source_variable\""), true);
|
||||
assert.equal(source.includes("EDITABLE_BACKEND_BINDING_KINDS.has(bindingKind)"), true);
|
||||
});
|
||||
|
||||
test("backend parameter editing updates CAD_AGENT_PARAMETERS value dictionaries", () => {
|
||||
const source = [
|
||||
"CAD_AGENT_PARAMETERS = {",
|
||||
" \"num_bolt_holes\": {\"display_name\": \"螺栓孔数量\", \"value\": 8, \"unit\": \"个\"},",
|
||||
"}",
|
||||
"count = int(CAD_AGENT_PARAMETERS[\"num_bolt_holes\"][\"value\"])",
|
||||
].join("\n");
|
||||
const edited = replaceParameterInSource(source, "num_bolt_holes", 10, {
|
||||
binding_kind: "native_python",
|
||||
parameter_path: "CAD_AGENT_PARAMETERS.num_bolt_holes.value",
|
||||
});
|
||||
assert.match(edited, /"num_bolt_holes": \{"display_name": "螺栓孔数量", "value": 10, "unit": "个"\}/);
|
||||
});
|
||||
|
||||
test("backend parameter editing updates top-level numeric assignments", () => {
|
||||
const source = [
|
||||
"MODULE = 3.0",
|
||||
"TEETH = 30",
|
||||
"FACE_WIDTH = 40.0 # mm",
|
||||
].join("\n");
|
||||
const edited = replaceParameterInSource(source, "face_width", 45, {
|
||||
binding_kind: "native_python",
|
||||
parameter_path: "gear.face_width",
|
||||
});
|
||||
assert.match(edited, /^FACE_WIDTH = 45\.0\s+# mm$/m);
|
||||
});
|
||||
|
||||
test("backend parameter editing accepts source_parameter top-level bindings", () => {
|
||||
const source = [
|
||||
"flange_od = 120.0 # 法兰外径 mm",
|
||||
"bolt_hole_count = 8 # 螺栓孔数量",
|
||||
].join("\n");
|
||||
const edited = replaceParameterInSource(source, "flange_od", 130, {
|
||||
binding_kind: "source_parameter",
|
||||
parameter_path: "flange_od",
|
||||
});
|
||||
assert.match(edited, /^flange_od = 130\.0\s+# 法兰外径 mm$/m);
|
||||
const generatorSource = fs.readFileSync(sourcePath, "utf8");
|
||||
assert.equal(generatorSource.includes("\"source_parameter\""), true);
|
||||
});
|
||||
|
||||
test("backend parameter editing accepts source_variable top-level bindings", () => {
|
||||
const source = [
|
||||
"outer_diameter = 140.0 # 外径 mm",
|
||||
"bolt_count = 8 # 安装孔数量",
|
||||
].join("\n");
|
||||
const edited = replaceParameterInSource(source, "outer_diameter", 150, {
|
||||
binding_kind: "source_variable",
|
||||
parameter_path: "outer_diameter",
|
||||
regenerate_adapter: "regenerate_native_source",
|
||||
});
|
||||
assert.match(edited, /^outer_diameter = 150\.0\s+# 外径 mm$/m);
|
||||
});
|
||||
|
||||
test("new backend-native generation creates a fresh task and records explicit revisions", () => {
|
||||
const source = fs.readFileSync(sourcePath, "utf8");
|
||||
const functionStart = source.indexOf("export async function executeBackendNativeGeneration");
|
||||
const legacyStart = source.indexOf("export async function executeDesignIR");
|
||||
assert.notEqual(functionStart, -1);
|
||||
assert.notEqual(legacyStart, -1);
|
||||
const directGenerationSource = source.slice(functionStart, legacyStart);
|
||||
assert.equal(directGenerationSource.includes("const task = await ensureTask();"), true);
|
||||
assert.equal(directGenerationSource.includes("const task = await ensureTask(selectedTaskId"), false);
|
||||
assert.equal(directGenerationSource.includes("generationMode === \"backend_conversion\""), true);
|
||||
assert.equal(directGenerationSource.includes("Structural edits must keep the current backend"), true);
|
||||
});
|
||||
|
||||
test("generate_cad exposes generationMode and treats stdout JSON as optional", () => {
|
||||
const source = fs.readFileSync(chatSourcePath, "utf8");
|
||||
const toolStart = source.indexOf("generate_cad: tool");
|
||||
assert.notEqual(toolStart, -1);
|
||||
const toolSource = source.slice(toolStart);
|
||||
assert.equal(toolSource.includes("generationMode"), true);
|
||||
assert.equal(toolSource.includes("stdout JSON is optional diagnostic output"), true);
|
||||
assert.equal(toolSource.includes("selectedBackend:"), false);
|
||||
assert.equal(toolSource.includes("capabilityEvidence"), false);
|
||||
});
|
||||
|
||||
test("generate_cad backend failures are returned to the model for retry", () => {
|
||||
const source = fs.readFileSync(chatSourcePath, "utf8");
|
||||
const toolStart = source.indexOf("generate_cad: tool");
|
||||
const nextToolStart = source.indexOf("const formatCurrentModelError", toolStart);
|
||||
assert.notEqual(toolStart, -1);
|
||||
assert.notEqual(nextToolStart, -1);
|
||||
const toolSource = source.slice(toolStart, nextToolStart);
|
||||
assert.equal(toolSource.includes("retryable: true"), true);
|
||||
assert.equal(toolSource.includes("call generate_cad again in the same turn"), true);
|
||||
assert.equal(toolSource.includes("failAgentStream(message);\n throw error;"), false);
|
||||
});
|
||||
|
||||
test("SimpleCADAPI generation reads original docs and preflights source", () => {
|
||||
const generatorSource = fs.readFileSync(sourcePath, "utf8");
|
||||
const chatSource = fs.readFileSync(chatSourcePath, "utf8");
|
||||
assert.equal(generatorSource.includes("simpleCadApiContractScript()"), true);
|
||||
assert.equal(generatorSource.includes("validateSimpleCadApiSource(sourcePath, cwd)"), true);
|
||||
assert.equal(chatSource.includes("read_simplecadapi_docs: tool"), true);
|
||||
assert.equal(chatSource.includes("SIMPLECADAPI_REQUIRED_DOCS"), true);
|
||||
assert.equal(chatSource.includes("read_simplecadapi_api_contract"), false);
|
||||
assert.equal(chatSource.includes("scad.primitives"), false);
|
||||
});
|
||||
|
||||
test("SimpleCADAPI documentation resolves namespace-shaped paths to the original API page", async () => {
|
||||
const result = await readSimpleCadApiDocumentation("ql/value.md");
|
||||
assert.equal(result.ok, true);
|
||||
if (!result.ok) return;
|
||||
assert.equal(result.document, "api/value.md");
|
||||
assert.equal(result.resolvedFrom, "ql/value.md");
|
||||
assert.equal(result.content.includes("simplecadapi.ql.value"), true);
|
||||
});
|
||||
|
||||
test("SimpleCADAPI documentation misses return a tool result instead of throwing", async () => {
|
||||
const result = await readSimpleCadApiDocumentation("ql/not-a-real-page.md");
|
||||
assert.deepEqual(result, {
|
||||
ok: false,
|
||||
document: "ql/not-a-real-page.md",
|
||||
error: "SimpleCADAPI document was not found. Read api/README.md or stdlib/README.md and use its linked canonical page path.",
|
||||
suggestedDocuments: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("text-to-cad build123d guidance is available from the original skill", async () => {
|
||||
const result = await readTextToCadDocumentation("references/build123d-modeling.md");
|
||||
assert.equal(result.ok, true);
|
||||
if (!result.ok) return;
|
||||
assert.equal(result.content.includes("build123d modeling patterns"), true);
|
||||
});
|
||||
|
||||
test("text-to-cad CAD-SkillX references are available through documentation lookup", async () => {
|
||||
const result = await readTextToCadDocumentation("references/cad-skillx/planning/mounting-plate.planning.md");
|
||||
assert.equal(result.ok, true);
|
||||
if (!result.ok) return;
|
||||
assert.equal(result.content.includes("Mounting Plate Planning Skill"), true);
|
||||
});
|
||||
|
||||
test("text-to-cad documentation lookup rejects paths outside CAD-SkillX references", async () => {
|
||||
const result = await readTextToCadDocumentation("references/cad-skillx/../build123d-modeling.md");
|
||||
assert.equal(result.ok, false);
|
||||
});
|
||||
|
||||
test("build123d API lookup returns the active chamfer signature", async () => {
|
||||
const result = await inspectBuild123dApi(["chamfer"]);
|
||||
assert.equal(result.symbols.length, 1);
|
||||
assert.equal(result.symbols[0].ok, true);
|
||||
assert.match(result.symbols[0].signature || "", /^\(objects: .*?, length: float/);
|
||||
});
|
||||
|
||||
test("new build123d generation is bound to the router token and exact API docs", () => {
|
||||
const source = fs.readFileSync(chatSourcePath, "utf8");
|
||||
assert.equal(source.includes("read_text_to_cad_docs: tool"), true);
|
||||
assert.equal(source.includes("read_build123d_api: tool"), true);
|
||||
assert.equal(source.includes("loadRequiredTextToCadDocumentation()"), true);
|
||||
assert.equal(source.includes("Studio-loaded text-to-cad/build123d base documentation"), true);
|
||||
assert.equal(source.includes("build123d generation requires text-to-cad docs"), false);
|
||||
assert.equal(source.includes("routeToken: z.string().optional()"), true);
|
||||
assert.equal(source.includes("routeDecisions.get(String(routeToken || \"\"))"), true);
|
||||
assert.equal(source.includes("build123d_api_preflight"), true);
|
||||
assert.equal(source.includes("服务端已自动补读缺失 API"), true);
|
||||
});
|
||||
|
||||
test("build123d API preflight ignores non-build123d imported helpers", () => {
|
||||
const candidates = build123dCallCandidates([
|
||||
"from pathlib import Path",
|
||||
"from math import cos, sin",
|
||||
"from build123d import Align, BuildPart, Cylinder, Hole, export_step",
|
||||
"",
|
||||
"def make_model():",
|
||||
" angle = cos(0) + sin(0)",
|
||||
" with BuildPart() as part:",
|
||||
" Cylinder(radius=10, height=5, align=(Align.CENTER, Align.CENTER, Align.CENTER))",
|
||||
" Hole(radius=2)",
|
||||
" Path('metadata.json').write_text('{}')",
|
||||
" export_step(part.part, 'model.step')",
|
||||
].join("\n"));
|
||||
assert.deepEqual(candidates, ["BuildPart", "Cylinder", "Hole", "export_step", "Align"]);
|
||||
});
|
||||
|
||||
test("agent stream does not forward model reasoning deltas to the UI", () => {
|
||||
const source = fs.readFileSync(chatSourcePath, "utf8");
|
||||
const streamStart = source.indexOf("result.toUIMessageStream({");
|
||||
assert.notEqual(streamStart, -1);
|
||||
const streamSource = source.slice(streamStart, source.indexOf("}));", streamStart));
|
||||
assert.equal(streamSource.includes("sendReasoning: false"), true);
|
||||
});
|
||||
|
||||
test("agent stream progress remains visible while the model is running", () => {
|
||||
const source = fs.readFileSync(chatSourcePath, "utf8");
|
||||
const transientStart = source.indexOf("const transientProgressSteps");
|
||||
assert.notEqual(transientStart, -1);
|
||||
const transientSource = source.slice(transientStart, source.indexOf(";", transientStart));
|
||||
assert.equal(transientSource.includes("agent_stream"), false);
|
||||
});
|
||||
|
||||
test("agent prompt requires Chinese editable parameter labels", () => {
|
||||
const source = fs.readFileSync(chatSourcePath, "utf8");
|
||||
assert.equal(source.includes("Parameter names shown to the user must be concise Chinese labels"), true);
|
||||
assert.equal(source.includes("Chinese user-facing names/display_name"), true);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,188 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
catalogEditableParameters,
|
||||
createParameterCatalogFromDesignIR,
|
||||
normalizeParameterCatalog,
|
||||
validateParameterEdit,
|
||||
} from "./cad-parameters";
|
||||
|
||||
test("declared semantic DesignIR parameters without backend binding are readonly", () => {
|
||||
const catalog = createParameterCatalogFromDesignIR(
|
||||
{
|
||||
semantic_layer: {
|
||||
parameters: {
|
||||
outer_diameter: {
|
||||
value: 160,
|
||||
unit: "mm",
|
||||
editable: true,
|
||||
range: [80, 240],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ taskId: "cad_test", sourceDesignIRPath: "flange.designir.json" },
|
||||
);
|
||||
|
||||
assert.equal(catalog.parameters.length, 1);
|
||||
assert.equal(catalog.parameters[0].display_name, "外径");
|
||||
assert.equal(catalog.parameters[0].editable, false);
|
||||
assert.equal(catalog.parameters[0].min, 80);
|
||||
assert.equal(catalog.parameters[0].max, 240);
|
||||
assert.equal(catalogEditableParameters(catalog).length, 0);
|
||||
});
|
||||
|
||||
test("backend-bound semantic DesignIR parameters are exported as editable", () => {
|
||||
const catalog = createParameterCatalogFromDesignIR(
|
||||
{
|
||||
semantic_layer: {
|
||||
parameters: {
|
||||
outer_diameter: {
|
||||
value: 160,
|
||||
unit: "mm",
|
||||
editable: true,
|
||||
range: [80, 240],
|
||||
backend_binding: {
|
||||
backend: "build123d",
|
||||
source_path: "model.build123d.py",
|
||||
binding_kind: "python_constant",
|
||||
parameter_path: "PARAMETERS.outer_diameter",
|
||||
regenerate_adapter: "build123d",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ taskId: "cad_test", sourceDesignIRPath: "flange.designir.json" },
|
||||
);
|
||||
|
||||
assert.equal(catalog.parameters.length, 1);
|
||||
assert.equal(catalog.parameters[0].display_name, "外径");
|
||||
assert.equal(catalog.parameters[0].editable, true);
|
||||
assert.equal(catalog.parameters[0].backend_binding?.backend, "build123d");
|
||||
assert.equal(catalogEditableParameters(catalog).length, 1);
|
||||
});
|
||||
|
||||
test("validated catalog parameters are exposed for editing", () => {
|
||||
const catalog = normalizeParameterCatalog(
|
||||
{
|
||||
parameters: [{
|
||||
name: "bolt_count",
|
||||
display_name: "螺栓数量",
|
||||
value: 8,
|
||||
unit: "",
|
||||
editable: true,
|
||||
edit_state: "validated_executable_binding",
|
||||
validated_range: {
|
||||
minimum_tested_inclusive: 4,
|
||||
maximum_tested_inclusive: 12,
|
||||
},
|
||||
}],
|
||||
},
|
||||
{ taskId: "cad_test", sourceDesignIRPath: "flange.designir.json" },
|
||||
);
|
||||
|
||||
const [parameter] = catalogEditableParameters(catalog);
|
||||
assert.equal(parameter.name, "bolt_count");
|
||||
assert.equal(parameter.min, 4);
|
||||
assert.equal(parameter.max, 12);
|
||||
assert.equal(parameter.step, 1);
|
||||
});
|
||||
|
||||
test("parameter edit validation accepts backend-bound parameters and rejects out-of-range values", () => {
|
||||
const catalog = normalizeParameterCatalog(
|
||||
{
|
||||
parameters: [{
|
||||
name: "thickness",
|
||||
value: 20,
|
||||
unit: "mm",
|
||||
editable: true,
|
||||
edit_state: "validated_executable_binding",
|
||||
validated_range: {
|
||||
minimum_tested_inclusive: 10,
|
||||
maximum_tested_inclusive: 30,
|
||||
},
|
||||
backend_binding: {
|
||||
backend: "build123d",
|
||||
source_path: "model.build123d.py",
|
||||
binding_kind: "python_constant",
|
||||
parameter_path: "PARAMETERS.thickness",
|
||||
regenerate_adapter: "build123d",
|
||||
},
|
||||
}, {
|
||||
name: "draft_only",
|
||||
value: 1,
|
||||
editable: true,
|
||||
range: [0, 3],
|
||||
}],
|
||||
},
|
||||
{ taskId: "cad_test", sourceDesignIRPath: "flange.designir.json" },
|
||||
);
|
||||
|
||||
assert.equal(validateParameterEdit(catalog, "thickness", 25).value, 25);
|
||||
assert.throws(
|
||||
() => validateParameterEdit(catalog, "thickness", 40),
|
||||
/declared range/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateParameterEdit(catalog, "draft_only", 2),
|
||||
/not declared editable/,
|
||||
);
|
||||
});
|
||||
|
||||
test("backend-bound parameters without a range remain editable", () => {
|
||||
const catalog = normalizeParameterCatalog(
|
||||
{
|
||||
parameters: [{
|
||||
name: "offset",
|
||||
value: 4,
|
||||
editable: true,
|
||||
backend_binding: {
|
||||
backend: "simplecadapi",
|
||||
source_path: "model.simplecadapi.py",
|
||||
binding_kind: "python_constant",
|
||||
parameter_path: "PARAMETERS.offset",
|
||||
regenerate_adapter: "simplecadapi",
|
||||
},
|
||||
}],
|
||||
},
|
||||
{ taskId: "cad_test", sourceDesignIRPath: "model.designir.json" },
|
||||
);
|
||||
|
||||
assert.equal(validateParameterEdit(catalog, "offset", 12).value, 12);
|
||||
});
|
||||
|
||||
test("parameter catalog exposes Chinese display names for generated parameters", () => {
|
||||
const catalog = normalizeParameterCatalog(
|
||||
{
|
||||
parameters: [{
|
||||
name: "jaw_clearance",
|
||||
display_name: "Jaw Clearance",
|
||||
value: 4,
|
||||
editable: true,
|
||||
backend_binding: {
|
||||
backend: "build123d",
|
||||
source_path: "model.build123d.py",
|
||||
binding_kind: "python_constant",
|
||||
parameter_path: "PARAMETERS.jaw_clearance",
|
||||
regenerate_adapter: "build123d",
|
||||
},
|
||||
}, {
|
||||
name: "孔距",
|
||||
value: 18,
|
||||
editable: true,
|
||||
backend_binding: {
|
||||
backend: "build123d",
|
||||
source_path: "model.build123d.py",
|
||||
binding_kind: "python_constant",
|
||||
parameter_path: "PARAMETERS.孔距",
|
||||
regenerate_adapter: "build123d",
|
||||
},
|
||||
}],
|
||||
},
|
||||
{ taskId: "cad_test", sourceDesignIRPath: "model.designir.json" },
|
||||
);
|
||||
|
||||
assert.equal(catalog.parameters[0].display_name, "间隙");
|
||||
assert.equal(catalog.parameters[1].display_name, "孔距");
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
export type ParameterCatalogParameter = Record<string, unknown> & {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
group: string;
|
||||
type: "number";
|
||||
value: number;
|
||||
default_value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step: number;
|
||||
precision: number;
|
||||
unit: string;
|
||||
control: "slider+number" | "number";
|
||||
editable: boolean;
|
||||
edit_state: string;
|
||||
validation: {
|
||||
validated: boolean;
|
||||
state: string;
|
||||
message?: string;
|
||||
};
|
||||
backend_binding?: {
|
||||
backend: string;
|
||||
source_path: string;
|
||||
binding_kind: string;
|
||||
parameter_path: string;
|
||||
regenerate_adapter: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ParameterCatalog = Record<string, unknown> & {
|
||||
schema_version: "1.0";
|
||||
task_id: string;
|
||||
source_designir_path: string;
|
||||
generated_at: string;
|
||||
groups: Array<{ id: string; display_name: string; count: number }>;
|
||||
parameters: ParameterCatalogParameter[];
|
||||
};
|
||||
|
||||
const DISPLAY_NAME_HINTS: Array<[RegExp, string]> = [
|
||||
[/outer.*diameter|outside.*diameter|od$/i, "外径"],
|
||||
[/inner.*diameter|inside.*diameter|hole.*diameter|id$/i, "内径"],
|
||||
[/counterbore.*diameter/i, "沉孔直径"],
|
||||
[/countersink.*diameter/i, "锥孔直径"],
|
||||
[/diameter/i, "直径"],
|
||||
[/radius/i, "半径"],
|
||||
[/thickness/i, "厚度"],
|
||||
[/clearance/i, "间隙"],
|
||||
[/offset/i, "偏移量"],
|
||||
[/spacing/i, "间距"],
|
||||
[/width/i, "宽度"],
|
||||
[/height/i, "高度"],
|
||||
[/length/i, "长度"],
|
||||
[/depth/i, "深度"],
|
||||
[/pitch/i, "节距"],
|
||||
[/bolt.*count|bolt.*number/i, "螺栓数量"],
|
||||
[/hole.*count|hole.*number/i, "孔数量"],
|
||||
[/tooth.*count|teeth|tooth.*number/i, "齿数"],
|
||||
[/count|number/i, "数量"],
|
||||
[/angle/i, "角度"],
|
||||
[/x$/i, "X向尺寸"],
|
||||
[/y$/i, "Y向尺寸"],
|
||||
[/z$/i, "Z向尺寸"],
|
||||
];
|
||||
|
||||
function finiteNumber(value: unknown): number | undefined {
|
||||
const numeric = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : undefined;
|
||||
}
|
||||
|
||||
function decimalPlaces(value: number) {
|
||||
const text = String(value);
|
||||
const dot = text.indexOf(".");
|
||||
return dot >= 0 ? Math.min(Math.max(text.length - dot - 1, 0), 6) : 0;
|
||||
}
|
||||
|
||||
function fallbackStep(value: number, min?: number, max?: number) {
|
||||
if (Number.isInteger(value)) return 1;
|
||||
if (typeof min === "number" && typeof max === "number" && max > min) {
|
||||
const raw = (max - min) / 100;
|
||||
if (raw >= 1) return 1;
|
||||
if (raw >= 0.1) return 0.1;
|
||||
if (raw >= 0.01) return 0.01;
|
||||
}
|
||||
return 0.1;
|
||||
}
|
||||
|
||||
function titleCaseName(name: string) {
|
||||
return name
|
||||
.replace(/[_-]+/g, " ")
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.trim()
|
||||
.replace(/\b\w/g, (value) => value.toUpperCase());
|
||||
}
|
||||
|
||||
function containsChinese(value: string) {
|
||||
return /[\u3400-\u9fff]/.test(value);
|
||||
}
|
||||
|
||||
function displayNameFor(name: string, record: Record<string, unknown>) {
|
||||
for (const key of ["display_name", "displayName", "label", "title"]) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && containsChinese(value.trim())) return value.trim();
|
||||
}
|
||||
const hint = DISPLAY_NAME_HINTS.find(([pattern]) => pattern.test(name));
|
||||
if (hint) return hint[1];
|
||||
if (containsChinese(name)) return name;
|
||||
const fallback = titleCaseName(name);
|
||||
return containsChinese(fallback) ? fallback : "参数";
|
||||
}
|
||||
|
||||
function groupFor(record: Record<string, unknown>) {
|
||||
const raw = String(record.group || record.category || "dimensions").trim() || "dimensions";
|
||||
const normalized = raw.toLowerCase();
|
||||
const displayName = normalized === "dimensions" || raw === "尺寸"
|
||||
? "尺寸"
|
||||
: raw === "features" || raw === "特征"
|
||||
? "特征"
|
||||
: raw;
|
||||
return { id: normalized.replace(/[^a-z0-9_-]+/g, "_") || "dimensions", display_name: displayName };
|
||||
}
|
||||
|
||||
function rangeFor(record: Record<string, unknown>) {
|
||||
const validatedRange = (
|
||||
record.validated_range && typeof record.validated_range === "object"
|
||||
? record.validated_range
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
const declaredRange = Array.isArray(record.range) ? record.range : [];
|
||||
const min = finiteNumber(
|
||||
record.min
|
||||
?? record.minimum
|
||||
?? validatedRange.minimum_tested_inclusive
|
||||
?? validatedRange.min
|
||||
?? declaredRange[0],
|
||||
);
|
||||
const max = finiteNumber(
|
||||
record.max
|
||||
?? record.maximum
|
||||
?? validatedRange.maximum_tested_inclusive
|
||||
?? validatedRange.max
|
||||
?? declaredRange[1],
|
||||
);
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
export function normalizeParameterRecord(
|
||||
rawName: string,
|
||||
rawRecord: unknown,
|
||||
): ParameterCatalogParameter | null {
|
||||
const record = (
|
||||
rawRecord && typeof rawRecord === "object"
|
||||
? rawRecord
|
||||
: { value: rawRecord }
|
||||
) as Record<string, unknown>;
|
||||
const name = String(record.name || record.id || rawName || "").trim();
|
||||
const value = finiteNumber(record.value);
|
||||
if (!name || value === undefined) return null;
|
||||
const { min, max } = rangeFor(record);
|
||||
const step = finiteNumber(record.step) ?? fallbackStep(value, min, max);
|
||||
const precision = Math.max(
|
||||
0,
|
||||
finiteNumber(record.precision) ?? decimalPlaces(step) ?? decimalPlaces(value),
|
||||
);
|
||||
const editState = String(record.edit_state || record.editState || "").trim();
|
||||
const rawBinding = (
|
||||
record.backend_binding && typeof record.backend_binding === "object"
|
||||
? record.backend_binding
|
||||
: null
|
||||
) as Record<string, unknown> | null;
|
||||
const topLevelBackendBinding = (
|
||||
!rawBinding
|
||||
&& (record.binding_kind || record.parameter_path || record.regenerate_adapter)
|
||||
)
|
||||
? {
|
||||
backend: String(record.backend || record.regenerate_adapter || ""),
|
||||
source_path: String(record.source_path || ""),
|
||||
binding_kind: String(record.binding_kind || ""),
|
||||
parameter_path: String(record.parameter_path || name),
|
||||
regenerate_adapter: String(record.regenerate_adapter || record.backend || ""),
|
||||
}
|
||||
: null;
|
||||
const inferredSurfaceBinding = (
|
||||
!rawBinding
|
||||
&& !topLevelBackendBinding
|
||||
&& record.editable === true
|
||||
&& editState === "validated_executable_binding"
|
||||
)
|
||||
? {
|
||||
backend: "surfaceir",
|
||||
source_path: "",
|
||||
binding_kind: "surfaceir_validated_parameter",
|
||||
parameter_path: name,
|
||||
regenerate_adapter: "surfaceir",
|
||||
}
|
||||
: null;
|
||||
const backendBinding = rawBinding || topLevelBackendBinding || inferredSurfaceBinding;
|
||||
const validated = (
|
||||
record.editable === true
|
||||
&& (editState === "validated_executable_binding" || Boolean(backendBinding))
|
||||
&& typeof min === "number"
|
||||
&& typeof max === "number"
|
||||
);
|
||||
const editable = record.editable === true && Boolean(backendBinding);
|
||||
const group = groupFor(record);
|
||||
return {
|
||||
...record,
|
||||
id: String(record.id || name),
|
||||
name,
|
||||
display_name: displayNameFor(name, record),
|
||||
description: typeof record.description === "string" ? record.description : "",
|
||||
group: group.id,
|
||||
group_display_name: group.display_name,
|
||||
type: "number",
|
||||
value,
|
||||
default_value: finiteNumber(record.default_value ?? record.defaultValue) ?? value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
precision,
|
||||
unit: String(record.unit || ""),
|
||||
control: typeof min === "number" && typeof max === "number" ? "slider+number" : "number",
|
||||
editable,
|
||||
backend_binding: backendBinding
|
||||
? {
|
||||
backend: String(backendBinding.backend || ""),
|
||||
source_path: String(backendBinding.source_path || ""),
|
||||
binding_kind: String(backendBinding.binding_kind || ""),
|
||||
parameter_path: String(backendBinding.parameter_path || name),
|
||||
regenerate_adapter: String(backendBinding.regenerate_adapter || backendBinding.backend || ""),
|
||||
}
|
||||
: undefined,
|
||||
edit_state: editState || (editable ? "declared_executable_binding" : "readonly"),
|
||||
validation: {
|
||||
validated,
|
||||
state: validated ? "validated_executable_binding" : editable ? "declared_unvalidated" : "readonly",
|
||||
message: validated
|
||||
? "Parameter passed isolated perturbation acceptance."
|
||||
: editable
|
||||
? "Parameter is directly editable from its DesignIR declaration."
|
||||
: "Parameter is marked readonly in DesignIR.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeParameterCatalog(
|
||||
source: unknown,
|
||||
{
|
||||
taskId,
|
||||
sourceDesignIRPath,
|
||||
}: {
|
||||
taskId: string;
|
||||
sourceDesignIRPath: string;
|
||||
},
|
||||
): ParameterCatalog {
|
||||
const sourceRecord = (
|
||||
source && typeof source === "object"
|
||||
? source
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
const sourceParameters = Array.isArray(sourceRecord.parameters)
|
||||
? sourceRecord.parameters.map((value, index) => normalizeParameterRecord(String(index), value))
|
||||
: Object.entries(sourceRecord.parameters || {}).map(([name, value]) => normalizeParameterRecord(name, value));
|
||||
const parameters = sourceParameters
|
||||
.filter((value): value is ParameterCatalogParameter => Boolean(value));
|
||||
const groupsById = new Map<string, { id: string; display_name: string; count: number }>();
|
||||
for (const parameter of parameters) {
|
||||
const id = parameter.group || "dimensions";
|
||||
const displayName = String(parameter.group_display_name || "尺寸");
|
||||
const current = groupsById.get(id) || { id, display_name: displayName, count: 0 };
|
||||
current.count += 1;
|
||||
groupsById.set(id, current);
|
||||
}
|
||||
return {
|
||||
...sourceRecord,
|
||||
schema_version: "1.0",
|
||||
task_id: taskId,
|
||||
source_designir_path: sourceDesignIRPath,
|
||||
generated_at: new Date().toISOString(),
|
||||
groups: Array.from(groupsById.values()),
|
||||
parameters,
|
||||
};
|
||||
}
|
||||
|
||||
export function createParameterCatalogFromDesignIR(
|
||||
designir: Record<string, any>,
|
||||
options: { taskId: string; sourceDesignIRPath: string },
|
||||
) {
|
||||
return normalizeParameterCatalog(
|
||||
{
|
||||
source_kind: "designir_semantic_layer",
|
||||
parameters: designir.semantic_layer?.parameters || {},
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function catalogEditableParameters(
|
||||
catalog: unknown,
|
||||
): Array<Record<string, unknown>> {
|
||||
return catalogParameters(catalog).filter(
|
||||
(value) => value.editable === true,
|
||||
);
|
||||
}
|
||||
|
||||
export function catalogParameters(
|
||||
catalog: unknown,
|
||||
): Array<Record<string, unknown>> {
|
||||
const parameters: unknown[] = (
|
||||
catalog && typeof catalog === "object" && Array.isArray((catalog as Record<string, unknown>).parameters)
|
||||
? (catalog as Record<string, unknown>).parameters
|
||||
: []
|
||||
) as unknown[];
|
||||
return parameters.filter(
|
||||
(value): value is Record<string, unknown> => (
|
||||
value !== null
|
||||
&& typeof value === "object"
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function updateCatalogParameterValue(
|
||||
catalog: unknown,
|
||||
parameterName: string,
|
||||
value: number,
|
||||
options: { taskId: string; sourceDesignIRPath: string },
|
||||
) {
|
||||
const normalized = normalizeParameterCatalog(catalog, options);
|
||||
return {
|
||||
...normalized,
|
||||
parameters: normalized.parameters.map((parameter) => (
|
||||
parameter.name === parameterName || parameter.id === parameterName
|
||||
? { ...parameter, value }
|
||||
: parameter
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateParameterEdit(catalog: unknown, parameterName: string, value: unknown) {
|
||||
const normalizedValue = finiteNumber(value);
|
||||
if (normalizedValue === undefined) {
|
||||
throw new Error("Parameter value must be a finite number.");
|
||||
}
|
||||
const parameters = (
|
||||
catalog && typeof catalog === "object" && Array.isArray((catalog as Record<string, unknown>).parameters)
|
||||
? (catalog as Record<string, unknown>).parameters
|
||||
: []
|
||||
) as Array<Record<string, unknown>>;
|
||||
const parameter = parameters.find((item) => (
|
||||
item.name === parameterName || item.id === parameterName
|
||||
));
|
||||
if (!parameter) {
|
||||
throw new Error(`${parameterName} is not listed in parameters.json.`);
|
||||
}
|
||||
if (parameter.editable !== true) {
|
||||
throw new Error(`${parameterName} is not declared editable in DesignIR.`);
|
||||
}
|
||||
const minimum = finiteNumber(parameter.min);
|
||||
const maximum = finiteNumber(parameter.max);
|
||||
if (
|
||||
minimum !== undefined
|
||||
&& maximum !== undefined
|
||||
&& (normalizedValue < minimum || normalizedValue > maximum)
|
||||
) {
|
||||
throw new Error(`${parameterName} must remain within the declared range [${minimum}, ${maximum}].`);
|
||||
}
|
||||
return { parameter, value: normalizedValue };
|
||||
}
|
||||
@@ -11,22 +11,48 @@ import {
|
||||
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 {
|
||||
editDesignIR3Parameter,
|
||||
executeDesignIR,
|
||||
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;
|
||||
@@ -61,11 +87,14 @@ function writeTextPart(writer: { write: (part: any) => void }, text: string) {
|
||||
writer.write({ type: "text-end", id });
|
||||
}
|
||||
|
||||
function uiTextResponse(text: string) {
|
||||
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,
|
||||
@@ -352,6 +381,292 @@ function attachmentSummary(attachments: unknown[]) {
|
||||
].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.",
|
||||
@@ -359,7 +674,9 @@ function buildSystemPrompt(viewerContext: unknown[], attachments: unknown[], tas
|
||||
"Strict honesty rule: never claim that a CAD file/model was generated unless the generate_cad tool has succeeded and returned artifact URLs.",
|
||||
"Never return only code when the user asks to generate CAD. Use the generate_cad tool instead.",
|
||||
"Do not rely on hardcoded templates, canned examples, mock data, fake filenames, or imaginary viewer state.",
|
||||
"The generate_cad tool validates executable DesignIR, selects a backend through CAD Router, and rebuilds STEP in a teacher-free sandbox.",
|
||||
"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.",
|
||||
@@ -367,16 +684,26 @@ function buildSystemPrompt(viewerContext: unknown[], attachments: unknown[], tas
|
||||
"When viewer context is present, use its cad-edit-intent.v1 or cad-ai-geometry-selection.v1 payload as precise geometry context.",
|
||||
"If viewer context selection.scope is selected_reference_only, modify only that selected topology/feature. Do not change a global pattern parameter or all repeated/symmetric features unless the user explicitly asks for all of them.",
|
||||
"For selected holes, use the selected reference center/surface/bbox/adjacent selectors to identify the individual feature. If the editable source models a repeated pattern with one shared diameter variable, split out or override the selected instance instead of changing the shared variable.",
|
||||
"Prefer editing the DesignIR recorded in cad-task.json before regenerating STEP and viewer assets.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"Use semantic parameter names that describe design intent, such as flange_thickness, shelf_depth, hole_spacing, rib_count, bore_diameter, and bolt_circle_diameter. Every editable parameter must directly drive at least one feature and have a perturbation test.",
|
||||
"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.",
|
||||
@@ -545,7 +872,7 @@ async function inspectStepAttachment(attachment: AttachmentRecord) {
|
||||
"print(json.dumps(payload, ensure_ascii=False))",
|
||||
].join("\n");
|
||||
const { stdout } = await execFileAsync(
|
||||
process.env.CAD_PYTHON || path.join(process.cwd(), "..", "text-to-cad", ".venv", "bin", "python"),
|
||||
cadPythonExecutable(),
|
||||
["-c", script, absolutePath],
|
||||
{
|
||||
timeout: 30_000,
|
||||
@@ -599,6 +926,7 @@ export async function streamAgentResponse({
|
||||
provider,
|
||||
model,
|
||||
selectedTaskId,
|
||||
conversationId,
|
||||
}: {
|
||||
messages: unknown[];
|
||||
attachments: unknown[];
|
||||
@@ -606,14 +934,54 @@ export async function streamAgentResponse({
|
||||
provider?: string;
|
||||
model?: string;
|
||||
selectedTaskId?: string;
|
||||
conversationId?: string;
|
||||
}) {
|
||||
const config = loadLlmConfig();
|
||||
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);
|
||||
const chatMessages = normalizedMessages(messages);
|
||||
const uploadedAttachments = normalizedAttachments(attachments);
|
||||
if (!apiKey) {
|
||||
return uiTextResponse(localAssistantReply({ messages: chatMessages, attachments: uploadedAttachments, viewerContext, selectedTaskId }));
|
||||
return uiTextResponse(
|
||||
localAssistantReply({ messages: chatMessages, attachments: uploadedAttachments, viewerContext, selectedTaskId }),
|
||||
persistAssistantMessage,
|
||||
);
|
||||
}
|
||||
const languageModel = buildLanguageModel({
|
||||
apiKey,
|
||||
@@ -624,7 +992,7 @@ export async function streamAgentResponse({
|
||||
|
||||
let cadGeneration: CadGenerationResult | null = null;
|
||||
let streamWriter: { write: (part: any) => void } | null = null;
|
||||
const transientProgressSteps = new Set(["analyze_request", "agent_stream"]);
|
||||
const transientProgressSteps = new Set(["analyze_request"]);
|
||||
const writeCadProgress = (payload: CadProgressPayload) => {
|
||||
streamWriter?.write({
|
||||
type: "data-cad-progress",
|
||||
@@ -663,7 +1031,105 @@ export async function streamAgentResponse({
|
||||
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.",
|
||||
@@ -800,9 +1266,14 @@ export async function streamAgentResponse({
|
||||
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,
|
||||
@@ -816,26 +1287,26 @@ export async function streamAgentResponse({
|
||||
}),
|
||||
edit_designir3_parameter: tool({
|
||||
description: [
|
||||
"Edit one perturbation-validated parameter on the current DesignIR 3.0 reconstruction.",
|
||||
"The requested value must be within the validated range shown in current cad-task.json.",
|
||||
"The server reruns independent teacher-side semantic edit acceptance before publishing.",
|
||||
"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("Validated parameter name from the current task."),
|
||||
value: z.number().describe("New value within the parameter's validated range."),
|
||||
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 DesignIR 3.0 task is selected.");
|
||||
throw new Error("No current CAD task is selected.");
|
||||
}
|
||||
writeCadProgress({
|
||||
step: "execute_cad",
|
||||
label: "修改 DesignIR 3.0 参数",
|
||||
step: "backend_parameter_edit",
|
||||
label: "修改后端绑定参数",
|
||||
status: "running",
|
||||
message: `${parameter} → ${value},正在执行独立参数验收。`,
|
||||
message: `${parameter} -> ${value},正在使用记录的无 LLM adapter 重新生成。`,
|
||||
});
|
||||
try {
|
||||
cadGeneration = await editDesignIR3Parameter({
|
||||
cadGeneration = await editDesignIRParameter({
|
||||
prompt: lastUserText(chatMessages),
|
||||
sourceTaskId: selectedTaskId,
|
||||
parameter,
|
||||
@@ -844,10 +1315,10 @@ export async function streamAgentResponse({
|
||||
} catch (error) {
|
||||
const message = error instanceof Error
|
||||
? error.message
|
||||
: "DesignIR 3.0 parameter edit failed.";
|
||||
: "CAD parameter edit failed.";
|
||||
writeCadProgress({
|
||||
step: "execute_cad",
|
||||
label: "修改 DesignIR 3.0 参数",
|
||||
step: "backend_parameter_edit",
|
||||
label: "修改后端绑定参数",
|
||||
status: "error",
|
||||
message,
|
||||
});
|
||||
@@ -855,8 +1326,8 @@ export async function streamAgentResponse({
|
||||
throw error;
|
||||
}
|
||||
writeCadProgress({
|
||||
step: "execute_cad",
|
||||
label: "修改 DesignIR 3.0 参数",
|
||||
step: "backend_parameter_edit",
|
||||
label: "修改后端绑定参数",
|
||||
status: "success",
|
||||
message: cadGeneration.summary,
|
||||
});
|
||||
@@ -866,16 +1337,21 @@ export async function streamAgentResponse({
|
||||
status: "success",
|
||||
message: "参数修改后的模型已载入。",
|
||||
});
|
||||
completeAgentStream("DesignIR 3.0 参数修改和验收已完成。");
|
||||
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,
|
||||
@@ -951,69 +1427,115 @@ export async function streamAgentResponse({
|
||||
}),
|
||||
generate_cad: tool({
|
||||
description: [
|
||||
"Generate a real CAD model from agent-authored semantic DesignIR 3.0.",
|
||||
"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.",
|
||||
"CAD Router selects the backend and the server rebuilds STEP without teacher geometry.",
|
||||
"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({
|
||||
summary: z.string().describe("Concise user-facing summary of the generated CAD model."),
|
||||
designirFilename: z.string().describe("DesignIR filename, e.g. model.designir.json."),
|
||||
stepFilename: z.string().describe("STEP output filename, e.g. model.step."),
|
||||
designir: z.record(z.string(), z.unknown()).describe("Complete semantic DesignIR 3.0 JSON object in fully_semantic_parametric mode."),
|
||||
teacherTaskId: z.string().optional().describe("Uploaded teacher STEP task id for independent acceptance only."),
|
||||
teacherPath: z.string().optional().describe("Uploaded teacher STEP path for independent acceptance only."),
|
||||
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,
|
||||
designirFilename,
|
||||
stepFilename,
|
||||
designir,
|
||||
teacherTaskId,
|
||||
teacherPath,
|
||||
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: "generate_designir",
|
||||
label: "生成 DesignIR",
|
||||
status: "success",
|
||||
message: designirFilename,
|
||||
});
|
||||
writeCadProgress({
|
||||
step: "execute_cad",
|
||||
label: "隔离重建 STEP",
|
||||
step: "backend_generate",
|
||||
label: "后端原生生成",
|
||||
status: "running",
|
||||
message: "正在进行防作弊检查、CAD Router 选路和无教师重建。",
|
||||
message: "正在执行 agent 提交的原生 source,server 不生成模板或替代模型。",
|
||||
});
|
||||
try {
|
||||
cadGeneration = await executeDesignIR({
|
||||
prompt: lastUserText(chatMessages),
|
||||
cadGeneration = await executeBackendNativeGeneration({
|
||||
request: routeDecision?.request || lastUserText(chatMessages),
|
||||
sourceKind,
|
||||
generationMode,
|
||||
routeDecision,
|
||||
nativeSource,
|
||||
summary,
|
||||
designirFilename,
|
||||
stepFilename,
|
||||
designir,
|
||||
teacherTaskId,
|
||||
teacherPath,
|
||||
targetName,
|
||||
assumptions,
|
||||
selectedTaskId,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "CAD generation failed.";
|
||||
writeCadProgress({
|
||||
step: "execute_cad",
|
||||
label: "隔离重建 STEP",
|
||||
step: "backend_generate",
|
||||
label: "后端原生生成",
|
||||
status: "error",
|
||||
message,
|
||||
});
|
||||
failAgentStream(message);
|
||||
throw error;
|
||||
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: "execute_cad",
|
||||
label: "隔离重建 STEP",
|
||||
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: "更新右侧预览",
|
||||
@@ -1027,9 +1549,14 @@ export async function streamAgentResponse({
|
||||
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,
|
||||
@@ -1075,10 +1602,11 @@ export async function streamAgentResponse({
|
||||
model: languageModel,
|
||||
system: buildSystemPrompt(viewerContext, uploadedAttachments, taskContext),
|
||||
tools: cadTools,
|
||||
stopWhen: isStepCount(3),
|
||||
stopWhen: isStepCount(16),
|
||||
messages: modelMessages,
|
||||
});
|
||||
writer.merge(result.toUIMessageStream({
|
||||
sendReasoning: false,
|
||||
onEnd: () => {
|
||||
completeAgentStream(cadGeneration ? "模型和 CAD 工具执行已完成。" : "模型回复已完成。");
|
||||
},
|
||||
@@ -1095,6 +1623,14 @@ export async function streamAgentResponse({
|
||||
}
|
||||
},
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { conversationModelMessages, sanitizeConversationMessage } from "./conversation-store";
|
||||
|
||||
test("conversation persistence keeps final CAD UI parts and drops transient parts", () => {
|
||||
const message = sanitizeConversationMessage({
|
||||
id: "assistant-result",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "text", text: "模型已生成。" },
|
||||
{ type: "data-cad-progress", data: { status: "running" } },
|
||||
{ type: "tool-generate_cad", state: "output-available" },
|
||||
{ type: "data-cad-result", data: { taskId: "cad_123456789abc", summary: "完成" } },
|
||||
{ type: "data-cad-error", data: { stage: "validation", message: "仅用于展示的最终错误" } },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(message, {
|
||||
id: "assistant-result",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "text", text: "模型已生成。" },
|
||||
{ type: "data-cad-result", data: { taskId: "cad_123456789abc", summary: "完成" } },
|
||||
{ type: "data-cad-error", data: { stage: "validation", message: "仅用于展示的最终错误" } },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("conversation model context contains only completed text messages", () => {
|
||||
const user = sanitizeConversationMessage({
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "把外径改成 120 mm" }],
|
||||
});
|
||||
const assistant = sanitizeConversationMessage({
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "data-cad-result", data: { taskId: "cad_123456789abc" } },
|
||||
{ type: "text", text: "已生成新版本。" },
|
||||
],
|
||||
});
|
||||
|
||||
assert.ok(user);
|
||||
assert.ok(assistant);
|
||||
assert.deepEqual(conversationModelMessages([user, assistant]), [
|
||||
{ role: "user", content: "把外径改成 120 mm" },
|
||||
{ role: "assistant", content: "已生成新版本。" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("conversation rejects invalid user messages without text", () => {
|
||||
assert.equal(sanitizeConversationMessage({
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
parts: [{ type: "file", url: "https://example.test/model.step" }],
|
||||
}), null);
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolveTaskRoot } from "@/lib/config";
|
||||
import { safeTaskId } from "@/lib/task-store";
|
||||
|
||||
export type ConversationAttachment = {
|
||||
id: string;
|
||||
taskId: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
path: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
mime: string;
|
||||
};
|
||||
|
||||
export type ConversationMessagePart =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "data-cad-result"; data: Record<string, unknown> }
|
||||
| { type: "data-cad-error"; data: { stage?: string; message: string } };
|
||||
|
||||
export type ConversationMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
parts: ConversationMessagePart[];
|
||||
};
|
||||
|
||||
export type ConversationRecord = {
|
||||
schema_version: "1.0";
|
||||
conversationId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
currentTaskId: string;
|
||||
taskIds: string[];
|
||||
attachments: ConversationAttachment[];
|
||||
messages: ConversationMessage[];
|
||||
};
|
||||
|
||||
const conversationLocks = new Map<string, Promise<void>>();
|
||||
|
||||
async function withConversationLock<T>(conversationId: string, operation: () => Promise<T>) {
|
||||
const safeId = safeConversationId(conversationId);
|
||||
const previous = conversationLocks.get(safeId) || Promise.resolve();
|
||||
let release: (() => void) | undefined;
|
||||
const current = previous.then(() => new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
}));
|
||||
conversationLocks.set(safeId, current);
|
||||
await previous;
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
release?.();
|
||||
if (conversationLocks.get(safeId) === current) {
|
||||
conversationLocks.delete(safeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function newConversationId() {
|
||||
return `conv_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
}
|
||||
|
||||
export function safeConversationId(value: string) {
|
||||
const conversationId = String(value || "").trim();
|
||||
if (!/^conv_[a-zA-Z0-9_-]{4,75}$/.test(conversationId)) {
|
||||
throw new Error("Invalid conversation id");
|
||||
}
|
||||
return conversationId;
|
||||
}
|
||||
|
||||
export function conversationRoot() {
|
||||
return path.join(resolveTaskRoot(), "conversations");
|
||||
}
|
||||
|
||||
export function conversationPath(conversationId: string) {
|
||||
return path.join(conversationRoot(), safeConversationId(conversationId), "conversation.json");
|
||||
}
|
||||
|
||||
function safeTaskIdOrEmpty(value: unknown) {
|
||||
const taskId = String(value || "").trim();
|
||||
if (!taskId) return "";
|
||||
try {
|
||||
return safeTaskId(taskId);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, limit = 120_000) {
|
||||
return String(value || "").slice(0, limit);
|
||||
}
|
||||
|
||||
function sanitizeCadResult(value: unknown) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const result = value as Record<string, unknown>;
|
||||
const taskId = safeTaskIdOrEmpty(result.taskId);
|
||||
if (!taskId) return null;
|
||||
return JSON.parse(JSON.stringify({ ...result, taskId })) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function sanitizeCadError(value: unknown) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const message = boundedText(record.message, 16_000).trim();
|
||||
if (!message) return null;
|
||||
const stage = boundedText(record.stage, 200).trim();
|
||||
return stage ? { stage, message } : { message };
|
||||
}
|
||||
|
||||
export function sanitizeConversationMessage(value: unknown): ConversationMessage | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const role = record.role === "assistant" ? "assistant" : record.role === "user" ? "user" : null;
|
||||
const id = boundedText(record.id, 160).trim();
|
||||
if (!role || !id || !Array.isArray(record.parts)) return null;
|
||||
const parts: ConversationMessagePart[] = [];
|
||||
for (const rawPart of record.parts) {
|
||||
if (!rawPart || typeof rawPart !== "object") continue;
|
||||
const part = rawPart as Record<string, unknown>;
|
||||
if (part.type === "text") {
|
||||
const text = boundedText(part.text).trim();
|
||||
if (text) parts.push({ type: "text", text });
|
||||
continue;
|
||||
}
|
||||
if (role !== "assistant" || part.type !== "data-cad-result" && part.type !== "data-cad-error") continue;
|
||||
if (part.type === "data-cad-result") {
|
||||
const data = sanitizeCadResult(part.data);
|
||||
if (data) parts.push({ type: "data-cad-result", data });
|
||||
continue;
|
||||
}
|
||||
const data = sanitizeCadError(part.data);
|
||||
if (data) parts.push({ type: "data-cad-error", data });
|
||||
}
|
||||
if (!parts.length || (role === "user" && !parts.some((part) => part.type === "text"))) return null;
|
||||
return { id, role, parts };
|
||||
}
|
||||
|
||||
function sanitizeAttachment(value: unknown): ConversationAttachment | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const taskId = safeTaskIdOrEmpty(record.taskId);
|
||||
const artifactPath = String(record.path || "").trim();
|
||||
if (!taskId || !artifactPath || artifactPath.startsWith("/") || artifactPath.split(/[\\/]+/).includes("..")) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: boundedText(record.id, 160).trim(),
|
||||
taskId,
|
||||
name: boundedText(record.name || path.basename(artifactPath), 500).trim(),
|
||||
kind: boundedText(record.kind || "other", 80).trim() || "other",
|
||||
path: artifactPath,
|
||||
size: Math.max(0, Number(record.size) || 0),
|
||||
sha256: boundedText(record.sha256, 200).trim(),
|
||||
mime: boundedText(record.mime || "application/octet-stream", 200).trim() || "application/octet-stream",
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueTaskIds(values: unknown[]) {
|
||||
return [...new Set(values.map(safeTaskIdOrEmpty).filter(Boolean))];
|
||||
}
|
||||
|
||||
function uniqueAttachments(values: unknown[]) {
|
||||
const seen = new Set<string>();
|
||||
const result: ConversationAttachment[] = [];
|
||||
for (const value of values) {
|
||||
const attachment = sanitizeAttachment(value);
|
||||
if (!attachment) continue;
|
||||
const key = [attachment.id, attachment.taskId, attachment.path].join(":");
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(attachment);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeConversation(value: unknown, expectedConversationId?: string): ConversationRecord | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const conversationId = safeConversationId(String(record.conversationId || expectedConversationId || ""));
|
||||
if (expectedConversationId && conversationId !== safeConversationId(expectedConversationId)) return null;
|
||||
const messages = Array.isArray(record.messages)
|
||||
? record.messages.map(sanitizeConversationMessage).filter((message): message is ConversationMessage => Boolean(message))
|
||||
: [];
|
||||
const messageIds = new Set<string>();
|
||||
const uniqueMessages = messages.filter((message) => {
|
||||
if (messageIds.has(message.id)) return false;
|
||||
messageIds.add(message.id);
|
||||
return true;
|
||||
});
|
||||
const currentTaskId = safeTaskIdOrEmpty(record.currentTaskId);
|
||||
const taskIds = uniqueTaskIds([
|
||||
...(Array.isArray(record.taskIds) ? record.taskIds : []),
|
||||
currentTaskId,
|
||||
...uniqueMessages.flatMap((message) => message.parts.map((part) => (
|
||||
part.type === "data-cad-result" ? part.data.taskId : ""
|
||||
))),
|
||||
]);
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
schema_version: "1.0",
|
||||
conversationId,
|
||||
createdAt: boundedText(record.createdAt, 80).trim() || now,
|
||||
updatedAt: boundedText(record.updatedAt, 80).trim() || now,
|
||||
currentTaskId,
|
||||
taskIds,
|
||||
attachments: uniqueAttachments(Array.isArray(record.attachments) ? record.attachments : []),
|
||||
messages: uniqueMessages,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultConversation(conversationId: string, currentTaskId = ""): ConversationRecord {
|
||||
const now = new Date().toISOString();
|
||||
const safeCurrentTaskId = safeTaskIdOrEmpty(currentTaskId);
|
||||
return {
|
||||
schema_version: "1.0",
|
||||
conversationId: safeConversationId(conversationId),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
currentTaskId: safeCurrentTaskId,
|
||||
taskIds: safeCurrentTaskId ? [safeCurrentTaskId] : [],
|
||||
attachments: [],
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function readConversation(conversationId: string) {
|
||||
const safeId = safeConversationId(conversationId);
|
||||
try {
|
||||
const source = await fs.readFile(conversationPath(safeId), "utf8");
|
||||
return normalizeConversation(JSON.parse(source), safeId);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeConversation(record: ConversationRecord) {
|
||||
const directory = path.dirname(conversationPath(record.conversationId));
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
const target = conversationPath(record.conversationId);
|
||||
const temporary = path.join(directory, `.conversation-${crypto.randomUUID()}.tmp`);
|
||||
await fs.writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`);
|
||||
await fs.rename(temporary, target);
|
||||
return record;
|
||||
}
|
||||
|
||||
async function readOrCreateConversation(conversationId: string, currentTaskId = "") {
|
||||
const existing = await readConversation(conversationId);
|
||||
if (existing) return existing;
|
||||
return writeConversation(defaultConversation(conversationId, currentTaskId));
|
||||
}
|
||||
|
||||
export async function ensureConversation(conversationId: string, currentTaskId = "") {
|
||||
return withConversationLock(conversationId, () => readOrCreateConversation(conversationId, currentTaskId));
|
||||
}
|
||||
|
||||
export async function updateConversation({
|
||||
conversationId,
|
||||
currentTaskId,
|
||||
attachments,
|
||||
}: {
|
||||
conversationId: string;
|
||||
currentTaskId?: string;
|
||||
attachments?: unknown[];
|
||||
}) {
|
||||
return withConversationLock(conversationId, async () => {
|
||||
const current = await readOrCreateConversation(conversationId, currentTaskId || "");
|
||||
const nextTaskId = safeTaskIdOrEmpty(currentTaskId) || current.currentTaskId;
|
||||
const next: ConversationRecord = {
|
||||
...current,
|
||||
updatedAt: new Date().toISOString(),
|
||||
currentTaskId: nextTaskId,
|
||||
taskIds: uniqueTaskIds([...current.taskIds, nextTaskId]),
|
||||
attachments: uniqueAttachments([...current.attachments, ...(attachments || [])]),
|
||||
};
|
||||
return writeConversation(next);
|
||||
});
|
||||
}
|
||||
|
||||
export async function appendConversationMessage({
|
||||
conversationId,
|
||||
message,
|
||||
currentTaskId,
|
||||
attachments,
|
||||
}: {
|
||||
conversationId: string;
|
||||
message: unknown;
|
||||
currentTaskId?: string;
|
||||
attachments?: unknown[];
|
||||
}) {
|
||||
const sanitized = sanitizeConversationMessage(message);
|
||||
if (!sanitized) throw new Error("Conversation message has no persistable content.");
|
||||
return withConversationLock(conversationId, async () => {
|
||||
const current = await readOrCreateConversation(conversationId, currentTaskId || "");
|
||||
const resultTaskIds = sanitized.parts.flatMap((part) => (
|
||||
part.type === "data-cad-result" ? [safeTaskIdOrEmpty(part.data.taskId)] : []
|
||||
));
|
||||
const nextTaskId = resultTaskIds.find(Boolean) || safeTaskIdOrEmpty(currentTaskId) || current.currentTaskId;
|
||||
const next: ConversationRecord = {
|
||||
...current,
|
||||
updatedAt: new Date().toISOString(),
|
||||
currentTaskId: nextTaskId,
|
||||
taskIds: uniqueTaskIds([...current.taskIds, nextTaskId, ...resultTaskIds]),
|
||||
attachments: uniqueAttachments([...current.attachments, ...(attachments || [])]),
|
||||
messages: current.messages.some((item) => item.id === sanitized.id)
|
||||
? current.messages
|
||||
: [...current.messages, sanitized],
|
||||
};
|
||||
return writeConversation(next);
|
||||
});
|
||||
}
|
||||
|
||||
export function conversationModelMessages(messages: ConversationMessage[]) {
|
||||
const completeMessages = messages.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.parts
|
||||
.filter((part): part is Extract<ConversationMessagePart, { type: "text" }> => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n"),
|
||||
})).filter((message) => message.content.trim());
|
||||
const selected = [] as typeof completeMessages;
|
||||
let characters = 0;
|
||||
for (const message of [...completeMessages].reverse()) {
|
||||
if (selected.length >= 48 || characters + message.content.length > 48_000) break;
|
||||
selected.push(message);
|
||||
characters += message.content.length;
|
||||
}
|
||||
return selected.reverse();
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
@@ -40,8 +41,19 @@ type ExportState = {
|
||||
};
|
||||
|
||||
function pythonExecutable() {
|
||||
return process.env.CAD_PYTHON
|
||||
|| enginePath("text-to-cad", ".venv", "bin", "python");
|
||||
const configured = String(process.env.CAD_PYTHON || "").trim();
|
||||
if (configured) return configured;
|
||||
const workspacePython = enginePath("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)));
|
||||
}
|
||||
|
||||
function exporterScript() {
|
||||
|
||||
@@ -201,9 +201,112 @@ export async function upsertManifest(taskId: string, patch: Partial<TaskManifest
|
||||
return writeManifest(taskId, next);
|
||||
}
|
||||
|
||||
function containsEditablePythonParameter(source: string, parameter: string, binding?: Record<string, unknown>) {
|
||||
if (
|
||||
/# CAD_AGENT_PARAMETERS_START\s*\nPARAMETERS\s*=\s*\{[\s\S]*?\}\s*\n# CAD_AGENT_PARAMETERS_END/.test(source)
|
||||
&& new RegExp(`["']?${parameter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']?\\s*:`).test(source)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
/CAD_AGENT_PARAMETERS\s*=\s*\{/.test(source)
|
||||
&& new RegExp(`["']${parameter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:\\s*\\{[\\s\\S]*?["']value["']\\s*:`).test(source)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const parameterPath = String(binding?.parameter_path || "").trim();
|
||||
const candidates = Array.from(new Set([
|
||||
parameter,
|
||||
parameterPath.split(".").filter(Boolean).at(-1) || "",
|
||||
parameter.toUpperCase(),
|
||||
parameterPath.split(".").filter(Boolean).at(-1)?.toUpperCase() || "",
|
||||
].filter((candidate) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(candidate))));
|
||||
return candidates.some((candidate) => (
|
||||
new RegExp(`^\\s*${candidate.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*=\\s*[-+]?(?:\\d+\\.?\\d*|\\.\\d+)(?:[eE][-+]?\\d+)?`, "m").test(source)
|
||||
));
|
||||
}
|
||||
|
||||
const DIRECT_EDITABLE_BINDING_KINDS = new Set([
|
||||
"python_constant",
|
||||
"native",
|
||||
"parameter",
|
||||
"native_python",
|
||||
"native_source",
|
||||
"source_parameter",
|
||||
"source_variable",
|
||||
"model_graph_parameter",
|
||||
]);
|
||||
|
||||
async function parameterIsDirectlyEditable(taskRoot: string, manifest: TaskManifest, parameter: Record<string, unknown>) {
|
||||
if (parameter.editable !== true) return false;
|
||||
const binding = (
|
||||
parameter.backend_binding && typeof parameter.backend_binding === "object"
|
||||
? parameter.backend_binding
|
||||
: null
|
||||
) as Record<string, unknown> | null;
|
||||
const uploadReconstruction = (
|
||||
manifest.studio?.uploadReconstruction && typeof manifest.studio.uploadReconstruction === "object"
|
||||
? manifest.studio.uploadReconstruction
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
if (!binding) {
|
||||
return Boolean(uploadReconstruction.teacherTaskId && uploadReconstruction.teacherPath);
|
||||
}
|
||||
const backend = String(binding.backend || binding.regenerate_adapter || "");
|
||||
if (backend === "surfaceir") {
|
||||
return Boolean(uploadReconstruction.teacherTaskId && uploadReconstruction.teacherPath);
|
||||
}
|
||||
const bindingKind = String(binding.binding_kind || "");
|
||||
if (bindingKind && !DIRECT_EDITABLE_BINDING_KINDS.has(bindingKind)) {
|
||||
return false;
|
||||
}
|
||||
const sourcePath = String(
|
||||
binding.source_path
|
||||
|| manifest.source?.native_source_path
|
||||
|| manifest.source?.path
|
||||
|| "",
|
||||
);
|
||||
if (!sourcePath.endsWith(".py")) return false;
|
||||
const absoluteSourcePath = path.join(taskRoot, sourcePath);
|
||||
try {
|
||||
const source = await fs.readFile(absoluteSourcePath, "utf8");
|
||||
return containsEditablePythonParameter(source, String(parameter.name || parameter.id || ""), binding);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function manifestWithDirectEditableParameters(taskId: string, manifest: TaskManifest | null) {
|
||||
if (!manifest) return manifest;
|
||||
const catalog = manifest.parameters;
|
||||
const parameters = Array.isArray(catalog?.parameters)
|
||||
? catalog.parameters.filter(
|
||||
(parameter): parameter is Record<string, unknown> => parameter !== null && typeof parameter === "object",
|
||||
)
|
||||
: [];
|
||||
if (!parameters.length) return manifest;
|
||||
const root = taskDir(taskId);
|
||||
const filtered = [];
|
||||
for (const parameter of parameters) {
|
||||
if (await parameterIsDirectlyEditable(root, manifest, parameter)) {
|
||||
filtered.push(parameter);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...manifest,
|
||||
parameters: {
|
||||
...catalog,
|
||||
parameters: filtered,
|
||||
groups: Array.isArray((catalog as Record<string, unknown>).groups)
|
||||
? (catalog as Record<string, unknown>).groups
|
||||
: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function readTask(taskId: string) {
|
||||
const dir = taskDir(taskId);
|
||||
const manifest = await readManifest(taskId);
|
||||
const manifest = await manifestWithDirectEditableParameters(taskId, await readManifest(taskId));
|
||||
const safeId = safeTaskId(taskId);
|
||||
const artifactVersion = String(manifest?.studio?.latestVersion || "").trim();
|
||||
return {
|
||||
|
||||
@@ -178,9 +178,6 @@ const DEFAULT_DAMPING_FACTOR = 0.14;
|
||||
const DEFAULT_ZOOM_SPEED = 4.5;
|
||||
const COARSE_POINTER_ZOOM_SPEED = 1.6;
|
||||
const EXPLODED_VIEW_ANIMATION_DURATION_MS = 1000;
|
||||
const ACCELERATED_WHEEL_ZOOM_SPEED = 10;
|
||||
const TRACKPAD_PINCH_ZOOM_SPEED = 14;
|
||||
const COARSE_POINTER_PINCH_ZOOM_SPEED = 2.4;
|
||||
const KEYBOARD_ORBIT_NUDGE_RAD = Math.PI / 32;
|
||||
const KEYBOARD_ORBIT_SPEED_RAD_PER_SEC = Math.PI * 0.42;
|
||||
const KEYBOARD_POLAR_EPSILON = 0.02;
|
||||
@@ -944,10 +941,6 @@ function updateStageEffects(runtime, viewerTheme, themeSettings, radius, floorZ
|
||||
}
|
||||
}
|
||||
|
||||
function isTrackpadLikeWheelEvent(event) {
|
||||
return event.ctrlKey || (event.deltaMode === 0 && Math.abs(event.deltaY) < 20);
|
||||
}
|
||||
|
||||
function normalizeViewportFrameInsets(value = {}) {
|
||||
const normalizeInset = (inset) => {
|
||||
const numericInset = Number(inset);
|
||||
@@ -3362,7 +3355,6 @@ const CadViewer = forwardRef(function CadViewer({
|
||||
getActiveViewPlaneFaceId,
|
||||
cancelCameraTransition,
|
||||
clearKeyboardOrbitState,
|
||||
isTrackpadLikeWheelEvent,
|
||||
getKeyboardOrbitCommand,
|
||||
getKeyboardOrbitAxes,
|
||||
applyOrbitDelta,
|
||||
@@ -3385,9 +3377,6 @@ const CadViewer = forwardRef(function CadViewer({
|
||||
INTERACTION_PIXEL_RATIO_CAP,
|
||||
IDLE_PIXEL_RATIO_CAP,
|
||||
INTERACTION_IDLE_DELAY_MS,
|
||||
TRACKPAD_PINCH_ZOOM_SPEED,
|
||||
COARSE_POINTER_PINCH_ZOOM_SPEED,
|
||||
ACCELERATED_WHEEL_ZOOM_SPEED,
|
||||
KEYBOARD_ORBIT_NUDGE_RAD,
|
||||
defaultGridRadius,
|
||||
sceneScaleMode: normalizedSceneScaleMode,
|
||||
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
import {
|
||||
resolveInteractionPixelRatioCap
|
||||
} from "cadjs/lib/viewer/renderQuality";
|
||||
import { updateOrbitControls } from "../orbitControls.js";
|
||||
import {
|
||||
updateOrbitControls,
|
||||
wheelZoomDistanceMultiplier
|
||||
} from "../orbitControls.js";
|
||||
|
||||
function createWebGlRenderer(THREE) {
|
||||
return createCadWebGlRenderer(THREE, {
|
||||
@@ -36,7 +39,6 @@ export function useViewerRuntime({
|
||||
getActiveViewPlaneFaceId,
|
||||
cancelCameraTransition,
|
||||
clearKeyboardOrbitState,
|
||||
isTrackpadLikeWheelEvent,
|
||||
getKeyboardOrbitCommand,
|
||||
getKeyboardOrbitAxes,
|
||||
applyOrbitDelta,
|
||||
@@ -59,9 +61,6 @@ export function useViewerRuntime({
|
||||
INTERACTION_PIXEL_RATIO_CAP,
|
||||
IDLE_PIXEL_RATIO_CAP,
|
||||
INTERACTION_IDLE_DELAY_MS,
|
||||
TRACKPAD_PINCH_ZOOM_SPEED,
|
||||
COARSE_POINTER_PINCH_ZOOM_SPEED,
|
||||
ACCELERATED_WHEEL_ZOOM_SPEED,
|
||||
KEYBOARD_ORBIT_NUDGE_RAD,
|
||||
defaultGridRadius,
|
||||
sceneScaleMode,
|
||||
@@ -123,15 +122,7 @@ export function useViewerRuntime({
|
||||
? window.matchMedia("(pointer: coarse)")
|
||||
: null;
|
||||
const prefersCoarsePointer = coarsePointerQuery?.matches ?? false;
|
||||
const getOrbitControlsPixelRatioBucket = () => Math.max((window.devicePixelRatio || 1) | 0, 1);
|
||||
const initialWheelPixelRatioBucket = getOrbitControlsPixelRatioBucket();
|
||||
const getDefaultZoomSpeed = () => (prefersCoarsePointer ? COARSE_POINTER_ZOOM_SPEED : DEFAULT_ZOOM_SPEED);
|
||||
const getPinchZoomSpeed = () => (prefersCoarsePointer ? COARSE_POINTER_PINCH_ZOOM_SPEED : TRACKPAD_PINCH_ZOOM_SPEED);
|
||||
const getWheelZoomSpeed = (baseZoomSpeed) => {
|
||||
// OrbitControls divides wheel deltas by a floored devicePixelRatio internally.
|
||||
// Browser zoom changes that bucket, so scale our speed back to the initial bucket.
|
||||
return baseZoomSpeed * (getOrbitControlsPixelRatioBucket() / initialWheelPixelRatioBucket);
|
||||
};
|
||||
const width = container.clientWidth || 800;
|
||||
const height = container.clientHeight || 640;
|
||||
|
||||
@@ -173,7 +164,12 @@ export function useViewerRuntime({
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
renderer.setPixelRatio(getPixelRatioCap(IDLE_PIXEL_RATIO_CAP));
|
||||
renderer.setSize(width, height);
|
||||
// Keep the canvas coupled to its flex container. ResizeObserver updates the
|
||||
// drawing buffer while CSS keeps the visible canvas at the current layout size.
|
||||
renderer.setSize(width, height, false);
|
||||
renderer.domElement.style.display = "block";
|
||||
renderer.domElement.style.width = "100%";
|
||||
renderer.domElement.style.height = "100%";
|
||||
container.innerHTML = "";
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
@@ -428,11 +424,24 @@ export function useViewerRuntime({
|
||||
}, INTERACTION_IDLE_DELAY_MS);
|
||||
};
|
||||
|
||||
const onResize = () => {
|
||||
let resizeRafId = 0;
|
||||
let lastResizeWidth = width;
|
||||
let lastResizeHeight = height;
|
||||
const resizeAndRenderFrame = () => {
|
||||
resizeRafId = 0;
|
||||
const w = container.clientWidth || 800;
|
||||
const h = container.clientHeight || 640;
|
||||
if (w < 2 || h < 2) {
|
||||
return;
|
||||
}
|
||||
if (Math.abs(w - lastResizeWidth) < 1 && Math.abs(h - lastResizeHeight) < 1) {
|
||||
requestRender();
|
||||
return;
|
||||
}
|
||||
lastResizeWidth = w;
|
||||
lastResizeHeight = h;
|
||||
applyRenderQuality(interactionState.pixelRatioCap);
|
||||
renderer.setSize(w, h);
|
||||
renderer.setSize(w, h, false);
|
||||
syncCameraViewport(perspectiveCamera, w, h);
|
||||
syncCameraViewport(orthographicCamera, w, h);
|
||||
applyCameraFrameInsets?.(runtimeRef.current, frameInsetsRef?.current, { updateProjection: false });
|
||||
@@ -440,8 +449,15 @@ export function useViewerRuntime({
|
||||
syncDrawingCanvasSize(runtimeRef.current);
|
||||
renderDrawingOverlay();
|
||||
runtimeRef.current?.onViewportResize?.();
|
||||
renderer.render(scene, runtimeRef.current?.camera || camera);
|
||||
requestRender();
|
||||
};
|
||||
const onResize = () => {
|
||||
if (resizeRafId) {
|
||||
return;
|
||||
}
|
||||
resizeRafId = window.requestAnimationFrame(resizeAndRenderFrame);
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
const resizeObserver = typeof ResizeObserver === "function"
|
||||
? new ResizeObserver(() => {
|
||||
@@ -478,16 +494,67 @@ export function useViewerRuntime({
|
||||
controlsStartDistance = null;
|
||||
scheduleIdleQuality();
|
||||
};
|
||||
const applyWheelZoom = (event) => {
|
||||
const activeRuntime = runtimeRef.current;
|
||||
const activeCamera = activeRuntime?.camera;
|
||||
const activeControls = activeRuntime?.controls;
|
||||
if (!activeRuntime?.THREE || !activeCamera || !activeControls?.target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const distanceMultiplier = wheelZoomDistanceMultiplier(event);
|
||||
if (!Number.isFinite(distanceMultiplier) || distanceMultiplier <= 0 || distanceMultiplier === 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (activeCamera.isOrthographicCamera) {
|
||||
const minZoom = Number.isFinite(Number(activeControls.minZoom)) && Number(activeControls.minZoom) > 0
|
||||
? Number(activeControls.minZoom)
|
||||
: 0;
|
||||
const maxZoom = Number.isFinite(Number(activeControls.maxZoom)) && Number(activeControls.maxZoom) > 0
|
||||
? Number(activeControls.maxZoom)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
activeCamera.zoom = Math.max(minZoom, Math.min(maxZoom, activeCamera.zoom / distanceMultiplier));
|
||||
activeCamera.updateProjectionMatrix?.();
|
||||
} else {
|
||||
const offset = activeCamera.position.clone().sub(activeControls.target);
|
||||
const currentDistance = offset.length();
|
||||
if (!Number.isFinite(currentDistance) || currentDistance <= 1e-6) {
|
||||
return false;
|
||||
}
|
||||
const minDistance = Number.isFinite(Number(activeControls.minDistance))
|
||||
? Number(activeControls.minDistance)
|
||||
: 0.01;
|
||||
const maxDistance = Number.isFinite(Number(activeControls.maxDistance)) && Number(activeControls.maxDistance) > 0
|
||||
? Number(activeControls.maxDistance)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const nextDistance = Math.max(minDistance, Math.min(maxDistance, currentDistance * distanceMultiplier));
|
||||
activeCamera.position.copy(
|
||||
activeControls.target.clone().add(offset.normalize().multiplyScalar(nextDistance))
|
||||
);
|
||||
activeCamera.zoom = 1;
|
||||
activeCamera.updateProjectionMatrix?.();
|
||||
}
|
||||
|
||||
activeCamera.lookAt(activeControls.target);
|
||||
activeControls.update?.();
|
||||
applyCameraFrameInsets?.(activeRuntime, frameInsetsRef?.current, { updateProjection: false });
|
||||
emitPerspectiveChange(activeRuntime);
|
||||
requestRender();
|
||||
return true;
|
||||
};
|
||||
const handleWheel = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation?.();
|
||||
runtimeRef.current?.onManualCameraInteraction?.("wheel");
|
||||
cancelCameraTransition(runtimeRef.current);
|
||||
controls.enableDamping = false;
|
||||
controls.zoomSpeed = getWheelZoomSpeed(isTrackpadLikeWheelEvent(event)
|
||||
? getPinchZoomSpeed()
|
||||
: ACCELERATED_WHEEL_ZOOM_SPEED);
|
||||
beginInteraction();
|
||||
if (applyWheelZoom(event)) {
|
||||
scheduleIdleQuality();
|
||||
}
|
||||
};
|
||||
const wheelListenerOptions = { passive: true, capture: true };
|
||||
const wheelListenerOptions = { passive: false, capture: true };
|
||||
|
||||
controls.addEventListener("start", handleControlsStart);
|
||||
controls.addEventListener("change", handleControlsChange);
|
||||
@@ -661,6 +728,9 @@ export function useViewerRuntime({
|
||||
window.clearTimeout(runtime.interactionState.renderFallbackTimerId);
|
||||
}
|
||||
cancelCameraTransition(runtime, { scheduleIdle: false });
|
||||
if (resizeRafId) {
|
||||
window.cancelAnimationFrame(resizeRafId);
|
||||
}
|
||||
window.cancelAnimationFrame(runtime.rafId);
|
||||
window.removeEventListener("resize", runtime.onResize);
|
||||
runtime.resizeObserver?.disconnect();
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
const MAX_ORBIT_DELTA_SECONDS = 1;
|
||||
const WHEEL_PIXEL_STEP = 100;
|
||||
const WHEEL_LINE_STEP = 3;
|
||||
const WHEEL_PAGE_STEP = 9;
|
||||
const WHEEL_ZOOM_STEP_SCALE = 0.08;
|
||||
const MAX_WHEEL_ZOOM_STEPS = 3;
|
||||
export const PREVIEW_ORBIT_SECONDS_PER_TURN = 60;
|
||||
export const PREVIEW_AUTO_ROTATE_SPEED = 60 / PREVIEW_ORBIT_SECONDS_PER_TURN;
|
||||
|
||||
@@ -35,3 +40,25 @@ export function updateOrbitControls(controls, timestamp, state) {
|
||||
}
|
||||
return deltaSeconds === null ? controls.update() : controls.update(deltaSeconds);
|
||||
}
|
||||
|
||||
export function normalizedWheelZoomSteps(event) {
|
||||
const deltaY = Number(event?.deltaY);
|
||||
if (!Number.isFinite(deltaY) || deltaY === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let divisor = WHEEL_PIXEL_STEP;
|
||||
if (event?.deltaMode === 1) {
|
||||
divisor = WHEEL_LINE_STEP;
|
||||
} else if (event?.deltaMode === 2) {
|
||||
divisor = WHEEL_PAGE_STEP;
|
||||
}
|
||||
|
||||
const steps = deltaY / divisor;
|
||||
return Math.max(-MAX_WHEEL_ZOOM_STEPS, Math.min(MAX_WHEEL_ZOOM_STEPS, steps));
|
||||
}
|
||||
|
||||
export function wheelZoomDistanceMultiplier(event) {
|
||||
const steps = normalizedWheelZoomSteps(event);
|
||||
return steps === 0 ? 1 : Math.exp(steps * WHEEL_ZOOM_STEP_SCALE);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
normalizedWheelZoomSteps,
|
||||
orbitControlsDeltaSeconds,
|
||||
PREVIEW_AUTO_ROTATE_SPEED,
|
||||
PREVIEW_ORBIT_SECONDS_PER_TURN,
|
||||
updateOrbitControls
|
||||
updateOrbitControls,
|
||||
wheelZoomDistanceMultiplier
|
||||
} from "./orbitControls.js";
|
||||
|
||||
test("preview auto-rotate speed uses the configured full-turn duration", () => {
|
||||
@@ -56,3 +58,19 @@ test("updateOrbitControls resets timing when auto-rotate is inactive", () => {
|
||||
assert.deepEqual(updateArgs, [[]]);
|
||||
assert.equal(state.orbitControlsLastTimestamp, 0);
|
||||
});
|
||||
|
||||
test("normalizedWheelZoomSteps normalizes Chrome pixel wheel deltas", () => {
|
||||
assert.equal(normalizedWheelZoomSteps({ deltaY: 100, deltaMode: 0 }), 1);
|
||||
assert.equal(normalizedWheelZoomSteps({ deltaY: -100, deltaMode: 0 }), -1);
|
||||
});
|
||||
|
||||
test("normalizedWheelZoomSteps caps very large wheel deltas", () => {
|
||||
assert.equal(normalizedWheelZoomSteps({ deltaY: 1000, deltaMode: 0 }), 3);
|
||||
assert.equal(normalizedWheelZoomSteps({ deltaY: -1000, deltaMode: 0 }), -3);
|
||||
});
|
||||
|
||||
test("wheelZoomDistanceMultiplier keeps one wheel event away from zoom limits", () => {
|
||||
assert.ok(wheelZoomDistanceMultiplier({ deltaY: 100, deltaMode: 0 }) < 1.1);
|
||||
assert.ok(wheelZoomDistanceMultiplier({ deltaY: -100, deltaMode: 0 }) > 0.9);
|
||||
assert.ok(wheelZoomDistanceMultiplier({ deltaY: 1000, deltaMode: 0 }) < 1.3);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user