更新功能
This commit is contained in:
Generated
-342
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user