更新功能

This commit is contained in:
2026-08-26 14:13:11 +08:00
parent beaa5dd9fe
commit a508e6f07e
47 changed files with 5616 additions and 520 deletions
-342
View File
@@ -31,7 +31,6 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.185.4",
"puppeteer-core": "^25.8.0",
"tailwindcss": "^4",
"tsx": "^4.20.6",
"typescript": "^5"
@@ -1491,35 +1490,6 @@
"node": ">= 10"
}
},
"node_modules/@puppeteer/browsers": {
"version": "3.2.1",
"resolved": "https://registry.npmmirror.com/@puppeteer/browsers/-/browsers-3.2.1.tgz",
"integrity": "sha512-KDz+3qDRdBAlRlMjmKyj6dEs33YHTk/xRHEENSXq6TNnhgoU15ruSHtEBeVF6OZ9tBDY55Se4P0nFMNsipzU9A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"modern-tar": "^0.8.0",
"yargs": "^18.0.0"
},
"bin": {
"browsers": "lib/main-cli.js"
},
"engines": {
"node": ">=22.12.0"
},
"peerDependencies": {
"proxy-agent": ">=8.0.1",
"yauzl": "^2.10.0 || ^3.4.0"
},
"peerDependenciesMeta": {
"proxy-agent": {
"optional": true
},
"yauzl": {
"optional": true
}
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/@radix-ui/number/-/number-1.1.3.tgz",
@@ -3455,32 +3425,6 @@
}
}
},
"node_modules/ansi-regex": {
"version": "6.3.0",
"resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.3.0.tgz",
"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz",
@@ -3557,72 +3501,12 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chromium-bidi": {
"version": "17.0.2",
"resolved": "https://registry.npmmirror.com/chromium-bidi/-/chromium-bidi-17.0.2.tgz",
"integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"mitt": "^3.0.1",
"zod": "^3.24.1"
},
"engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0"
},
"peerDependencies": {
"devtools-protocol": "*"
}
},
"node_modules/chromium-bidi/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmmirror.com/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cliui/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz",
@@ -3664,21 +3548,6 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/devtools-protocol": {
"version": "0.0.1666840",
"resolved": "https://registry.npmmirror.com/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz",
"integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"dev": true,
"license": "MIT"
},
"node_modules/enhanced-resolve": {
"version": "5.24.5",
"resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
@@ -3735,16 +3604,6 @@
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/eventsource-parser": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
@@ -3776,29 +3635,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-east-asian-width": {
"version": "1.6.0",
"resolved": "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz",
@@ -4118,23 +3954,6 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"dev": true,
"license": "MIT"
},
"node_modules/modern-tar": {
"version": "0.8.4",
"resolved": "https://registry.npmmirror.com/modern-tar/-/modern-tar-0.8.4.tgz",
"integrity": "sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/nanoid": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-6.0.1.tgz",
@@ -4315,24 +4134,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/puppeteer-core": {
"version": "25.8.0",
"resolved": "https://registry.npmmirror.com/puppeteer-core/-/puppeteer-core-25.8.0.tgz",
"integrity": "sha512-LDOrawV8vfCVk+yLj2ozvajNP4Sv3OV9y3Tpiyy2g2Z+aQlbcozP6KJfI4iSBq7YQER+86ihEtPa5ioiZyWxMQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "3.2.1",
"chromium-bidi": "17.0.2",
"devtools-protocol": "0.0.1666840",
"typed-query-selector": "^2.12.2",
"webdriver-bidi-protocol": "0.4.2",
"ws": "^8.21.1"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/radix-ui": {
"version": "1.6.7",
"resolved": "https://registry.npmmirror.com/radix-ui/-/radix-ui-1.6.7.tgz",
@@ -4614,39 +4415,6 @@
"node": ">=0.10.0"
}
},
"node_modules/string-width": {
"version": "8.2.2",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-8.2.2.tgz",
"integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.5.0",
"strip-ansi": "^7.1.2"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmmirror.com/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -4767,13 +4535,6 @@
"fsevents": "~2.3.3"
}
},
"node_modules/typed-query-selector": {
"version": "2.12.2",
"resolved": "https://registry.npmmirror.com/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
"dev": true,
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
@@ -4910,109 +4671,6 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/webdriver-bidi-protocol": {
"version": "0.4.2",
"resolved": "https://registry.npmmirror.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz",
"integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "18.1.0",
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-18.1.0.tgz",
"integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^9.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"string-width": "^8.2.1",
"y18n": "^5.0.5",
"yargs-parser": "^22.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yargs-parser": {
"version": "22.0.0",
"resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz",
-1
View File
@@ -33,7 +33,6 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.185.4",
"puppeteer-core": "^25.8.0",
"tailwindcss": "^4",
"tsx": "^4.20.6",
"typescript": "^5"
+9 -1
View File
@@ -394,7 +394,15 @@ button:disabled {
.config-warning { display: flex; flex: 0 0 auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--ui-error-border); background: var(--ui-error-bg); color: var(--ui-error-text); font-size: 12px; padding: 8px 12px; }
.studio-main { display: flex; min-height: 0; flex: 1; }
.agent-pane { display: flex; width: 420px; min-width: 0; min-height: 0; flex: 0 0 auto; flex-direction: column; border-right: 1px solid var(--ui-border); background: var(--ui-panel); }
.preview-pane { min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); }
.preview-pane { position: relative; min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); }
.generation-status { position: absolute; z-index: 30; top: 12px; right: 12px; width: min(260px, calc(100% - 24px)); max-height: min(42vh, 360px); overflow: auto; border: 1px solid var(--ui-border); border-radius: 6px; background: var(--ui-glass-popover); box-shadow: var(--ui-shadow-soft); backdrop-filter: blur(12px); color: var(--ui-text); padding: 10px; font-size: 12px; }
.generation-status-heading { display: flex; align-items: center; gap: 6px; color: var(--ui-text-strong); font-weight: 650; }
.generation-status-active { margin: 6px 0 8px; color: var(--ui-accent-text); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
.generation-status ul { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
.generation-status li { display: flex; justify-content: space-between; gap: 8px; border-top: 1px solid var(--ui-border-muted); padding-top: 4px; color: var(--ui-text-muted); }
.generation-status li[data-status="completed"] small { color: var(--ui-success); }
.generation-status li[data-status="planned"] small { color: var(--ui-text-subtle); }
.generation-status li[data-status="failed"] small { color: var(--ui-error); }
.agent-thread-shell, .thread-root { display: flex; min-height: 0; flex: 1; flex-direction: column; }
.agent-thread-shell { position: relative; }
.spin { animation: ui-spin 900ms linear infinite; }
+93 -8
View File
@@ -6,13 +6,14 @@ import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { AlertCircle, Box, Loader2, Moon, Sun } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { latestSuccessfulResult } from "@/lib/cad-artifacts";
import { activeCheckpointPreview, latestSuccessfulResult } from "@/lib/cad-artifacts";
import { normalizeCadMessages } from "@/lib/cad-messages";
import type { ViewerSelectionContext } from "@/lib/viewer-selection";
import type {
BackendConfig,
CadError,
CadAttachment,
CadProgress,
CadResult,
CadUIMessage,
ConversationRecord,
@@ -39,6 +40,8 @@ export function AgentStudio() {
const [modelId, setModelId] = useState("");
const [theme, setTheme] = useState<"light" | "dark">("light");
const [viewerSelection, setViewerSelection] = useState<ViewerSelectionContext | null>(null);
const [taskRunning, setTaskRunning] = useState(false);
const [taskRecord, setTaskRecord] = useState<TaskRecord | null>(null);
const syncUrl = useCallback((conversation: string, task: string) => {
const params = new URLSearchParams(window.location.search);
@@ -73,10 +76,12 @@ export function AgentStudio() {
const taskId = urlTaskId || conversation.current_task_id || "";
let restored: CadResult | null = null;
let restoredTask: TaskRecord | null = null;
if (taskId) {
const taskResponse = await fetch(`/api/tasks/${encodeURIComponent(taskId)}`, { cache: "no-store" });
if (taskResponse.ok) {
restored = latestSuccessfulResult((await taskResponse.json()) as TaskRecord);
restoredTask = (await taskResponse.json()) as TaskRecord;
restored = activeCheckpointPreview(restoredTask) ?? latestSuccessfulResult(restoredTask);
}
}
@@ -90,6 +95,8 @@ export function AgentStudio() {
setProviderId(defaultProvider?.id || "");
setModelId(defaultProvider?.models.find((model) => model.id === nextConfig.default_model)?.id || defaultProvider?.models[0]?.id || "");
setCadResult(restored);
setTaskRunning(restoredTask?.lifecycle === "running");
setTaskRecord(restoredTask);
setLoadState("ready");
syncUrl(nextConversationId, taskId);
} catch (error) {
@@ -127,6 +134,7 @@ export function AgentStudio() {
setViewerSelection(null);
setSelectedTaskId(result.taskId);
setLastError("");
if (result.lifecycle) setTaskRunning(result.lifecycle === "running");
if (conversationId) {
syncUrl(conversationId, result.taskId);
void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, {
@@ -137,8 +145,24 @@ export function AgentStudio() {
}
}, [conversationId, syncUrl]);
const handleTaskState = useCallback((progress: CadProgress) => {
const taskId = String(progress.taskId || "");
if (taskId) {
setSelectedTaskId(taskId);
if (conversationId) {
syncUrl(conversationId, taskId);
void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, {
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentTaskId: taskId }),
});
}
}
if (progress.step === "generation_plan" && progress.status === "running") setTaskRunning(true);
if (progress.step === "task_terminal") setTaskRunning(progress.lifecycle === "running");
}, [conversationId, syncUrl]);
const handleError = useCallback((error: CadError) => {
setLastError(error.message);
if (error.stage === "generation") setTaskRunning(false);
}, []);
const handleUpload = useCallback(async (files: FileList | null) => {
@@ -167,6 +191,34 @@ export function AgentStudio() {
}
}, [conversationId]);
useEffect(() => {
if (!taskRunning || !selectedTaskId) return;
let cancelled = false;
const refresh = async () => {
try {
const response = await fetch(`/api/tasks/${encodeURIComponent(selectedTaskId)}`, { cache: "no-store" });
if (!response.ok) return;
const next = await response.json() as TaskRecord;
if (cancelled) return;
setTaskRecord(next);
const running = next.lifecycle === "running";
setTaskRunning(running);
if (running) {
const preview = activeCheckpointPreview(next);
if (preview) setCadResult(preview);
} else {
const restored = latestSuccessfulResult(next);
if (restored) setCadResult(restored);
}
} catch {
// Keep the persisted lock until a later poll can prove a terminal state.
}
};
void refresh();
const timer = window.setInterval(() => void refresh(), 2000);
return () => { cancelled = true; window.clearInterval(timer); };
}, [selectedTaskId, taskRunning]);
if (loadState === "loading") {
return <StudioLoading />;
}
@@ -185,6 +237,7 @@ export function AgentStudio() {
initialMessages={initialMessages}
onCadResult={handleResult}
onCadError={handleError}
onCadProgress={handleTaskState}
>
<StudioShell
config={config}
@@ -203,6 +256,8 @@ export function AgentStudio() {
onCadResult={handleResult}
onCadError={handleError}
onSelectionChange={setViewerSelection}
taskRunning={taskRunning}
taskRecord={taskRecord}
/>
</AgentRuntime>
);
@@ -217,6 +272,7 @@ function AgentRuntime({
initialMessages,
onCadResult,
onCadError,
onCadProgress,
children,
}: {
conversationId: string;
@@ -227,6 +283,7 @@ function AgentRuntime({
initialMessages: CadUIMessage[];
onCadResult: (result: CadResult) => void;
onCadError: (error: CadError) => void;
onCadProgress: (progress: CadProgress) => void;
children: React.ReactNode;
}) {
const conversationRef = useRef(conversationId);
@@ -236,6 +293,7 @@ function AgentRuntime({
const viewerSelectionRef = useRef(viewerSelection);
const onCadResultRef = useRef(onCadResult);
const onCadErrorRef = useRef(onCadError);
const onCadProgressRef = useRef(onCadProgress);
conversationRef.current = conversationId;
taskRef.current = selectedTaskId;
providerRef.current = providerId;
@@ -243,6 +301,7 @@ function AgentRuntime({
viewerSelectionRef.current = viewerSelection;
onCadResultRef.current = onCadResult;
onCadErrorRef.current = onCadError;
onCadProgressRef.current = onCadProgress;
const transport = useMemo(() => new DefaultChatTransport<CadUIMessage>({
api: "/api/chat",
@@ -267,6 +326,9 @@ function AgentRuntime({
messages: initialMessages,
transport,
onData: (part) => {
if (part.type === "data-cad-progress") {
onCadProgressRef.current(part.data as CadProgress);
}
if (part.type === "data-cad-result") {
onCadResultRef.current(part.data as CadResult);
taskRef.current = (part.data as CadResult).taskId;
@@ -425,6 +487,8 @@ function StudioShell({
onCadResult,
onCadError,
onSelectionChange,
taskRunning,
taskRecord,
}: {
config: BackendConfig | null;
cadResult: CadResult | null;
@@ -442,8 +506,10 @@ function StudioShell({
onCadResult: (result: CadResult) => void;
onCadError: (error: CadError) => void;
onSelectionChange: (selection: ViewerSelectionContext | null) => void;
taskRunning: boolean;
taskRecord: TaskRecord | null;
}) {
const running = useAuiState((state) => state.thread.isRunning);
const running = useAuiState((state) => state.thread.isRunning) || taskRunning;
const provider = config?.providers.find((item) => item.id === providerId);
const handleViewerError = useCallback((message: string) => {
onCadError({ stage: "viewer", message });
@@ -453,24 +519,43 @@ function StudioShell({
<header className="app-header">
<div className="app-brand"><Box size={16} /><strong>CDSL CAD Studio</strong>{cadResult ? <span className="task-badge">{cadResult.taskId}</span> : null}</div>
<div className="app-controls">
<select aria-label="模型提供商" value={providerId} onChange={(event) => { const id = event.target.value; onProviderChange(id); onModelChange(config?.providers.find((item) => item.id === id)?.models[0]?.id || ""); }}>
<select aria-label="模型提供商" value={providerId} disabled={running} onChange={(event) => { const id = event.target.value; onProviderChange(id); onModelChange(config?.providers.find((item) => item.id === id)?.models[0]?.id || ""); }}>
{config?.providers.map((item) => <option key={item.id} value={item.id}>{item.label}</option>)}
</select>
<select aria-label="模型" value={modelId} onChange={(event) => onModelChange(event.target.value)}>
<select aria-label="模型" value={modelId} disabled={running} onChange={(event) => onModelChange(event.target.value)}>
{provider?.models.map((model) => <option key={model.id} value={model.id}>{model.id}{model.vision ? " · Vision" : ""}</option>)}
</select>
<button className="theme-button" type="button" title="切换亮暗主题" onClick={onToggleTheme}>{theme === "light" ? <Moon size={16} /> : <Sun size={16} />}</button>
<button className="theme-button" type="button" title="切换亮暗主题" disabled={running} onClick={onToggleTheme}>{theme === "light" ? <Moon size={16} /> : <Sun size={16} />}</button>
</div>
</header>
{!config?.configured ? <div className="config-warning"><AlertCircle size={16} /><span></span></div> : null}
{config?.incremental_generation && !config.review_configured ? <div className="config-warning"><AlertCircle size={16} /><span>{config.review_error || "请配置独立视觉模型与 Chromium。"}</span></div> : null}
<div className="studio-main">
<aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} uploadError={uploadError} onUpload={onUpload} /></aside>
<section className="preview-pane"><CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onResult={onCadResult} onError={handleViewerError} onSelectionChange={onSelectionChange} /></section>
<aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} uploadError={uploadError} taskRunning={taskRunning} onUpload={onUpload} /></aside>
<section className="preview-pane">
<GenerationStatus task={taskRecord} />
<CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onResult={onCadResult} onError={handleViewerError} onSelectionChange={onSelectionChange} />
</section>
</div>
</main>
);
}
function GenerationStatus({ task }: { task: TaskRecord | null }) {
const plan = task?.generation_plan;
const nodes = Array.isArray(plan?.nodes) ? plan.nodes.filter((node): node is Record<string, unknown> => Boolean(node && typeof node === "object")) : [];
if (task?.lifecycle !== "running") return null;
return (
<aside className="generation-status" aria-live="polite">
<div className="generation-status-heading"><Loader2 className="spin" size={14} /><span></span></div>
<div className="generation-status-active">{task.active_node_id || "正在生成计划"}</div>
{nodes.length ? <ul>{nodes.map((node) => <li key={String(node.id || "node")} data-status={String(node.status || "planned")}>
<span>{String(node.id || "node")}</span><small>{String(node.status || "planned")}</small>
</li>)}</ul> : null}
</aside>
);
}
function StudioLoading() {
return (
<main className="boot-screen">
+9 -8
View File
@@ -1,20 +1,21 @@
"use client";
import { Bot, Check, CircleAlert, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Sparkles, Square, Upload } from "lucide-react";
import { Bot, Check, CircleAlert, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Sparkles, Upload } from "lucide-react";
import { useRef, useState, type ChangeEvent, type DragEvent } from "react";
import { ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAuiState } from "@assistant-ui/react";
import type { CadAttachment } from "@/lib/cad-types";
import { CadErrorPart, CadImageAnalysisPart, CadProgressPart, CadResultPart, TextPart } from "./cad-message-parts";
export function AgentThread({ attachments, uploading, uploadError, onUpload }: {
export function AgentThread({ attachments, uploading, uploadError, taskRunning = false, onUpload }: {
attachments: CadAttachment[];
uploading: boolean;
uploadError: string;
taskRunning?: boolean;
onUpload: (files: FileList | null) => void;
}) {
const fileInput = useRef<HTMLInputElement>(null);
const dragDepth = useRef(0);
const running = useAuiState((state) => state.thread.isRunning);
const running = useAuiState((state) => state.thread.isRunning) || taskRunning;
const [isDraggingFiles, setIsDraggingFiles] = useState(false);
const canUpload = !uploading && !running;
@@ -64,7 +65,7 @@ export function AgentThread({ attachments, uploading, uploadError, onUpload }: {
</ThreadPrimitive.Empty>
<div className="message-list"><ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} /></div>
</ThreadPrimitive.Viewport>
<Composer fileInput={fileInput} uploading={uploading} onUpload={onUpload} />
<Composer fileInput={fileInput} uploading={uploading} taskRunning={taskRunning} onUpload={onUpload} />
</ThreadPrimitive.Root>
{isDraggingFiles ? <div className="file-drop-overlay" role="status" aria-live="polite"><Upload size={24} /><span></span></div> : null}
</div>
@@ -103,8 +104,8 @@ function AssistantMessage() {
);
}
function Composer({ fileInput, uploading, onUpload }: { fileInput: React.RefObject<HTMLInputElement | null>; uploading: boolean; onUpload: (files: FileList | null) => void }) {
const running = useAuiState((state) => state.thread.isRunning);
function Composer({ fileInput, uploading, taskRunning = false, onUpload }: { fileInput: React.RefObject<HTMLInputElement | null>; uploading: boolean; taskRunning?: boolean; onUpload: (files: FileList | null) => void }) {
const running = useAuiState((state) => state.thread.isRunning) || taskRunning;
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
if (event.currentTarget.files?.length) onUpload(event.currentTarget.files);
event.currentTarget.value = "";
@@ -113,12 +114,12 @@ function Composer({ fileInput, uploading, onUpload }: { fileInput: React.RefObje
<div className="composer-shell">
<input ref={fileInput} className="composer-file-input" type="file" accept=".png,.jpg,.jpeg,.webp,.txt,.md,.csv,.json" multiple tabIndex={-1} aria-hidden="true" onChange={handleFileChange} />
<ComposerPrimitive.Root className="composer-root">
<ComposerPrimitive.Input aria-label="CAD 请求" className="composer-input" placeholder="描述要生成或修改的 CAD 模型..." submitMode="enter" rows={4} />
<ComposerPrimitive.Input aria-label="CAD 请求" className="composer-input" placeholder={running ? "CAD 正在生成,任务结束后可继续对话" : "描述要生成或修改的 CAD 模型..."} submitMode="enter" rows={4} disabled={running} />
<div className="composer-footer">
<span><Check size={14} aria-hidden="true" /> Enter Shift + Enter </span>
<div className="composer-actions">
{running ? (
<ComposerPrimitive.Cancel className="composer-command composer-cancel" title="停止生成" aria-label="停止生成"><Square size={14} fill="currentColor" aria-hidden="true" /></ComposerPrimitive.Cancel>
<span className="text-[11px] text-[var(--ui-text-subtle)]"><Loader2 className="mr-1 inline spin" size={13} aria-hidden="true" /></span>
) : (
<>
<button type="button" className="composer-command composer-upload" title="上传图片或文档" aria-label="上传图片或文档" aria-busy={uploading || undefined} disabled={uploading} onClick={() => fileInput.current?.click()}>{uploading ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <Paperclip size={14} aria-hidden="true" />}</button>
+41 -7
View File
@@ -29,14 +29,14 @@ export function CadProgressPart({ data }: { data: CadProgress }) {
}
export function CadResultPart({ data }: { data: CadResult }) {
const downloads: Array<[string, string]> = [
const downloads: Array<[string, string]> = data.checkpoint ? [] : [
["STEP", data.stepPath],
["CDSL", data.cdslPath],
["GLB", data.glbPath],
["报告", data.reportPath],
];
if (data.qualityPath) downloads.push(["质量报告", data.qualityPath]);
if (data.snapshotPaths?.length) downloads.push(["快照清单", data.snapshotPaths[0]]);
if (!data.checkpoint && data.qualityPath) downloads.push(["质量报告", data.qualityPath]);
if (!data.checkpoint && data.snapshotPaths?.length) downloads.push(["快照清单", data.snapshotPaths[0]]);
const qualityLabels: Record<string, string> = {
accepted: "验收通过",
built_with_warnings: "构建完成,有警告",
@@ -60,16 +60,16 @@ export function CadResultPart({ data }: { data: CadResult }) {
</div>
{data.assumptions?.length ? <div className="cad-result-notes"><strong></strong><span>{data.assumptions.join("")}</span></div> : null}
{data.referenceIds.length ? <div className="cad-result-notes"><strong></strong><span>{data.referenceIds.join("")}</span></div> : null}
<QualitySummary data={data} />
{!data.checkpoint ? <QualitySummary data={data} /> : null}
{data.snapshotStatus && data.snapshotStatus !== "unavailable" ? <div className="cad-result-notes"><strong></strong><span>{data.snapshotStatus}</span></div> : null}
<div className="download-row">
{downloads.length ? <div className="download-row">
{downloads.map(([label, path]) => (
<a key={label} className="download-link" href={encodeArtifactUrl(data.taskId, path)} download aria-label={`下载 ${label}`}>
<Download size={13} aria-hidden="true" />
{label}
</a>
))}
</div>
</div> : null}
</section>
);
}
@@ -114,9 +114,12 @@ function QualitySummary({ data }: { data: CadResult }) {
export function CadImageAnalysisPart({ data }: { data: CadImageAnalysis }) {
const dimensionCandidates = data.dimensionCandidates ?? data.requiredDimensions ?? [];
const profileCount = data.profiles?.length || 0;
const holeCount = data.holes?.length || 0;
const viewCount = data.views?.length || data.attachmentIds.length;
return (
<section className="cad-message cad-image-analysis" aria-label="图像分析">
<div className="cad-message-heading"><Ruler size={14} aria-hidden="true" /><span></span></div>
<div className="cad-message-heading"><Ruler size={14} aria-hidden="true" /><span>{data.observationStage === "survey" ? "图片勘测" : data.observationStage === "sketch" ? "草图候选" : "图像分析"}</span></div>
<div className="cad-image-part-type">{data.partType}</div>
<div className="image-analysis-section">
<span></span>
@@ -135,6 +138,37 @@ export function CadImageAnalysisPart({ data }: { data: CadImageAnalysis }) {
</li>)}
</ul>
</div> : null}
{(data.views?.length || profileCount || holeCount || data.measurements?.length || data.assumptions?.length) ? (
<details className="image-analysis-details">
<summary></summary>
<div className="image-analysis-section">
<span></span>
<p>{viewCount} {profileCount} {holeCount} /</p>
</div>
{data.profiles?.length ? <div className="image-analysis-section">
<span></span>
<ul className="image-dimension-list">
{data.profiles.slice(0, 12).map((profile) => <li key={profile.id}>
<strong>{profile.id}</strong>
<span>{profile.segments?.length || 0} {profile.closed ? "闭合" : "未确认闭合"}{profile.confidence == null ? "" : `,置信度 ${Math.round(profile.confidence * 100)}%`}</span>
</li>)}
</ul>
</div> : null}
{data.measurements?.length ? <div className="image-analysis-section">
<span></span>
<ul className="image-dimension-list">
{data.measurements.slice(0, 16).map((measurement, index) => <li key={`${measurement.name}-${index}`}>
<strong>{measurement.name}</strong>
<span>{measurement.value_mm == null ? "待确认" : `${measurement.value_mm} mm`} · {measurement.source || "image"}</span>
</li>)}
</ul>
</div> : null}
{data.uncertainties?.length ? <div className="image-analysis-section">
<span></span>
<p>{data.uncertainties.slice(0, 16).join("")}</p>
</div> : null}
</details>
) : null}
</section>
);
}
+43 -18
View File
@@ -51,6 +51,7 @@ function resultFromBackend(payload: Record<string, unknown>): CadResult {
parametersPath: typeof payload.parameters_path === "string" ? payload.parameters_path : undefined,
selectorPath: typeof payload.selector_path === "string" ? payload.selector_path : undefined,
edgesPath: typeof payload.edges_path === "string" ? payload.edges_path : undefined,
topologyPath: typeof payload.topology_path === "string" ? payload.topology_path : undefined,
summary: String(payload.summary || "Updated CDSL model"),
referenceIds: Array.isArray(payload.reference_ids) ? payload.reference_ids.map(String) : [],
engine: String(payload.engine || "cdsl_only"),
@@ -61,6 +62,8 @@ function resultFromBackend(payload: Record<string, unknown>): CadResult {
repairAttempts: Number(payload.repair_attempts || 0),
snapshotPaths: Array.isArray(payload.snapshot_paths) ? payload.snapshot_paths.map(String) : [],
snapshotStatus: String(payload.snapshot_status || "unavailable"),
checkpoint: Boolean(payload.checkpoint),
lifecycle: typeof payload.lifecycle === "string" ? payload.lifecycle : undefined,
};
}
@@ -225,14 +228,32 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
setLoadState((current) => current.kind === "ready" ? current : { kind: "loading" });
const glbUrl = encodeArtifactUrl(result.taskId, result.glbPath);
const selectorUrl = result.selectorPath ? encodeArtifactUrl(result.taskId, result.selectorPath) : "";
const topologyUrl = result.topologyPath ? encodeArtifactUrl(result.taskId, result.topologyPath) : "";
void Promise.all([
loadRenderGlb(glbUrl),
selectorUrl ? loadRenderJson(selectorUrl).catch(() => null) : Promise.resolve(null),
topologyUrl ? loadRenderJson(topologyUrl).catch(() => null) : Promise.resolve(null),
])
.then(([meshData, selectorSidecar]) => {
.then(([meshData, selectorSidecar, topologySnapshot]) => {
if (controller.signal.aborted) return;
const selectorRuntime = selectorSidecar && typeof selectorSidecar === "object"
? buildCdslSelectorRuntime(selectorSidecar, meshData)
const topologyMatchesRevision = topologySnapshot && typeof topologySnapshot === "object"
&& String((topologySnapshot as Record<string, unknown>).task_id || "") === result.taskId
&& String((topologySnapshot as Record<string, unknown>).revision_id || "") === result.revisionId;
const rawTopologyRecords = topologyMatchesRevision ? (topologySnapshot as Record<string, unknown>).records : null;
const topologyRecords = Array.isArray(rawTopologyRecords)
? rawTopologyRecords.filter((record): record is Record<string, unknown> => Boolean(record && typeof record === "object"))
: [];
const selectorPayload = selectorSidecar && typeof selectorSidecar === "object"
? {
...(selectorSidecar as Record<string, unknown>),
edges: Array.isArray((selectorSidecar as Record<string, unknown>).edges)
&& ((selectorSidecar as Record<string, unknown>).edges as unknown[]).length
? (selectorSidecar as Record<string, unknown>).edges
: topologyRecords.filter((record) => record.kind === "edge" && record.executable !== false),
}
: null;
const selectorRuntime = selectorPayload
? buildCdslSelectorRuntime(selectorPayload as Parameters<typeof buildCdslSelectorRuntime>[0], meshData)
: null;
setLoadState({ kind: "ready", meshData, selectorRuntime });
setHoveredReferenceId("");
@@ -254,7 +275,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
onError(error instanceof Error ? error.message : "CAD Viewer asset loading failed");
});
return () => controller.abort();
}, [onError, onSelectionChange, result?.glbPath, result?.revisionId, result?.selectorPath, result?.taskId]);
}, [onError, onSelectionChange, result?.glbPath, result?.revisionId, result?.selectorPath, result?.taskId, result?.topologyPath]);
useEffect(() => {
if (!reveal) return;
@@ -263,7 +284,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
}, [reveal]);
useEffect(() => {
if (!result) {
if (!result || result.checkpoint || isGenerating) {
setParameters([]);
setShowParameters(false);
return;
@@ -282,7 +303,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
if (!controller.signal.aborted) setParameterError(error instanceof Error ? error.message : "无法读取参数");
});
return () => controller.abort();
}, [result?.revisionId, result?.taskId]);
}, [isGenerating, result?.checkpoint, result?.revisionId, result?.taskId]);
const submitEdit = useCallback(async (operation: string, picks: Record<string, unknown>[]) => {
if (!result || !operation || !picks.length) return;
@@ -380,7 +401,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
}, [loadState, onSelectionChange, result]);
const commitParameters = useCallback(async (values: Record<string, number>) => {
if (!result || !Object.keys(values).length) return;
if (!result || result.checkpoint || isGenerating || !Object.keys(values).length) return;
const parameterId = Object.keys(values)[0];
setParameterPending(parameterId);
setParameterError("");
@@ -396,13 +417,17 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
} finally {
setParameterPending("");
}
}, [onResult, result]);
}, [isGenerating, onResult, result]);
const viewerTheme = useMemo(() => ({ ...VIEWER_THEME, colorMode: theme }), [theme]);
const activeToolDefinition = activeTool ? cadEditToolForOperation(activeTool) : null;
const pickableFaces = useMemo(
() => loadState.kind === "ready" ? loadState.selectorRuntime?.references.filter((reference) => reference.selectorType === "face") || [] : [],
[loadState]
() => !isGenerating && !result?.checkpoint && loadState.kind === "ready" ? loadState.selectorRuntime?.references.filter((reference) => reference.selectorType === "face") || [] : [],
[isGenerating, loadState, result?.checkpoint]
);
const pickableEdges = useMemo(
() => !isGenerating && !result?.checkpoint && loadState.kind === "ready" ? loadState.selectorRuntime?.edges || [] : [],
[isGenerating, loadState, result?.checkpoint]
);
if (loadState.kind === "empty") return <ViewerState icon={<Box size={20} />} text="3D 预览等待模型" />;
if (loadState.kind === "loading") return <ViewerState icon={<Loader2 className="spin" size={20} />} text="加载 CAD Viewer 资产..." />;
@@ -429,10 +454,10 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
selectedReferenceIds={selectedReferenceIds}
selectorRuntime={loadState.selectorRuntime}
pickableFaces={pickableFaces}
pickableEdges={[]}
pickableEdges={pickableEdges}
onHoverReferenceChange={setHoveredReferenceId}
onActivateReference={handleActivateReference}
editPointPickEnabled={Boolean(activeTool)}
editPointPickEnabled={Boolean(activeTool) && !isGenerating && !result?.checkpoint}
activeEditToolId={activeTool}
editToolPickKind={cadEditToolNextPickKind(activeTool, editPicks.length)}
editToolPicks={editPicks}
@@ -445,16 +470,16 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
onAiSelectionComplete={onAiSelectionComplete}
/>
<AiSelectionOverlay draft={aiSelectionDraft} />
<EditToolPickOverlay
{!isGenerating && !result?.checkpoint ? <EditToolPickOverlay
activeToolId={activeTool}
picks={editPicks}
hoverPick={editHoverPick}
parameters={editParameters}
/>
/> : null}
<EmbeddedCadEditToolbar
activeToolId={activeTool}
aiSelectionMode={selectionMode}
disabled={!result || editPending}
disabled={!result || editPending || isGenerating || Boolean(result?.checkpoint)}
unavailableToolIds={["add_chamfer", "add_fillet"]}
onSelectTool={(tool) => {
setActiveTool(tool);
@@ -473,13 +498,13 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
}}
/>
<EmbeddedCadViewToolbar
disabled={!result}
disabled={!result || isGenerating}
onResetView={() => viewerRef.current?.zoomToFit?.()}
onScreenshot={() => void viewerRef.current?.captureScreenshot?.({ filename: "cdsl-cad.png" })}
onParameters={() => setShowParameters(true)}
/>
{editPending || isGenerating ? <div className="viewer-loading"><Loader2 className="spin" size={16} /><span>{editPending ? "正在应用 CDSL 编辑..." : "正在生成 CDSL 模型..."}</span></div> : null}
{activeToolDefinition ? (
{activeToolDefinition && !isGenerating && !result?.checkpoint ? (
<div className="absolute bottom-3 left-3 z-30 w-[236px] border border-[var(--ui-border)] bg-[var(--ui-glass-popover)] p-3 text-[var(--ui-text-strong)] shadow-[var(--ui-shadow-soft)] backdrop-blur" data-viewer-interaction-overlay="true">
<div className="mb-2 text-xs font-semibold">{activeToolDefinition.label}</div>
<div className="grid gap-2">
@@ -543,7 +568,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
onClose={() => setShowParameters(false)}
onCommit={(id, value) => void commitParameters({ [id]: value })}
onReset={(values) => void commitParameters(values)}
downloads={[
downloads={result.checkpoint ? [] : [
{ label: "STEP", description: "CAD exchange", url: encodeArtifactUrl(result.taskId, result.stepPath) },
{ label: "CDSL", description: "Editable model", url: encodeArtifactUrl(result.taskId, result.cdslPath) },
{ label: "GLB", description: "Preview mesh", url: encodeArtifactUrl(result.taskId, result.glbPath) },
+20 -5
View File
@@ -6,11 +6,8 @@ export function encodeArtifactUrl(taskId: string, artifactPath: string) {
return `/api/tasks/${encodedTask}/artifacts/${encodedPath}`;
}
export function latestSuccessfulResult(task: TaskRecord | null): CadResult | null {
if (!task) return null;
const current =
task.revisions.find((revision) => revision.revision_id === task.current_revision) ??
[...task.revisions].reverse().find((revision) => revision.status === "success");
function resultForRevision(task: TaskRecord, revisionId: string, checkpoint: boolean): CadResult | null {
const current = task.revisions.find((revision) => revision.revision_id === revisionId);
if (
!current ||
current.status !== "success" ||
@@ -31,6 +28,7 @@ export function latestSuccessfulResult(task: TaskRecord | null): CadResult | nul
parametersPath: current.parameters_path,
selectorPath: current.selector_path,
edgesPath: current.edges_path,
topologyPath: current.topology_path,
summary: current.summary || "CDSL CAD model",
referenceIds: current.reference_ids || [],
engine: current.engine || "cdsl_only",
@@ -41,5 +39,22 @@ export function latestSuccessfulResult(task: TaskRecord | null): CadResult | nul
repairAttempts: current.repair_attempts || 0,
snapshotPaths: current.snapshot_manifest_path ? [current.snapshot_manifest_path] : [],
snapshotStatus: current.snapshot_status || "unavailable",
checkpoint,
lifecycle: task.lifecycle || "completed",
};
}
export function activeCheckpointPreview(task: TaskRecord | null): CadResult | null {
if (!task || task.lifecycle !== "running") return null;
const revisionId = task.preview_revision || task.active_revision || task.current_revision;
if (!revisionId) return null;
return resultForRevision(task, revisionId, true);
}
export function latestSuccessfulResult(task: TaskRecord | null): CadResult | null {
if (!task) return null;
const current =
task.revisions.find((revision) => revision.revision_id === (task.published_revision || task.current_revision)) ??
[...task.revisions].reverse().find((revision) => revision.status === "success" && revision.visibility !== "checkpoint");
return current ? resultForRevision(task, current.revision_id, current.visibility === "checkpoint") : null;
}
+46 -1
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { backendEventToUiChunk } from "./cad-stream";
import { latestSuccessfulResult } from "./cad-artifacts";
import { activeCheckpointPreview, latestSuccessfulResult } from "./cad-artifacts";
import { messagesForBackend } from "./cad-messages";
import type { CadUIMessage } from "./cad-types";
@@ -14,6 +14,23 @@ test("maps backend cad_result SSE into an AI SDK data part", () => {
assert.deepEqual("data" in chunk! ? chunk.data : null, { taskId: "cad_abc", revisionId: "rev_001" });
});
test("keeps progressive revisions as separate data parts", () => {
const first = backendEventToUiChunk({ event: "cad_result", data: { taskId: "cad_abc", revisionId: "rev_001" } }, "text_1");
const second = backendEventToUiChunk({ event: "cad_result", data: { taskId: "cad_abc", revisionId: "rev_002" } }, "text_1");
assert.notEqual(first?.id, second?.id);
});
test("maps visual repair review into a blocking progress state", () => {
const chunk = backendEventToUiChunk({
event: "render_review",
data: { taskId: "cad_abc", nodeId: "round", review: { verdict: "repair", confidence: 0.92, evidence: ["missing round"] } },
}, "text_1");
assert.equal(chunk?.type, "data-cad-progress");
assert.deepEqual("data" in chunk! ? chunk.data : null, {
step: "render_review", label: "视觉复核", status: "error", message: "missing round", taskId: "cad_abc", nodeId: "round",
});
});
test("maps structured image analysis SSE into an AI SDK data part", () => {
const data = {
attachmentIds: ["upload_flange"],
@@ -41,6 +58,34 @@ test("restores the latest successful task revision for the viewer", () => {
assert.equal(result?.summary, "done");
});
test("prefers the published revision over an active checkpoint", () => {
const result = latestSuccessfulResult({
task_id: "cad_abc",
current_revision: "rev_002",
active_revision: "rev_002",
published_revision: "rev_001",
lifecycle: "running",
revisions: [
{ revision_id: "rev_001", status: "success", visibility: "final", cdsl_path: "a", step_path: "b", glb_path: "c", report_path: "d" },
{ revision_id: "rev_002", status: "success", visibility: "checkpoint", cdsl_path: "aa", step_path: "bb", glb_path: "cc", report_path: "dd" },
],
});
assert.equal(result?.revisionId, "rev_001");
assert.equal(result?.checkpoint, false);
});
test("restores an active checkpoint only while the task is running", () => {
const result = activeCheckpointPreview({
task_id: "cad_abc", current_revision: "rev_002", active_revision: "rev_002", published_revision: "rev_001", lifecycle: "running",
revisions: [
{ revision_id: "rev_001", status: "success", visibility: "final", cdsl_path: "a", step_path: "b", glb_path: "c", report_path: "d" },
{ revision_id: "rev_002", status: "success", visibility: "checkpoint", cdsl_path: "aa", step_path: "bb", glb_path: "cc", report_path: "dd" },
],
});
assert.equal(result?.revisionId, "rev_002");
assert.equal(result?.checkpoint, true);
});
test("strips non-text and non-CAD parts before sending to FastAPI", () => {
const messages: CadUIMessage[] = [{
id: "m1",
+28 -1
View File
@@ -19,10 +19,37 @@ export function backendEventToUiChunk(
data: item.data,
};
}
if (["generation_plan", "checkpoint", "render_review", "rollback", "task_terminal"].includes(item.event)) {
const review = item.data.review && typeof item.data.review === "object"
? item.data.review as Record<string, unknown>
: null;
const status = item.event === "task_terminal"
? (String(item.data.lifecycle || "") === "failed" ? "error" : "success")
: item.event === "render_review" && String(review?.verdict || "") === "repair" && Number(review?.confidence || 0) >= 0.85
? "error"
: String(item.data.status || "running");
return {
type: "data-cad-progress",
id: `${item.event}_${String(item.data.taskId || Date.now())}_${String(item.data.nodeId || "")}`,
data: { step: item.event, label: ({
generation_plan: "生成计划", checkpoint: "构建检查点", render_review: "视觉复核", rollback: "回滚检查点", task_terminal: "生成任务",
} as Record<string, string>)[item.event], status, message: String(
item.data.message || item.data.reason || (review?.evidence instanceof Array ? review.evidence.join("") : ""),
),
...(item.data.taskId ? { taskId: String(item.data.taskId) } : {}),
...(item.data.nodeId ? { nodeId: String(item.data.nodeId) } : {}),
...(item.data.lifecycle ? { lifecycle: String(item.data.lifecycle) } : {}),
},
};
}
if (item.event === "cad_result") {
const taskId = String(item.data.taskId || "");
const revisionId = String(item.data.revisionId || Date.now());
return {
type: "data-cad-result",
id: `result_${String(item.data.taskId || Date.now())}`,
// Each revision is a distinct progressive result. Reusing only the task
// id makes the AI SDK reconcile intermediate revisions into one part.
id: `result_${taskId}_${revisionId}`,
data: item.data,
};
}
+59
View File
@@ -5,6 +5,9 @@ export type CadProgress = {
label: string;
status: "running" | "success" | "error" | string;
message?: string;
taskId?: string;
nodeId?: string;
lifecycle?: "running" | "completed" | "failed" | string;
};
export type CadResult = {
@@ -17,6 +20,7 @@ export type CadResult = {
parametersPath?: string;
selectorPath?: string;
edgesPath?: string;
topologyPath?: string;
summary: string;
referenceIds: string[];
engine: string;
@@ -27,6 +31,8 @@ export type CadResult = {
repairAttempts?: number;
snapshotPaths?: string[];
snapshotStatus?: string;
checkpoint?: boolean;
lifecycle?: "running" | "completed" | "failed" | string;
};
export type CadError = {
@@ -40,7 +46,32 @@ export type CadImageDimension = {
reason: string;
};
export type CadImageSegment = {
type: "line" | "arc" | "circle" | "polyline" | "unknown_curve" | string;
start?: number[];
end?: number[];
center?: number[];
radius_mm?: number | null;
points?: number[][];
confidence?: number | null;
notes?: string;
};
export type CadImageProfile = {
id: string;
role?: string;
plane_hint?: string;
closed?: boolean;
segments?: CadImageSegment[];
source_images?: string[];
confidence?: number | null;
uncertain?: string[];
notes?: string;
};
export type CadImageAnalysis = {
observationStage?: "survey" | "sketch" | "complete" | string;
schemaVersion?: string;
attachmentIds: string[];
partType: string;
visibleFeatures: string[];
@@ -48,6 +79,16 @@ export type CadImageAnalysis = {
dimensionCandidates?: CadImageDimension[];
// Legacy conversations stored this field before dimensions became optional.
requiredDimensions?: CadImageDimension[];
views?: Array<{ attachment_id?: string; view_role?: string; orientation?: string; quality?: string; confidence?: number | null }>;
overallGeometry?: Record<string, unknown>;
surfaces?: Array<Record<string, unknown>>;
profiles?: CadImageProfile[];
holes?: Array<Record<string, unknown>>;
bends?: Array<Record<string, unknown>>;
measurements?: Array<{ name: string; value_mm?: number | null; source?: string; confidence?: number | null; evidence?: string }>;
uncertainties?: string[];
assumptions?: string[];
cvHints?: Array<Record<string, unknown>>;
artifactPath?: string;
};
@@ -77,6 +118,10 @@ export type CadAttachment = {
size: number;
sha256: string;
extracted_path?: string;
width?: number;
height?: number;
orientation?: string;
format?: string;
};
export type TaskRevision = {
@@ -89,6 +134,7 @@ export type TaskRevision = {
parameters_path?: string;
selector_path?: string;
edges_path?: string;
topology_path?: string;
summary?: string;
reference_ids?: string[];
engine?: string;
@@ -101,11 +147,21 @@ export type TaskRevision = {
repair_attempts?: number;
snapshot_manifest_path?: string;
snapshot_status?: string;
visibility?: "checkpoint" | "final" | "superseded" | string;
node_id?: string;
render_manifest_path?: string;
visual_review_path?: string;
};
export type TaskRecord = {
task_id: string;
current_revision: string;
active_revision?: string;
published_revision?: string;
lifecycle?: "running" | "completed" | "failed" | string;
active_node_id?: string;
preview_revision?: string;
generation_plan?: Record<string, unknown> | null;
revisions: TaskRevision[];
};
@@ -121,4 +177,7 @@ export type BackendConfig = {
configured: boolean;
library_samples: number;
max_repair_attempts?: number;
incremental_generation?: boolean;
review_configured?: boolean;
review_error?: string;
};
@@ -41,3 +41,22 @@ test("uses backend face triangle ranges when they are available", () => {
assert.deepEqual([...runtime.proxy.faceIds], [0, 1]);
assert.deepEqual([...runtime.proxy.faceRuns], [0, 0, 0, 1, 0, 0, 0, 1, 1, 1]);
});
test("keeps face row lookup separate from edge row lookup", () => {
const runtime = buildCdslSelectorRuntime({
references: [
{ id: "face_000", selectorType: "face", center: [0, 0, 0], normal: [0, 0, 1], frame: { origin_mm: [0, 0, 0], normal: [0, 0, 1] } },
],
edges: [{
record_id: "edge_000",
geometry: { start_mm: [0, 0, 0], end_mm: [1, 0, 0], center_mm: [0.5, 0, 0], bbox_mm: [0, 0, 0, 1, 0, 0], curve_type: "line" },
}],
}, {
vertices: new Float32Array(9),
indices: new Uint32Array([0, 1, 2]),
});
assert.equal(runtime.faceReferenceByRowIndex.get(0)?.id, "face_000");
assert.equal(runtime.edgeReferenceByRowIndex.get(0)?.id, "edge_000");
assert.deepEqual(runtime.faces.map((reference) => reference.id), ["face_000"]);
});
+92 -12
View File
@@ -11,9 +11,20 @@ type SidecarReference = {
surface_type?: string;
triangle_start?: number;
triangle_count?: number;
executable?: boolean;
snapshot_id?: string;
};
type Sidecar = { references?: SidecarReference[] };
type SidecarEdge = {
record_id?: string;
selectorType?: string;
executable?: boolean;
snapshot_id?: string;
geometry?: Record<string, unknown>;
owner_feature_ids?: string[];
};
type Sidecar = { references?: SidecarReference[]; edges?: SidecarEdge[] };
const GLB_CAD_UNIT_SCALE = 1000;
const FACE_ID_NONE = 0xffffffff;
@@ -96,7 +107,7 @@ function triangleRuns(faceIds: Uint32Array, occurrenceRow: number) {
export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) {
const sourceReferences = Array.isArray(sidecar?.references) ? sidecar.references : [];
const faces = sourceReferences
.filter((reference) => String(reference?.selectorType || "").toLowerCase() === "face")
.filter((reference) => String(reference?.selectorType || "").toLowerCase() === "face" && reference?.executable !== false)
.map((reference, rowIndex) => {
const frame = sourceFrame(reference);
if (!frame) return null;
@@ -141,6 +152,73 @@ export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) {
})
.filter(Boolean) as any[];
const edgeLines: number[] = [];
const edgeIndices: number[] = [];
const edgeIds: number[] = [];
const edgeReferences = (Array.isArray(sidecar?.edges) ? sidecar.edges : [])
.filter((record) => record?.executable !== false)
.map((record, rowIndex) => {
const geometry = record.geometry || {};
const bbox = Array.isArray(geometry.bbox_mm) && geometry.bbox_mm.length === 6
? geometry.bbox_mm.map(Number)
: null;
const center = Array.isArray(geometry.center_mm) && geometry.center_mm.length >= 3
? geometry.center_mm.slice(0, 3).map(Number)
: null;
const start = Array.isArray(geometry.start_mm) && geometry.start_mm.length >= 3
? geometry.start_mm.slice(0, 3).map(Number)
: null;
const end = Array.isArray(geometry.end_mm) && geometry.end_mm.length >= 3
? geometry.end_mm.slice(0, 3).map(Number)
: null;
let points: number[][] = start && end ? [start, end] : [];
if (!points.length && center && bbox && String(geometry.curve_type || "").toLowerCase() === "circle") {
const extents = [bbox[3] - bbox[0], bbox[4] - bbox[1], bbox[5] - bbox[2]];
const normalAxis = extents.indexOf(Math.min(...extents));
const axes = [0, 1, 2].filter((axis) => axis !== normalAxis);
const radius = Math.max(extents[axes[0]], extents[axes[1]]) / 2;
points = Array.from({ length: 33 }, (_, index) => {
const angle = (index / 32) * Math.PI * 2;
const point = [...center];
point[axes[0]] += Math.cos(angle) * radius;
point[axes[1]] += Math.sin(angle) * radius;
return point;
});
}
if (points.length < 2 || !points.every((point) => point.every(Number.isFinite))) return null;
const segmentStart = edgeIndices.length / 2;
for (let index = 1; index < points.length; index += 1) {
const startIndex = edgeLines.length / 3;
edgeLines.push(...previewVector(points[index - 1] as Vector3), ...previewVector(points[index] as Vector3));
edgeIndices.push(startIndex, startIndex + 1);
edgeIds.push(rowIndex);
}
const edgeId = String(record.record_id || `edge_${rowIndex}`);
const previewCenter = center ? previewVector(center as Vector3) : null;
return {
id: edgeId,
selectorType: "edge",
normalizedSelector: edgeId,
displaySelector: edgeId,
label: String(geometry.curve_type || "edge"),
summary: String(geometry.curve_type || edgeId),
shortSummary: String(geometry.curve_type || edgeId),
partId: "glb:0",
rowIndex,
pickData: {
selectorType: "edge",
center: previewCenter,
sourceBoundsMm: bbox,
bbox: bbox ? mappedBBox({ min: bbox.slice(0, 3), max: bbox.slice(3, 6) }) : null,
segmentStart,
segmentCount: points.length - 1,
curveType: String(geometry.curve_type || "edge"),
cdslCoordinateSystem: "build123d_y_up_glb",
},
};
})
.filter(Boolean) as any[];
const vertices = meshData?.vertices;
const indices = meshData?.indices;
const triangleCount = Math.floor((indices?.length || 0) / 3);
@@ -186,7 +264,7 @@ export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) {
}
const faceRuns = triangleRuns(faceIds, 0);
const references = faces;
const references = [...faces, ...edgeReferences];
return {
schemaVersion: 1,
surfaceEdgeRendering: false,
@@ -196,19 +274,21 @@ export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) {
bbox: meshData?.bounds || null,
occurrences: [{ id: "glb:0" }],
shapes: [],
faces: references,
edges: [],
faces,
edges: edgeReferences,
vertices: [],
references,
referenceMap: new Map(references.map((reference) => [reference.id, reference])),
referenceByNormalizedSelector: new Map(references.map((reference) => [reference.normalizedSelector, reference])),
referenceByDisplaySelector: new Map(references.map((reference) => [reference.displaySelector, reference])),
faceReferenceByRowIndex: new Map(references.map((reference) => [reference.rowIndex, reference])),
edgeReferenceByRowIndex: new Map(),
// Face and edge rows are separate namespaces. Mapping all references here
// lets edge rows overwrite face rows and makes face clicks unselectable.
faceReferenceByRowIndex: new Map(faces.map((reference) => [reference.rowIndex, reference])),
edgeReferenceByRowIndex: new Map(edgeReferences.map((reference) => [reference.rowIndex, reference])),
vertexReferenceByRowIndex: new Map(),
occurrenceIdByRowIndex: new Map([[0, "glb:0"]]),
faceReferenceMap: new Map(references.map((reference) => [reference.id, reference])),
edgeReferenceMap: new Map(),
faceReferenceMap: new Map(faces.map((reference) => [reference.id, reference])),
edgeReferenceMap: new Map(edgeReferences.map((reference) => [reference.id, reference])),
vertexReferenceMap: new Map(),
singleOccurrenceId: "glb:0",
proxy: {
@@ -217,9 +297,9 @@ export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) {
faceIds,
faceRuns,
faceRunColumns: ["occurrenceRow", "primitiveIndex", "triangleStart", "triangleCount", "faceRow"],
edgePositions: new Float32Array(0),
edgeIndices: new Uint32Array(0),
edgeIds: new Uint32Array(0),
edgePositions: new Float32Array(edgeLines),
edgeIndices: new Uint32Array(edgeIndices),
edgeIds: new Uint32Array(edgeIds),
faceEdgeRows: [],
edgeFaceRows: [],
},
+1
View File
@@ -128,6 +128,7 @@ function compactEntity(
selector: String(selectedEntity?.selector || reference?.displaySelector || reference?.normalizedSelector || id).trim(),
label: String(reference?.label || selectedEntity?.surfaceType || referenceData.surfaceType || "face").trim(),
selectorType: String(selectedEntity?.selectorType || reference?.selectorType || "face").trim(),
snapshotId: String(selectedEntity?.snapshotId || reference?.snapshot_id || "").trim(),
surfaceType: String(selectedEntity?.surfaceType || referenceData.surfaceType || "unknown").trim(),
centerMm: referenceData.centerMm,
normal: referenceData.normal,