Feat/UI自动识别和布局编辑器简化版 #170

Merged
kdletters merged 131 commits from feat/agc-ui-editor into master 2026-08-20 19:07:26 +08:00
200 changed files with 24075 additions and 1142 deletions
+1
View File
@@ -163,6 +163,7 @@ module.exports = {
'server-rs/target',
'server-rs/target-*',
'apps/desktop-shell/src-tauri/target',
'apps/ai-game-creator-shell/src/features/ui-editor/types/**',
'target',
'src/main.tsx',
'src/App.tsx',
+1
View File
@@ -3,6 +3,7 @@ node_modules
.git
.codex-logs
public/Icons
apps/ai-game-creator-shell/src/features/ui-editor/types/
media
*.log
.preview.*
+303 -12
View File
@@ -8,16 +8,21 @@
"name": "@genarrative/ai-game-creator-shell",
"version": "0.1.0",
"dependencies": {
"@cubone/react-file-manager": "^1.35.0",
"@lexical/react": "^0.47.0",
"@lexical/utils": "^0.47.0",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-opener": "~2",
"@vitejs/plugin-react": "^5.0.4",
"focus-trap-react": "^12.0.3",
"lexical": "^0.47.0",
"lucide-react": "^0.546.0",
"react": "^19.0.0",
"react-arborist": "^3.16.0",
"react-colorful": "^5.8.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
@@ -251,6 +256,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -296,6 +310,30 @@
"node": ">=6.9.0"
}
},
"node_modules/@cubone/react-file-manager": {
"version": "1.35.0",
"resolved": "https://registry.npmjs.org/@cubone/react-file-manager/-/react-file-manager-1.35.0.tgz",
"integrity": "sha512-yvMDY0DXw3JMPhJeFUahJI8NsmSStPU8cOOr6v3i9aYeIioQYVfm0jkVyHAKoW3wKlPgIOc0lL4BI2PV6FejQw==",
"license": "MIT",
"dependencies": {
"@fontsource/nunito-sans": "^5.2.7",
"i18next": "^25.0.0",
"react-collapsed": "^4.2.0",
"react-icons": "^5.4.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -765,6 +803,15 @@
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@fontsource/nunito-sans": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/nunito-sans/-/nunito-sans-5.3.0.tgz",
"integrity": "sha512-CctNhebQ2YYJUwVMCAkHexQg7yedfz9s8JzaETALyc9QIQuDFRlNTWXux9H5PsNLgptPkrzRDomAdLJt1sDFpQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1305,6 +1352,24 @@
"url": "https://opencollective.com/preact"
}
},
"node_modules/@react-dnd/asap": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz",
"integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==",
"license": "MIT"
},
"node_modules/@react-dnd/invariant": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz",
"integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==",
"license": "MIT"
},
"node_modules/@react-dnd/shallowequal": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz",
"integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==",
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -2210,6 +2275,15 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz",
"integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-http": {
"version": "2.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz",
@@ -2330,7 +2404,6 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -2583,6 +2656,26 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/dnd-core": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz",
"integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==",
"license": "MIT",
"dependencies": {
"@react-dnd/asap": "^4.0.0",
"@react-dnd/invariant": "^2.0.0",
"redux": "^4.1.1"
}
},
"node_modules/dnd-core/node_modules/redux": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.9.2"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.394",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.394.tgz",
@@ -2681,6 +2774,12 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2698,6 +2797,31 @@
}
}
},
"node_modules/focus-trap": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-8.2.2.tgz",
"integrity": "sha512-qV0g8hRYBqgACcFOH3f9wXc4zPKhr/0z9RI2a6ZijZ72EeBi4g8oBy8zAWuUR1TsMpOzwpUMFvjdasrC41Joug==",
"license": "MIT",
"dependencies": {
"tabbable": "^6.5.0"
}
},
"node_modules/focus-trap-react": {
"version": "12.0.3",
"resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-12.0.3.tgz",
"integrity": "sha512-4eXtzhRTtFrxle9Tkl0Wkj8SFJnWz3i3yb5e53Mdn4E8XZQ/Lzqom9U4uUAJ8wd7yDmyGVP96dxwQZXLXd4sbg==",
"license": "MIT",
"dependencies": {
"focus-trap": "^8.2.2",
"tabbable": "^6.5.0"
},
"peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0",
"@types/react-dom": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2768,6 +2892,15 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"license": "BSD-3-Clause",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -2778,6 +2911,37 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/i18next": {
"version": "25.10.10",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.10.tgz",
"integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2"
},
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@@ -2967,7 +3131,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2988,7 +3151,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3009,7 +3171,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3030,7 +3191,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3051,7 +3211,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3072,7 +3231,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3093,7 +3251,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3114,7 +3271,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3135,7 +3291,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3156,7 +3311,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3177,7 +3331,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3509,6 +3662,12 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
"node_modules/micromark": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
@@ -4195,6 +4354,85 @@
"node": ">=0.10.0"
}
},
"node_modules/react-arborist": {
"version": "3.16.0",
"resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.16.0.tgz",
"integrity": "sha512-Pp1TQXETKMeg/0yAMGBFIwze7IfZaghDScX4vekJ4Ge8/t7uYifbDBhEGUSOYaDuahE1f8fyBIrlN2pLD4ysTA==",
"license": "MIT",
"dependencies": {
"react-dnd": "^14.0.3",
"react-dnd-html5-backend": "^14.0.3",
"react-window": "^1.8.11",
"redux": "^5.0.0",
"use-sync-external-store": "^1.2.0"
},
"peerDependencies": {
"react": ">= 16.14",
"react-dom": ">= 16.14"
}
},
"node_modules/react-collapsed": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/react-collapsed/-/react-collapsed-4.2.0.tgz",
"integrity": "sha512-8rQuV3wGTjBI4M1WzD5DpqCBRliHNDKEEFeC6tAQEVrRDic46xBOtySgMGjVVacIobEaMf7AiYlI5APCZIZtkg==",
"license": "MIT",
"dependencies": {
"tiny-warning": "^1.0.3"
},
"peerDependencies": {
"react": "^16.9.0 || ^17 || ^18 || ^19",
"react-dom": "^16.9.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-colorful": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.8.0.tgz",
"integrity": "sha512-Wy9OzPfjSN9bF12OB8N7UQvlsZ0I+7wHxpN+bV5BjNQGxOj6IiwkRjevJK9yOBjJWGQvAaf1OXtn8rUeEatAng==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-dnd": {
"version": "14.0.5",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz",
"integrity": "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==",
"license": "MIT",
"dependencies": {
"@react-dnd/invariant": "^2.0.0",
"@react-dnd/shallowequal": "^2.0.0",
"dnd-core": "14.0.1",
"fast-deep-equal": "^3.1.3",
"hoist-non-react-statics": "^3.3.2"
},
"peerDependencies": {
"@types/hoist-non-react-statics": ">= 3.3.1",
"@types/node": ">= 12",
"@types/react": ">= 16",
"react": ">= 16.14"
},
"peerDependenciesMeta": {
"@types/hoist-non-react-statics": {
"optional": true
},
"@types/node": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/react-dnd-html5-backend": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz",
"integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==",
"license": "MIT",
"dependencies": {
"dnd-core": "14.0.1"
}
},
"node_modules/react-dom": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
@@ -4207,6 +4445,21 @@
"react": "^19.2.7"
}
},
"node_modules/react-icons": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz",
"integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@@ -4243,6 +4496,29 @@
"node": ">=0.10.0"
}
},
"node_modules/react-window": {
"version": "1.8.11",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz",
"integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.0.0",
"memoize-one": ">=3.1.1 <6"
},
"engines": {
"node": ">8.0.0"
},
"peerDependencies": {
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
@@ -4446,6 +4722,12 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -4613,6 +4895,15 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+5
View File
@@ -35,16 +35,21 @@
"typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs"
},
"dependencies": {
"@cubone/react-file-manager": "^1.35.0",
"@lexical/react": "^0.47.0",
"@lexical/utils": "^0.47.0",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-opener": "~2",
"@vitejs/plugin-react": "^5.0.4",
"focus-trap-react": "^12.0.3",
"lexical": "^0.47.0",
"lucide-react": "^0.546.0",
"react": "^19.0.0",
"react-arborist": "^3.16.0",
"react-colorful": "^5.8.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
@@ -116,6 +116,7 @@ const rustSharedContractSource = fs.readFileSync(
const allowedUncalledTauriCommands = [
'archive_failed_local_project_resource_edit',
'chat_with_game_creator_agent',
'check_ui_editor_font_glyph_coverage',
'commit_local_project_asset',
'confirm_local_project_asset_canvas_generation_service_identity',
'create_local_project_asset_canvas_draft',
@@ -1307,13 +1308,11 @@ if (!viteConfigSource.includes('allow: [repoRoot]')) {
);
}
if (
!(
tauriConfig.build?.beforeDevCommand?.includes(
'run ai-game-creator-shell:dev-server',
) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve')
)
) {
if (!(
tauriConfig.build?.beforeDevCommand?.includes(
'run ai-game-creator-shell:dev-server',
) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve')
)) {
throw new Error(
'AI game creator shell beforeDevCommand must start the selected Vite dev server',
);
@@ -661,7 +661,7 @@ async function main() {
function isDirectModuleExecution() {
return Boolean(
process.argv[1] &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
);
}
+199 -1
View File
@@ -75,6 +75,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
@@ -696,6 +705,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const_fn"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413d67b29ef1021b4d60f4aa1e925ca031751e213832b4b1d588fae623c05c60"
[[package]]
name = "cookie"
version = "0.18.1"
@@ -1639,6 +1654,7 @@ dependencies = [
"image",
"jsonschema",
"libc",
"nalgebra",
"oxc_allocator",
"oxc_ast",
"oxc_ast_visit",
@@ -1650,6 +1666,7 @@ dependencies = [
"portable-pty",
"reqwest 0.12.28",
"rmcp",
"schemars 1.2.1",
"serde",
"serde_json",
"serde_yaml",
@@ -1664,6 +1681,9 @@ dependencies = [
"tauri-plugin-opener",
"tempfile",
"tokio",
"ts-rs",
"ttf-parser",
"typed_floats",
"unicode-normalization",
"url",
"uuid",
@@ -1764,6 +1784,30 @@ dependencies = [
"winapi",
]
[[package]]
name = "glam"
version = "0.30.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
[[package]]
name = "glam"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556f6b2ea90b8d15a74e0e7bb41671c9bdf38cd9f78c284d750b9ce58a2b5be7"
[[package]]
name = "glam"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0"
[[package]]
name = "glam"
version = "0.33.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360bd2cd76e0cd9032d42cf2922155cecea2685b0cfa4630c3246df030bcfd6"
[[package]]
name = "glib"
version = "0.18.5"
@@ -2605,6 +2649,16 @@ dependencies = [
"web_atoms",
]
[[package]]
name = "matrixmultiply"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
dependencies = [
"autocfg",
"rawpointer",
]
[[package]]
name = "memchr"
version = "2.8.2"
@@ -2694,6 +2748,38 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "nalgebra"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adc43a60c217b0c6ff46e47f26911015ad8d2e5a8be1af668c67e370d99a4346"
dependencies = [
"approx",
"glam 0.30.10",
"glam 0.31.1",
"glam 0.32.1",
"glam 0.33.3",
"matrixmultiply",
"nalgebra-macros",
"num-complex",
"num-rational",
"num-traits",
"serde",
"simba",
"typenum",
]
[[package]]
name = "nalgebra-macros"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "973e7178a678cfd059ccec50887658d482ce16b0aa9da3888ddeab5cd5eb4889"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "native-tls"
version = "0.2.18"
@@ -2827,6 +2913,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
"serde",
]
[[package]]
@@ -4004,6 +4091,12 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rawpointer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -4327,6 +4420,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "safe_arch"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a52ec151f024d703f9fd65abb7cbe81e7cdb39f18917a3a37e3014470dc7c59"
dependencies = [
"bytemuck",
]
[[package]]
name = "same-file"
version = "1.0.6"
@@ -4353,7 +4455,7 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
dependencies = [
"dyn-clone",
"indexmap 1.9.3",
"schemars_derive",
"schemars_derive 0.8.22",
"serde",
"serde_json",
"url",
@@ -4380,6 +4482,7 @@ checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
dependencies = [
"dyn-clone",
"ref-cast",
"schemars_derive 1.2.1",
"serde",
"serde_json",
]
@@ -4396,6 +4499,18 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "schemars_derive"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.118",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -4723,6 +4838,18 @@ dependencies = [
"libc",
]
[[package]]
name = "simba"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1a7200d82ff1c7b4235efd12f11e2537a402c91cf83a5cb97ce80eef787fde7"
dependencies = [
"approx",
"num-complex",
"num-traits",
"wide",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
@@ -5390,6 +5517,15 @@ dependencies = [
"utf-8",
]
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]]
name = "textwrap"
version = "0.16.2"
@@ -5816,6 +5952,34 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ts-rs"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
dependencies = [
"thiserror 2.0.18",
"ts-rs-macros",
]
[[package]]
name = "ts-rs-macros"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"termcolor",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "tungstenite"
version = "0.28.0"
@@ -5833,6 +5997,30 @@ dependencies = [
"utf-8",
]
[[package]]
name = "typed_floats"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "436de2fdc42c0f07890f4b51dcc90a468b0b12280f49caff1cf57ce2e646817c"
dependencies = [
"const_fn",
"rustversion",
"serde",
"typed_floats_macros",
]
[[package]]
name = "typed_floats_macros"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da27e33675dd7bfc62c7ead8c36779f961815ababe717191d67b75c2356edc4f"
dependencies = [
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.118",
]
[[package]]
name = "typeid"
version = "1.0.3"
@@ -6384,6 +6572,16 @@ dependencies = [
"libc",
]
[[package]]
name = "wide"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de2aaf408e58689c2096682331b1f42bb2d9f2ed6b11560407d023cd0a6c634e"
dependencies = [
"bytemuck",
"safe_arch",
]
[[package]]
name = "winapi"
version = "0.3.9"
@@ -15,6 +15,9 @@ shared-contracts = { path = "../../../server-rs/crates/shared-contracts", defaul
tauri-build = { version = "2.6.2", features = [] }
[dependencies]
ts-rs = "12.0.1"
typed_floats = { version = "1.0.7", features = ["serde"] }
nalgebra = { version = "0.35.0", features = ["serde-serialize"] }
agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" }
base64 = "0.22"
chromiumoxide = "0.9.1"
@@ -33,6 +36,7 @@ rmcp = { version = "2.2.0", default-features = false, features = ["client", "req
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
schemars = "1.2.1"
sha2 = "0.10"
similar = "2.7"
platform-llm = { path = "../../../server-rs/crates/platform-llm" }
@@ -45,6 +49,7 @@ tauri-plugin-dialog = "2.7.1"
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
tauri-plugin-opener = "2"
tempfile = "3"
ttf-parser = "0.25.1"
tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] }
url = "2"
unicode-normalization = "0.1"
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "developer",
"description": "开发窗口允许打开本地素材选择对话框。",
"windows": ["developer"],
"permissions": ["dialog:allow-open"]
}
@@ -19,6 +19,7 @@
{ "url": "http://127.0.0.1:*/*" }
]
},
"opener:default"
"opener:default",
"dialog:allow-open"
]
}
File diff suppressed because it is too large Load Diff
@@ -113,6 +113,53 @@ use runner::*;
use swarm_cli::*;
use user_input::*;
use windows::*;
#[tauri::command]
async fn suggest_ui_design_semantic(
project_path: String,
state: ui_editor::state::State,
) -> Result<Vec<ui_editor::commands::UIDesignSuggestionTreeNode>, String> {
ui_editor::commands::suggest_ui_design_semantic_impl(project_path, state).await
}
#[tauri::command]
async fn recognize_ui(
project_path: String,
state: ui_editor::state::State,
) -> Result<ui_editor::commands::RecognitionDTO, String> {
ui_editor::commands::recognize_ui_impl(project_path, state).await
}
#[tauri::command]
async fn merge_ui(state: ui_editor::state::State) -> Result<ui_editor::commands::MergeDTO, String> {
ui_editor::commands::merge_ui_impl(state).await
}
#[tauri::command]
async fn bind_components(
project_path: String,
state: ui_editor::state::State,
sprite_ids: Vec<String>,
) -> Result<ui_editor::commands::BindingDTO, String> {
ui_editor::commands::bind_components_impl(project_path, state, sprite_ids).await
}
#[tauri::command]
fn load_ui_design_state(
input: ui_editor::persistence::LoadUiDesignStateInput,
) -> Result<ui_editor::persistence::UiDesignStateSnapshot, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.list")?;
ui_editor::persistence::load_ui_design_state_at(input)
}
#[tauri::command]
fn save_ui_design_state(
input: ui_editor::persistence::SaveUiDesignStateInput,
) -> Result<ui_editor::persistence::SaveUiDesignStateResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
ui_editor::persistence::save_ui_design_state_at(input)
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -907,6 +954,14 @@ struct UploadLocalAssetResult {
manifest_path: String,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct CreateUiDesignResourceResult {
asset: UploadLocalAssetResult,
manifest: GameCreationAppManifest,
committed_project_revision: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ImportCanvasExportResult {
@@ -925,6 +980,22 @@ struct SyncCanvasProjectAssetsResult {
assets: Vec<UploadLocalAssetResult>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalImportResult {
assets: Vec<ImportedAsset>,
}
type RemoteImportResult = LocalImportResult;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ImportedAsset {
id: String,
local_path: String,
asset_kind: Option<String>,
}
#[derive(Debug, Eq, PartialEq)]
struct GeneratedPlatformArtAssetSlice {
name: String,
@@ -2225,6 +2296,7 @@ fn main() {
read_game_creator_mcp_catalog,
upload_local_asset,
register_local_asset,
create_ui_design_resource,
derive_local_project_resource,
list_pending_local_project_resource_edits,
resume_local_project_resource_edit,
@@ -2235,6 +2307,16 @@ fn main() {
import_canvas_asset,
import_canvas_export,
sync_canvas_project_assets,
import_ui_editor_assets,
prepare_ui_editor_project_fonts,
read_ui_editor_font_bytes,
check_ui_editor_font_glyph_coverage,
suggest_ui_design_semantic,
recognize_ui,
merge_ui,
bind_components,
load_ui_design_state,
save_ui_design_state,
generate_platform_art_asset,
open_canvas_project,
get_game_creation_agent_capabilities,
@@ -2491,3 +2573,4 @@ mod diagnostic_log_tests {
#[cfg(test)]
mod tests;
pub mod ui_editor;
@@ -0,0 +1,418 @@
use crate::config::build_game_creator_llm_client_from_config;
use crate::ui_editor::commands::utils::{
parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, strict_json_schema,
};
use crate::ui_editor::component::Component;
use crate::ui_editor::layout::node::{Node, StageStatus};
use crate::ui_editor::persistence::{
UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE, UI_DESIGN_STATE_MAX_NODES,
};
use crate::ui_editor::state::State;
use crate::ui_editor::utils::{NodeId, SpriteAssetId};
use platform_llm::{
LlmFunctionTool, LlmMessage, LlmMessageContentPart, LlmRunRequest, LlmToolChoice,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::Path;
use ts_rs::TS;
pub const ASSET_BATCH_SIZE: usize = 5;
const SYSTEM_PROMPT: &str = r#"
你是游戏 UI 组件绑定器。你会看到全部 UI 参考图、可编辑节点说明,以及本批独立素材的真实像素。
* 只对视觉上确实需要改变组件的节点返回 changes;
* 每个 change 的 components 是该节点完整的新渲染栈,空数组表示明确清空。数组顺序从底到顶渲染。
* 对每个 Component,直接完整返回其全部参数.
* 有任何困难或者不确定把状态设为 NeedReview,说明中文原因。
* 纯结构节点可以返回空数组并标为 NoProblem。
* 面向用户的 reason 使用中文。
"#;
#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
enum DraftStatus {
NoProblem,
NeedReview(String),
}
#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
struct BindingChangeDraft {
node_id: NodeId,
components: Vec<Component>,
components_status: DraftStatus,
}
#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
struct BindingResponse {
changes: Vec<BindingChangeDraft>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub struct BindingChange {
pub node_id: NodeId,
pub components: Vec<Component>,
pub components_status: StageStatus,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub struct BindingDTO {
pub changes: Vec<BindingChange>,
}
#[derive(Serialize)]
struct EditableNodeContext<'a> {
node_id: &'a NodeId,
name: &'a str,
description: &'a str,
components: &'a [Component],
}
fn binding_json_schema() -> Result<serde_json::Value, String> {
strict_json_schema::<BindingResponse>()
}
fn collect_editable_nodes<'a>(node: &'a Node, output: &mut Vec<EditableNodeContext<'a>>) {
if node.metadata.allow_llm_edit_component {
output.push(EditableNodeContext {
node_id: &node.id,
name: &node.metadata.name,
description: &node.metadata.description,
components: &node.components,
});
}
for child in &node.children {
collect_editable_nodes(child, output);
}
}
fn validate_binding_response_shape(
value: &serde_json::Value,
editable_node_count: usize,
) -> Result<(), String> {
let changes = value
.get("changes")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| "组件绑定工具参数缺少 changes 数组".to_string())?;
let max_changes = editable_node_count.min(UI_DESIGN_STATE_MAX_NODES);
if changes.len() > max_changes {
return Err(format!("组件绑定 changes 不能超过 {max_changes}"));
}
for change in changes {
let components = change
.get("components")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| "组件绑定 change 缺少 components 数组".to_string())?;
if components.len() > UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE {
return Err(format!(
"单个组件绑定栈不能超过 {UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE} 个组件"
));
}
}
Ok(())
}
fn parse_binding_response(
arguments: &str,
editable_node_count: usize,
) -> Result<BindingResponse, String> {
let value = parse_limited_llm_tool_arguments(arguments)?;
validate_binding_response_shape(&value, editable_node_count)?;
serde_json::from_value(value).map_err(|error| format!("组件绑定工具参数无效:{error}"))
}
fn validate_and_materialize(
changes: Vec<BindingChangeDraft>,
editable_ids: &HashSet<NodeId>,
known_sprite_ids: &HashSet<SpriteAssetId>,
) -> Result<BindingDTO, String> {
let mut changed_ids = HashSet::new();
let mut materialized = Vec::with_capacity(changes.len());
for change in changes {
if !editable_ids.contains(&change.node_id) {
return Err(format!(
"组件绑定返回了未授权节点:{}",
change.node_id.as_str()
));
}
if !changed_ids.insert(change.node_id.clone()) {
return Err(format!("组件绑定重复返回节点:{}", change.node_id.as_str()));
}
for component in &change.components {
if let Component::Image(image) = component {
if image
.target_graphic
.as_ref()
.is_some_and(|id| !known_sprite_ids.contains(id))
{
return Err("组件绑定引用了不存在的独立素材".to_string());
}
}
}
let components = change.components;
let components_status = match change.components_status {
DraftStatus::NoProblem => StageStatus::NoProblem,
DraftStatus::NeedReview(reason) if reason.trim().is_empty() => {
return Err("组件待审状态必须包含原因".to_string())
}
DraftStatus::NeedReview(reason) => StageStatus::NeedReview(reason),
};
materialized.push(BindingChange {
node_id: change.node_id,
components,
components_status,
});
}
Ok(BindingDTO {
changes: materialized,
})
}
pub(crate) async fn bind_components_impl(
project_path: String,
state: State,
sprite_ids: Vec<String>,
) -> Result<BindingDTO, String> {
if sprite_ids.is_empty() {
return Err("请先导入至少一个独立素材".to_string());
}
if sprite_ids.len() > ASSET_BATCH_SIZE {
return Err(format!("单次组件绑定最多 {} 个独立素材", ASSET_BATCH_SIZE));
}
let sprite_ids = sprite_ids
.into_iter()
.map(SpriteAssetId::new)
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("独立素材 ID 无效:{error}"))?;
let batch_sprite_ids = sprite_ids.iter().cloned().collect::<HashSet<_>>();
if batch_sprite_ids.len() != sprite_ids.len() {
return Err("独立素材批次包含重复 ID".to_string());
}
if state.ui_design_images.is_empty() || state.ui_design_images.len() > 4 {
return Err("组件绑定需要 1 至 4 张界面图".to_string());
}
let mut editable_nodes = Vec::new();
k88936 marked this conversation as resolved
Review

这里直接在 async command 中读取并 Base64 编码参考图;下面对 sprite 也有同样的问题。该入口最多会读取 4 张参考图和 5 个独立素材,但没有 metadata/流式大小上限,也没有移到 blocking worker。持久化 State 只校验路径和像素尺寸,无法约束文件字节数,因此一个异常大文件会阻塞 Tokio worker 并放大内存占用。请复用 read_ui_reference_image_data_url 的受限 blocking reader,或为 binding 建立等价的单文件/总量预算,并补超限回归测试。

这里直接在 async command 中读取并 Base64 编码参考图;下面对 sprite 也有同样的问题。该入口最多会读取 4 张参考图和 5 个独立素材,但没有 metadata/流式大小上限,也没有移到 blocking worker。持久化 State 只校验路径和像素尺寸,无法约束文件字节数,因此一个异常大文件会阻塞 Tokio worker 并放大内存占用。请复用 `read_ui_reference_image_data_url` 的受限 blocking reader,或为 binding 建立等价的单文件/总量预算,并补超限回归测试。
for tree in &state.ui_trees {
if !state.ui_design_images.contains_key(&tree.src_ui_design) {
return Err("UI 树引用的界面图不存在".to_string());
}
collect_editable_nodes(&tree.root, &mut editable_nodes);
}
if editable_nodes.is_empty() {
return Err("当前没有允许 LLM 修改组件的节点".to_string());
}
let editable_ids = editable_nodes
.iter()
.map(|node| node.node_id.clone())
.collect::<HashSet<_>>();
if editable_ids.len() != editable_nodes.len() {
return Err("UI 树包含重复节点 ID".to_string());
}
let root = Path::new(project_path.trim());
let mut parts = Vec::with_capacity(state.ui_design_images.len() * 2 + sprite_ids.len() * 2 + 1);
let node_context = serde_json::to_string(&editable_nodes)
.map_err(|error| format!("序列化可编辑节点失败:{error}"))?;
parts.push(LlmMessageContentPart::InputText {
text: format!("可编辑节点:{node_context}"),
});
for (id, image) in &state.ui_design_images {
let absolute = crate::project::resolve_local_project_path(root, &image.path)?;
let image_url = read_ui_reference_image_data_url(absolute)
.await
.map_err(|error| format!("读取界面图失败:{error}"))?;
parts.push(LlmMessageContentPart::InputText {
text: format!(
"UI_REFERENCE id={} pixel_size={:?}",
id.as_str(),
image.pixel_size
),
});
parts.push(LlmMessageContentPart::InputImage { image_url });
}
for id in &sprite_ids {
let sprite = state
.sprite_assets
.get(id)
.ok_or_else(|| format!("独立素材不存在:{}", id.as_str()))?;
let absolute = crate::project::resolve_local_project_path(root, &sprite.path)?;
let image_url = read_ui_reference_image_data_url(absolute)
.await
.map_err(|error| format!("读取独立素材失败:{error}"))?;
parts.push(LlmMessageContentPart::InputText {
text: format!(
"SPRITE id={} name={} asset_type={} pixel_size={:?}",
id.as_str(),
sprite.metadata.name,
sprite.metadata.asset_type,
sprite.pixel_size
),
});
parts.push(LlmMessageContentPart::InputImage { image_url });
k88936 marked this conversation as resolved Outdated
Outdated
Review

此前 recognition、merge 和 suggestion 都已先用 parse_limited_llm_tool_arguments 限制 1 MiB,再做结构校验后反序列化;binding 仍直接反序列化不可信 Provider arguments。异常超大 JSON 可在命令处理时耗尽内存。请复用统一的字节限制,并为 changes(以及每个组件栈)增加有界校验后再转 DTO;补超限 tool-call 参数测试。

此前 recognition、merge 和 suggestion 都已先用 `parse_limited_llm_tool_arguments` 限制 1 MiB,再做结构校验后反序列化;binding 仍直接反序列化不可信 Provider arguments。异常超大 JSON 可在命令处理时耗尽内存。请复用统一的字节限制,并为 `changes`(以及每个组件栈)增加有界校验后再转 DTO;补超限 tool-call 参数测试。
}
let client = build_game_creator_llm_client_from_config()?;
let tool = LlmFunctionTool::new(
"bind_ui_components",
"根据 UI 参考图和当前批次独立素材,返回需要修改的节点组件",
binding_json_schema()?,
)
.with_strict(true);
let response = client
.run(
LlmRunRequest::new(vec![
LlmMessage::system(SYSTEM_PROMPT),
LlmMessage::user_multimodal(parts),
])
.with_function_tools(vec![tool])
.with_tool_choice(LlmToolChoice::Required),
)
.await
.map_err(|error| format!("组件绑定失败:{error}"))?;
let call = response
.tool_calls
.iter()
.find(|call| call.name == "bind_ui_components")
.ok_or_else(|| "LLM 未返回 bind_ui_components 工具调用".to_string())?;
let parsed = parse_binding_response(&call.arguments, editable_nodes.len())?;
let known_sprite_ids = state.sprite_assets.keys().cloned().collect::<HashSet<_>>();
let result = validate_and_materialize(parsed.changes, &editable_ids, &known_sprite_ids)?;
eprintln!(
"ui_binding.completed ui_images={} sprites={} editable_nodes={} changes={}",
state.ui_design_images.len(),
sprite_ids.len(),
editable_nodes.len(),
result.changes.len()
);
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui_editor::component::text::{FontSource, TextComponent};
use ts_rs::{Config, TS};
fn id(value: &str) -> NodeId {
NodeId::new(value).expect("valid id")
}
#[test]
fn materialization_rejects_unapproved_node_and_unknown_sprite() {
let editable = HashSet::from([id("editable")]);
let known = HashSet::from([
SpriteAssetId::new("sprite").expect("valid sprite"),
SpriteAssetId::new("other-batch-sprite").expect("valid sprite"),
]);
let unapproved = BindingChangeDraft {
node_id: id("other"),
components: Vec::new(),
components_status: DraftStatus::NoProblem,
};
assert!(validate_and_materialize(vec![unapproved], &editable, &known).is_err());
// References to sprites from another batch are allowed once they exist in the project.
let other_batch = BindingChangeDraft {
node_id: id("editable"),
components: vec![Component::Image(
crate::ui_editor::component::image::ImageComponent {
target_graphic: Some(
SpriteAssetId::new("other-batch-sprite").expect("valid sprite"),
),
image_type: crate::ui_editor::component::image::ImageType::Simple {
preserve_aspect: false,
},
},
)],
components_status: DraftStatus::NoProblem,
};
assert!(validate_and_materialize(vec![other_batch], &editable, &known).is_ok());
// References to sprites that do not exist in the project at all are still rejected.
let unknown = BindingChangeDraft {
node_id: id("editable"),
components: vec![Component::Image(
crate::ui_editor::component::image::ImageComponent {
target_graphic: Some(SpriteAssetId::new("unknown").expect("valid sprite")),
image_type: crate::ui_editor::component::image::ImageType::Simple {
preserve_aspect: false,
},
},
)],
components_status: DraftStatus::NoProblem,
};
assert!(validate_and_materialize(vec![unknown], &editable, &known).is_err());
}
#[test]
fn materialization_preserves_changed_only_empty_component_lists() {
let editable = HashSet::from([id("editable")]);
let result = validate_and_materialize(
vec![BindingChangeDraft {
node_id: id("editable"),
components: Vec::new(),
components_status: DraftStatus::NoProblem,
}],
&editable,
&HashSet::new(),
)
.expect("valid changed-only clear");
assert_eq!(result.changes.len(), 1);
assert!(result.changes[0].components.is_empty());
assert_eq!(result.changes[0].components_status, StageStatus::NoProblem);
}
#[test]
fn binding_response_rejects_oversized_tool_arguments_before_dto_conversion() {
let oversized =
"x".repeat(crate::ui_editor::commands::utils::LLM_TOOL_ARGUMENT_MAX_BYTES + 1);
assert!(parse_binding_response(&oversized, 1)
.expect_err("oversized tool arguments must fail")
.contains("字节上限"));
}
#[test]
fn binding_response_bounds_changes_and_each_component_stack() {
let too_many_changes = serde_json::json!({
"changes": [{"components": []}, {"components": []}]
});
assert!(validate_binding_response_shape(&too_many_changes, 1).is_err());
let too_many_components = serde_json::json!({
"changes": [{
"components": (0..=UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE)
.map(|_| serde_json::Value::Null)
.collect::<Vec<_>>()
}]
});
assert!(validate_binding_response_shape(&too_many_components, 1).is_err());
}
#[tokio::test]
async fn binding_image_input_rejects_oversized_files_via_bounded_reader() {
let directory = tempfile::tempdir().expect("binding image fixture");
let path = directory.path().join("oversized.png");
let file = std::fs::File::create(&path).expect("create sparse binding image");
file.set_len((crate::ui_editor::commands::utils::UI_REFERENCE_IMAGE_MAX_BYTES + 1) as u64)
.expect("size sparse binding image");
drop(file);
assert!(read_ui_reference_image_data_url(path).await.is_err());
}
#[test]
fn exports_ui_editor_types() {
let config = Config::from_env();
BindingDTO::export_all(&config).expect("BindingDTO TypeScript export succeeds");
Node::export_all(&config).expect("Node TypeScript export succeeds");
TextComponent::export_all(&config).expect("TextComponent TypeScript export succeeds");
FontSource::export_all(&config).expect("FontSource TypeScript export succeeds");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
pub mod binding;
pub mod merge;
pub mod recognition;
pub mod ui_design_suggestion;
pub mod utils;
pub(crate) use binding::bind_components_impl;
pub use binding::BindingDTO;
pub(crate) use merge::merge_ui_impl;
pub use merge::MergeDTO;
pub(crate) use recognition::recognize_ui_impl;
pub use recognition::RecognitionDTO;
pub(crate) use ui_design_suggestion::suggest_ui_design_semantic_impl;
pub use ui_design_suggestion::UIDesignSuggestionTreeNode;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,341 @@
use crate::config::build_game_creator_llm_client_from_config;
use crate::ui_editor::commands::utils::{
parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, strict_json_schema,
};
use crate::ui_editor::resource::ui_design_image::UIDesignImageRole;
use crate::ui_editor::state::State;
use crate::ui_editor::utils::UIDesignImageId;
use platform_llm::{
LlmFunctionTool, LlmMessage, LlmMessageContentPart, LlmRunRequest, LlmToolChoice,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::Path;
use ts_rs::TS;
const SYSTEM_PROMPT: &str = r#"
请识别这些 UI 参考图的界面语义,并调用 suggest_ui_design_semantics 工具返回结果。
不要在工具调用外输出 JSON、Markdown、代码围栏、注释、额外字段或解释文字。
字段含义:
- id:必填,必须逐字匹配当前输入参考图的 id。它标识“这条建议属于哪张图”,不是新 ID,不能为 null。
- name:要写入该图片 metadata 的简短、可读名称;
- description:要写入该图片 metadata 的简短语义描述,例如“带底部导航的主游戏页面”。
- role:要写入该图片 metadata 的界面角色。
Page 表示完整主页面,
Section 表示同一主页面中的子界面或页签,
Modal/Drawer/Popover 表示浮层或局部覆盖界面,
State 表示同一界面的状态变体,
Scrolled 表示滚动或分页后的内容,
Detail 表示局部详情或补充证据。
- children:该节点直接包含的子界面图。
- 根节点必须是 Page,子节点不能是 Page
所有字段都必须出现,禁止省略字段、使用 null 或返回空字符串。每张参考图最多出现一次;id 必须逐字匹配输入图片 id。
以下 UI 设计图中的 metadata 部分信息已确定,请根据已有信息补全语义.
"#;
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS, JsonSchema)]
#[schemars(deny_unknown_fields)]
#[serde(deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub struct UIDesignSuggestionTreeNode {
pub id: UIDesignImageId,
pub name: String,
pub description: String,
pub role: UIDesignImageRole,
pub children: Vec<UIDesignSuggestionTreeNode>,
}
#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
#[serde(deny_unknown_fields)]
struct UIDesignSuggestion {
ui_designs: Vec<UIDesignSuggestionTreeNode>,
}
fn ui_design_suggestion_json_schema() -> Result<serde_json::Value, String> {
strict_json_schema::<UIDesignSuggestion>()
}
const MAX_REFERENCES: usize = 4;
const MAX_SUGGESTION_TREE_DEPTH: usize = 4;
fn validate_suggestion_response_shape(value: &serde_json::Value) -> Result<(), String> {
let roots = value
.get("ui_designs")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| "UI 语义建议工具参数缺少 ui_designs 数组".to_string())?;
let mut stack = roots.iter().map(|node| (node, 1usize)).collect::<Vec<_>>();
let mut node_count = 0usize;
while let Some((node, depth)) = stack.pop() {
if depth > MAX_SUGGESTION_TREE_DEPTH {
return Err(format!(
"UI 语义建议树最大深度不能超过 {MAX_SUGGESTION_TREE_DEPTH}"
));
}
node_count += 1;
if node_count > MAX_REFERENCES {
return Err(format!("UI 语义建议最多包含 {MAX_REFERENCES} 个节点"));
}
let children = node
.get("children")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| "UI 语义建议节点缺少 children 数组".to_string())?;
stack.extend(children.iter().map(|child| (child, depth + 1)));
}
Ok(())
}
fn validate_suggestions(
suggestions: &[UIDesignSuggestionTreeNode],
image_ids: &HashSet<UIDesignImageId>,
) -> Result<(), String> {
let mut seen = HashSet::new();
fn visit(
nodes: &[UIDesignSuggestionTreeNode],
is_root: bool,
image_ids: &HashSet<UIDesignImageId>,
seen: &mut HashSet<UIDesignImageId>,
) -> Result<(), String> {
for node in nodes {
if !image_ids.contains(&node.id) {
return Err("LLM 返回了未知界面图 ID".to_string());
}
if !seen.insert(node.id.clone()) {
return Err("LLM 返回了重复界面图建议".to_string());
}
if node.name.trim().is_empty() || node.description.trim().is_empty() {
return Err("界面图语义字段不能是空字符串".to_string());
}
if is_root {
if node.role != UIDesignImageRole::Page {
return Err("界面图树根节点必须是 Page".to_string());
}
} else if node.role == UIDesignImageRole::Page {
return Err("Page 不能作为其它界面图的子节点".to_string());
}
visit(&node.children, false, image_ids, seen)?;
}
Ok(())
}
visit(suggestions, true, image_ids, &mut seen)?;
if seen != *image_ids {
return Err("LLM 未为每张界面图返回语义建议".to_string());
}
Ok(())
}
#[tauri::command]
pub(crate) async fn suggest_ui_design_semantic_impl(
project_path: String,
state: State,
) -> Result<Vec<UIDesignSuggestionTreeNode>, String> {
if state.ui_design_images.is_empty() {
eprintln!("ui_design_suggestion.error stage=validate reason=no_images");
return Err("请先导入界面图".to_string());
}
if state.ui_design_images.len() > MAX_REFERENCES {
eprintln!(
"ui_design_suggestion.error stage=validate reason=too_many_images count={}",
state.ui_design_images.len()
);
return Err("界面图最多 4 张".to_string());
}
let root = Path::new(project_path.trim());
let mut parts = Vec::new();
let mut ids = HashSet::new();
for (id, image) in &state.ui_design_images {
ids.insert(id.clone());
let absolute =
crate::project::resolve_local_project_path(root, &image.path).map_err(|error| {
eprintln!(
"ui_design_suggestion.error stage=resolve_image id={} error={error}",
id.as_str()
);
error
})?;
let image_url = read_ui_reference_image_data_url(absolute)
.await
.map_err(|error| {
eprintln!(
"ui_design_suggestion.error stage=read_image id={} error={error}",
id.as_str()
);
error
})?;
parts.push(LlmMessageContentPart::InputText {
text: format!("REFERENCE id:{} metadata:{}", id.as_str(), image.metadata,),
});
parts.push(LlmMessageContentPart::InputImage { image_url });
}
let client = build_game_creator_llm_client_from_config().map_err(|error| {
eprintln!("ui_design_suggestion.error stage=build_client error={error}");
error
})?;
let schema = ui_design_suggestion_json_schema().map_err(|error| {
eprintln!("ui_design_suggestion.error stage=build_schema error={error}");
error
})?;
let tool = LlmFunctionTool::new(
"suggest_ui_design_semantics",
"为所有 UI 参考图补全界面语义 metadata",
schema,
)
.with_strict(true);
let response = client
.run(
LlmRunRequest::new(vec![
LlmMessage::system(SYSTEM_PROMPT),
LlmMessage::user_multimodal(parts),
])
.with_function_tools(vec![tool])
.with_tool_choice(LlmToolChoice::Required),
)
.await
.map_err(|error| {
eprintln!("ui_design_suggestion.error stage=llm_request error={error}");
format!("UI 参考图语义识别失败:{error}")
})?;
eprintln!(
"ui_design_suggestion.llm_output text_present={} tool_call_count={}",
!response.text.trim().is_empty(),
response.tool_calls.len()
);
let call = response
.tool_calls
.iter()
.find(|call| call.name == "suggest_ui_design_semantics")
.ok_or_else(|| {
eprintln!("ui_design_suggestion.error stage=parse_tool_call reason=missing_tool_call");
"LLM 响应无效(详情:未返回 suggest_ui_design_semantics 工具调用)".to_string()
})?;
let arguments = parse_limited_llm_tool_arguments(&call.arguments).map_err(|error| {
eprintln!("ui_design_suggestion.error stage=parse_arguments error={error}");
format!("LLM 响应无效(详情:UI 语义建议工具参数无效:{error}")
})?;
validate_suggestion_response_shape(&arguments).map_err(|error| {
eprintln!("ui_design_suggestion.error stage=validate_arguments error={error}");
format!("LLM 响应无效(详情:{error}")
})?;
let suggestions = serde_json::from_value::<UIDesignSuggestion>(arguments)
.map_err(|error| {
eprintln!("ui_design_suggestion.error stage=parse_arguments error={error}");
format!("LLM 响应无效(详情:UI 语义建议工具参数无效:{error}")
})?
.ui_designs;
validate_suggestions(&suggestions, &ids).map_err(|error| {
eprintln!("ui_design_suggestion.error stage=validate_result error={error}");
format!("LLM 响应无效(详情:{error}")
})?;
Ok(suggestions)
}
#[cfg(test)]
mod tests {
use super::*;
fn id(value: &str) -> UIDesignImageId {
UIDesignImageId::new(value).expect("valid image id")
}
fn node(
value: &str,
role: UIDesignImageRole,
children: Vec<UIDesignSuggestionTreeNode>,
) -> UIDesignSuggestionTreeNode {
UIDesignSuggestionTreeNode {
id: id(value),
name: value.to_string(),
description: format!("{value} description"),
role,
children,
}
}
fn image_ids(values: &[&str]) -> HashSet<UIDesignImageId> {
values.iter().map(|value| id(value)).collect()
}
#[test]
fn validates_page_tree() {
let suggestions = vec![node(
"page",
UIDesignImageRole::Page,
vec![node("section", UIDesignImageRole::Section, Vec::new())],
)];
assert!(validate_suggestions(&suggestions, &image_ids(&["page", "section"])).is_ok());
}
#[test]
fn suggestion_shape_rejects_more_than_four_nodes_or_four_levels() {
let oversized = (0..=MAX_REFERENCES)
.map(|_| serde_json::json!({"children": []}))
.collect::<Vec<_>>();
assert!(validate_suggestion_response_shape(&serde_json::json!({
"ui_designs": oversized
}))
.is_err());
let mut nested = serde_json::json!({"children": []});
for _ in 0..MAX_SUGGESTION_TREE_DEPTH {
nested = serde_json::json!({"children": [nested]});
}
assert!(validate_suggestion_response_shape(&serde_json::json!({
"ui_designs": [nested]
}))
.is_err());
}
#[test]
fn rejects_duplicate_and_unknown_ids() {
let duplicate = vec![
node(
"page",
UIDesignImageRole::Page,
vec![node("section", UIDesignImageRole::Section, Vec::new())],
),
node("page", UIDesignImageRole::Page, Vec::new()),
];
assert!(validate_suggestions(&duplicate, &image_ids(&["page", "section"])).is_err());
let unknown = vec![node("missing", UIDesignImageRole::Page, Vec::new())];
assert!(validate_suggestions(&unknown, &image_ids(&["page"])).is_err());
}
#[test]
fn rejects_invalid_root_roles_and_nested_pages() {
let non_page_root = vec![node("section", UIDesignImageRole::Section, Vec::new())];
assert!(validate_suggestions(&non_page_root, &image_ids(&["section"])).is_err());
let nested_page = vec![node(
"page",
UIDesignImageRole::Page,
vec![node("nested", UIDesignImageRole::Page, Vec::new())],
)];
assert!(validate_suggestions(&nested_page, &image_ids(&["page", "nested"])).is_err());
}
#[test]
fn strict_schema_requires_tree_fields() {
let schema = ui_design_suggestion_json_schema().expect("schema");
assert_eq!(schema["required"], serde_json::json!(["ui_designs"]));
let node_schema = &schema["properties"]["ui_designs"]["items"];
let required = node_schema["required"]
.as_array()
.expect("node required")
.iter()
.filter_map(serde_json::Value::as_str)
.collect::<HashSet<_>>();
assert_eq!(
required,
["id", "name", "description", "role", "children"]
.into_iter()
.collect()
);
assert_eq!(node_schema["additionalProperties"], false);
}
}
@@ -0,0 +1,140 @@
use base64::Engine as _;
use schemars::JsonSchema;
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
pub(crate) const LLM_TOOL_ARGUMENT_MAX_BYTES: usize = 1024 * 1024;
pub(crate) const UI_REFERENCE_IMAGE_MAX_BYTES: usize = 5 * 1024 * 1024;
pub(crate) fn parse_limited_llm_tool_arguments(
arguments: &str,
) -> Result<serde_json::Value, String> {
if arguments.len() > LLM_TOOL_ARGUMENT_MAX_BYTES {
return Err(format!(
"LLM 工具参数超过 {LLM_TOOL_ARGUMENT_MAX_BYTES} 字节上限"
));
}
serde_json::from_str(arguments).map_err(|error| format!("LLM 工具参数不是有效 JSON{error}"))
}
pub(crate) async fn read_ui_reference_image_data_url(path: PathBuf) -> Result<String, String> {
tokio::task::spawn_blocking(move || read_ui_reference_image_data_url_blocking(&path))
.await
.map_err(|error| format!("读取界面图任务失败:{error}"))?
}
fn read_ui_reference_image_data_url_blocking(path: &Path) -> Result<String, String> {
let extension = path
.extension()
.and_then(|value| value.to_str())
.map(str::to_ascii_lowercase);
let mime = match extension.as_deref() {
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("webp") => "image/webp",
_ => return Err(format!("不支持的界面图格式:{}", path.display())),
};
let mut file =
File::open(path).map_err(|error| format!("读取界面图失败:{}: {error}", path.display()))?;
let metadata = file
.metadata()
.map_err(|error| format!("读取界面图信息失败:{}: {error}", path.display()))?;
if !metadata.is_file() {
return Err(format!("界面图不是普通文件:{}", path.display()));
}
if metadata.len() > UI_REFERENCE_IMAGE_MAX_BYTES as u64 {
return Err(format!(
"单张界面图不能超过 {} MiB",
UI_REFERENCE_IMAGE_MAX_BYTES / 1024 / 1024
));
}
let mut bytes = Vec::with_capacity(metadata.len() as usize);
Read::by_ref(&mut file)
.take((UI_REFERENCE_IMAGE_MAX_BYTES + 1) as u64)
.read_to_end(&mut bytes)
.map_err(|error| format!("读取界面图失败:{}: {error}", path.display()))?;
if bytes.len() > UI_REFERENCE_IMAGE_MAX_BYTES {
return Err(format!(
"单张界面图不能超过 {} MiB",
UI_REFERENCE_IMAGE_MAX_BYTES / 1024 / 1024
));
}
Ok(format!(
"data:{mime};base64,{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
))
}
pub(crate) fn strict_json_schema<T: JsonSchema>() -> Result<serde_json::Value, String> {
let mut settings = schemars::generate::SchemaSettings::default();
settings.inline_subschemas = true;
let generator = schemars::SchemaGenerator::new(settings);
let mut schema = serde_json::to_value(generator.into_root_schema_for::<T>())
.map_err(|error| format!("生成 JSON Schema 失败:{error}"))?;
normalize_strict_schema(&mut schema);
Ok(schema)
}
fn normalize_strict_schema(schema: &mut serde_json::Value) {
match schema {
serde_json::Value::Object(object) => {
if let Some(one_of) = object.remove("oneOf") {
object.insert("anyOf".to_string(), one_of);
}
if let Some(required) = object
.get("properties")
.and_then(|value| value.as_object())
.map(|properties| {
properties
.keys()
.cloned()
.map(serde_json::Value::String)
.collect::<Vec<_>>()
})
{
object.insert("required".to_string(), serde_json::Value::Array(required));
object.insert(
"additionalProperties".to_string(),
serde_json::Value::Bool(false),
);
}
for value in object.values_mut() {
normalize_strict_schema(value);
}
}
serde_json::Value::Array(values) => {
for value in values {
normalize_strict_schema(value);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn llm_tool_arguments_limit_counts_utf8_bytes() {
let oversized = "".repeat(LLM_TOOL_ARGUMENT_MAX_BYTES / 3 + 1);
assert!(parse_limited_llm_tool_arguments(&oversized).is_err());
assert_eq!(
parse_limited_llm_tool_arguments(r#"{"ok":true}"#).expect("valid arguments"),
serde_json::json!({"ok": true})
);
}
#[test]
fn reference_image_rejects_file_over_five_mib_before_reading() {
let directory = tempfile::tempdir().expect("reference image fixture");
let path = directory.path().join("oversized.png");
let file = File::create(&path).expect("create sparse reference image");
file.set_len((UI_REFERENCE_IMAGE_MAX_BYTES + 1) as u64)
.expect("size sparse reference image");
drop(file);
assert!(read_ui_reference_image_data_url_blocking(&path).is_err());
}
}
@@ -0,0 +1,27 @@
use std::{error::Error, fmt};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ComponentValueError {
InvalidAssetId,
NonFinite,
MustBePositive,
MustBeNonNegative,
InvalidRange,
SpriteBorderExceedsSprite,
}
impl fmt::Display for ComponentValueError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self {
Self::InvalidAssetId => "资源 ID 不能为空或包含首尾空白",
Self::NonFinite => "数值必须是有限数",
Self::MustBePositive => "数值必须大于零",
Self::MustBeNonNegative => "数值不能小于零",
Self::InvalidRange => "数值范围无效",
Self::SpriteBorderExceedsSprite => "九宫格边距不能超过 Sprite 尺寸",
};
formatter.write_str(message)
}
}
impl Error for ComponentValueError {}
@@ -0,0 +1 @@
pub mod error;
@@ -0,0 +1,278 @@
use std::{error::Error, fmt};
use crate::ui_editor::component::common::error::ComponentValueError;
use crate::ui_editor::layout::transform::Transform;
use crate::ui_editor::resource::sprite::SpriteAsset;
use crate::ui_editor::utils::SpriteAssetId;
use nalgebra::Vector2;
use schemars::JsonSchema;
use serde::{de::Deserializer, Deserialize, Serialize};
use ts_rs::TS;
use typed_floats::tf32::StrictlyPositiveFinite;
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum HorizontalFillOrigin {
Left,
Right,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum VerticalFillOrigin {
Bottom,
Top,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum Radial90Origin {
BottomLeft,
TopLeft,
TopRight,
BottomRight,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum Radial180Origin {
Bottom,
Left,
Top,
Right,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum Radial360Origin {
Bottom,
Right,
Top,
Left,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum FillMethod {
Horizontal(HorizontalFillOrigin),
Vertical(VerticalFillOrigin),
Radial90 {
origin: Radial90Origin,
clockwise: bool,
},
Radial180 {
origin: Radial180Origin,
clockwise: bool,
},
Radial360 {
origin: Radial360Origin,
clockwise: bool,
},
}
fn validate_fill_amount(amount: f32) -> Result<(), ComponentValueError> {
if !amount.is_finite() {
return Err(ComponentValueError::NonFinite);
}
if !(0.0..=1.0).contains(&amount) {
return Err(ComponentValueError::InvalidRange);
}
Ok(())
}
fn deserialize_fill_amount<'de, D>(deserializer: D) -> Result<f32, D::Error>
where
D: Deserializer<'de>,
{
let amount = f32::deserialize(deserializer)?;
validate_fill_amount(amount).map_err(serde::de::Error::custom)?;
Ok(amount)
}
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum ImageType {
Simple {
preserve_aspect: bool,
},
Sliced {
fill_center: bool,
#[schemars(with = "f32")]
#[ts(as = "f32")]
pixels_per_unit_multiplier: StrictlyPositiveFinite,
},
Tiled {
fill_center: bool,
#[schemars(with = "f32")]
#[ts(as = "f32")]
pixels_per_unit_multiplier: StrictlyPositiveFinite,
},
Filled {
preserve_aspect: bool,
method: FillMethod,
#[serde(deserialize_with = "deserialize_fill_amount")]
amount: f32,
},
}
impl ImageType {
pub const fn sliced(
fill_center: bool,
pixels_per_unit_multiplier: StrictlyPositiveFinite,
) -> Self {
Self::Sliced {
fill_center,
pixels_per_unit_multiplier,
}
}
pub const fn tiled(
fill_center: bool,
pixels_per_unit_multiplier: StrictlyPositiveFinite,
) -> Self {
Self::Tiled {
fill_center,
pixels_per_unit_multiplier,
}
}
pub fn filled(
preserve_aspect: bool,
method: FillMethod,
amount: f32,
) -> Result<Self, ComponentValueError> {
validate_fill_amount(amount)?;
Ok(Self::Filled {
preserve_aspect,
method,
amount,
})
}
pub fn supports_native_size(self) -> bool {
matches!(self, Self::Simple { .. } | Self::Filled { .. })
}
pub fn uses_sprite_border(self) -> bool {
matches!(self, Self::Sliced { .. } | Self::Tiled { .. })
}
pub fn tiles_center(self) -> bool {
matches!(
self,
Self::Tiled {
fill_center: true,
..
}
)
}
}
impl Default for ImageType {
fn default() -> Self {
Self::Simple {
preserve_aspect: false,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SetNativeSizeError {
MissingSprite,
MismatchedSprite,
UnsupportedImageType,
}
impl fmt::Display for SetNativeSizeError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self {
Self::MissingSprite => "Image 没有 Target Graphic Sprite",
Self::MismatchedSprite => "提供的 Sprite 与 Image Target Graphic 不匹配",
Self::UnsupportedImageType => "只有 Simple 或 Filled Image 支持 Set Native Size",
};
formatter.write_str(message)
}
}
impl Error for SetNativeSizeError {}
/// Image 渲染组件;布局由同一 Entity 上独立的 `Control` 提供。
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub struct ImageComponent {
pub target_graphic: Option<SpriteAssetId>,
pub image_type: ImageType,
}
impl ImageComponent {
pub fn new() -> Self {
Self {
target_graphic: None,
image_type: ImageType::default(),
}
}
/// 跨组件把布局重置为 Sprite 的原始尺寸。
pub fn set_native_size(
&self,
sprite: &SpriteAsset,
control: &mut Transform,
) -> Result<Vector2<f32>, SetNativeSizeError> {
if !self.image_type.supports_native_size() {
return Err(SetNativeSizeError::UnsupportedImageType);
}
let target_graphic = self
.target_graphic
.as_ref()
.ok_or(SetNativeSizeError::MissingSprite)?;
if target_graphic != sprite.asset_id() {
return Err(SetNativeSizeError::MismatchedSprite);
}
let native_size = sprite.native_size();
control.anchor_max = control.anchor_min;
control.offset_max = control.offset_min + native_size;
Ok(native_size)
}
}
#[cfg(test)]
mod tests {
use super::{FillMethod, HorizontalFillOrigin, ImageType};
#[test]
fn filled_amount_is_validated_when_deserialized() {
let valid = serde_json::json!({
"Filled": {
"preserve_aspect": false,
"method": { "Horizontal": "Left" },
"amount": 0.5
}
});
let image_type: ImageType = serde_json::from_value(valid).expect("valid fill amount");
assert!(matches!(
image_type,
ImageType::Filled {
method: FillMethod::Horizontal(HorizontalFillOrigin::Left),
amount: 0.5,
..
}
));
for amount in ["-0.1", "1.1", "null"] {
let value = serde_json::json!({
"Filled": {
"preserve_aspect": false,
"method": { "Horizontal": "Left" },
"amount": serde_json::from_str::<serde_json::Value>(amount).unwrap()
}
});
assert!(serde_json::from_value::<ImageType>(value).is_err());
}
}
}
impl Default for ImageComponent {
fn default() -> Self {
Self::new()
}
}
@@ -0,0 +1,11 @@
pub mod common;
pub mod image;
pub mod text;
#[derive(
Clone, Debug, PartialEq, schemars::JsonSchema, serde::Deserialize, serde::Serialize, ts_rs::TS,
)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum Component {
Image(image::ImageComponent),
Text(text::TextComponent),
}
@@ -0,0 +1,195 @@
use crate::ui_editor::component::common::error::ComponentValueError;
use crate::ui_editor::resource::sprite::{Color, WHITE};
use crate::ui_editor::utils::FontAssetId;
use schemars::JsonSchema;
use serde::{de::Deserializer, Deserialize, Serialize};
use std::num::NonZeroU32;
use ts_rs::TS;
use typed_floats::tf32::StrictlyPositiveFinite;
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum FontStyle {
Normal,
Bold,
Italic,
BoldItalic,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum TextAlignment {
UpperLeft,
UpperCenter,
UpperRight,
MiddleLeft,
MiddleCenter,
MiddleRight,
LowerLeft,
LowerCenter,
LowerRight,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum HorizontalTextOverflow {
Wrap,
Overflow,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum VerticalTextOverflow {
Truncate,
Overflow,
}
#[derive(Clone, Copy, Debug, JsonSchema, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub struct BestFitFontSize {
min: NonZeroU32,
max: NonZeroU32,
}
impl<'de> Deserialize<'de> for BestFitFontSize {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct RawBestFitFontSize {
min: NonZeroU32,
max: NonZeroU32,
}
let raw = RawBestFitFontSize::deserialize(deserializer)?;
Self::new(raw.min, raw.max).map_err(serde::de::Error::custom)
}
}
impl BestFitFontSize {
pub fn new(min: NonZeroU32, max: NonZeroU32) -> Result<Self, ComponentValueError> {
if min > max {
return Err(ComponentValueError::InvalidRange);
}
Ok(Self { min, max })
}
pub fn min(self) -> NonZeroU32 {
self.min
}
pub fn max(self) -> NonZeroU32 {
self.max
}
}
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum FontSizing {
Fixed(NonZeroU32),
BestFit(BestFitFontSize),
}
impl Default for FontSizing {
fn default() -> Self {
Self::Fixed(NonZeroU32::new(14).expect("14 is non-zero"))
}
}
/// 文本的字体来源。系统字体是一个明确的有效选择,而不是缺失的字体绑定。
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum FontSource {
SystemFont,
Bound(FontAssetId),
}
/// Text / Label 渲染组件;布局由同一 Entity 上独立的 `Control` 提供。
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub struct TextComponent {
pub content: String,
pub font: FontSource,
pub font_style: FontStyle,
pub font_sizing: FontSizing,
#[ts(as = "[u8; 4]")]
pub color: Color,
pub alignment: TextAlignment,
pub horizontal_overflow: HorizontalTextOverflow,
pub vertical_overflow: VerticalTextOverflow,
#[schemars(with = "f32")]
#[ts(as = "f32")]
pub line_spacing: StrictlyPositiveFinite,
}
impl TextComponent {
pub fn new(content: impl Into<String>) -> Self {
Self {
content: content.into(),
font: FontSource::SystemFont,
font_style: FontStyle::Normal,
font_sizing: FontSizing::default(),
color: WHITE,
alignment: TextAlignment::UpperLeft,
horizontal_overflow: HorizontalTextOverflow::Wrap,
vertical_overflow: VerticalTextOverflow::Truncate,
line_spacing: StrictlyPositiveFinite::new(1.0).expect("1.0 is positive and finite"),
}
}
}
impl Default for TextComponent {
fn default() -> Self {
Self::new(String::new())
}
}
#[cfg(test)]
mod best_fit_tests {
use super::{BestFitFontSize, FontSizing};
#[test]
fn best_fit_font_size_is_validated_when_deserialized() {
let valid: BestFitFontSize =
serde_json::from_value(serde_json::json!({ "min": 12, "max": 24 }))
.expect("valid font-size range");
assert_eq!(valid.min().get(), 12);
assert_eq!(valid.max().get(), 24);
assert!(serde_json::from_value::<BestFitFontSize>(
serde_json::json!({ "min": 24, "max": 12 }),
)
.is_err());
assert!(serde_json::from_value::<FontSizing>(
serde_json::json!({ "BestFit": { "min": 24, "max": 12 } }),
)
.is_err());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn system_font_is_an_explicit_serialized_choice() {
let component = TextComponent::default();
assert_eq!(
serde_json::to_value(component).expect("serialize default text component")["font"],
serde_json::json!("SystemFont")
);
}
#[test]
fn bound_font_preserves_its_asset_id() {
let component = TextComponent {
font: FontSource::Bound(FontAssetId::new("font-main").expect("valid font id")),
..TextComponent::default()
};
assert_eq!(
serde_json::to_value(component).expect("serialize bound font")["font"],
serde_json::json!({ "Bound": "font-main" })
);
}
}
@@ -0,0 +1,9 @@
use serde::{Deserialize, Serialize};
use ts_rs::TS;
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum ChildrenDisplayMode {
Stack,
Exclusive,
}
@@ -0,0 +1,115 @@
use crate::ui_editor::layout::transform::Transform;
use nalgebra::Vector2;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
/// Godot Control 的 Container 相关布局属性。
///
/// `Transform` 始终被保留。只有直接父节点为 Container 时,父节点才会
/// 忽略 child 的 anchors / offsets,改读 minimum size 与 size flags。
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub struct ControlLayout {
// TODO let llm output container layout
pub transform: Transform,
#[ts(as = "[f32; 2]")]
pub custom_minimum_size: Vector2<f32>,
#[ts(as = "u8")]
pub size_flags_horizontal: u8,
#[ts(as = "u8")]
pub size_flags_vertical: u8,
pub size_flags_stretch_ratio: f32,
pub container: Container,
}
impl Default for ControlLayout {
fn default() -> Self {
Self {
transform: Transform::default(),
custom_minimum_size: Vector2::zeros(),
size_flags_horizontal: SIZE_FLAG_FILL,
size_flags_vertical: SIZE_FLAG_FILL,
size_flags_stretch_ratio: 1.0,
container: Container::None,
}
}
}
impl ControlLayout {
pub fn with_transform(transform: Transform) -> Self {
Self {
transform,
..Self::default()
}
}
}
/// Godot Control size flags 的有效 bitmask 值。
///
/// JSON 中使用 Godot 兼容数值:0/1/2/3/4/8。
pub const SIZE_FLAG_FILL: u8 = 1;
pub const fn is_valid_size_flag(value: u8) -> bool {
matches!(value, 0 | 1 | 2 | 3 | 4 | 8)
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum ContainerAlignment {
Begin,
Center,
End,
}
/// 互斥 Container 定义。没有 Container 即普通 Control。
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum Container {
None,
HBox {
alignment: ContainerAlignment,
separation: f32,
},
VBox {
alignment: ContainerAlignment,
separation: f32,
},
Grid {
columns: u32,
h_separation: f32,
v_separation: f32,
},
Margin {
margin_left: f32,
margin_top: f32,
margin_right: f32,
margin_bottom: f32,
},
Center {
use_top_left: bool,
},
}
impl Container {
pub const fn hbox() -> Self {
Self::HBox {
alignment: ContainerAlignment::Begin,
separation: 4.0,
}
}
pub const fn vbox() -> Self {
Self::VBox {
alignment: ContainerAlignment::Begin,
separation: 4.0,
}
}
pub const fn grid() -> Self {
Self::Grid {
columns: 1,
h_separation: 4.0,
v_separation: 4.0,
}
}
}

Some files were not shown because too many files have changed in this diff Show More