融合主分支UI编辑器与资源工作台
保留资源自由画板、详情卡与图片精修交互。 接入主分支UI设计资源创建与编辑入口。 合并主分支凭据目录权限和画布相关更新。
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- codex/ai-game-creator-app
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -59,6 +58,16 @@ jobs:
|
||||
else
|
||||
base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)"
|
||||
fi
|
||||
resolved_base_ref="$(git rev-parse --verify "${base_ref}^{commit}" 2>/dev/null || true)"
|
||||
head_ref="$(git rev-parse HEAD)"
|
||||
if [[ "${resolved_base_ref}" == "${head_ref}" ]]; then
|
||||
resolved_base_ref="$(git rev-parse --verify HEAD^ 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -z "${resolved_base_ref}" ]]; then
|
||||
echo 'comparison base must resolve to a commit distinct from HEAD.' >&2
|
||||
exit 1
|
||||
fi
|
||||
base_ref="${resolved_base_ref}"
|
||||
if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \
|
||||
&& ! git merge-base --is-ancestor "${base_ref}" HEAD; then
|
||||
echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2
|
||||
@@ -139,6 +148,16 @@ jobs:
|
||||
else
|
||||
base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)"
|
||||
fi
|
||||
resolved_base_ref="$(git rev-parse --verify "${base_ref}^{commit}" 2>/dev/null || true)"
|
||||
head_ref="$(git rev-parse HEAD)"
|
||||
if [[ "${resolved_base_ref}" == "${head_ref}" ]]; then
|
||||
resolved_base_ref="$(git rev-parse --verify HEAD^ 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -z "${resolved_base_ref}" ]]; then
|
||||
echo 'comparison base must resolve to a commit distinct from HEAD.' >&2
|
||||
exit 1
|
||||
fi
|
||||
base_ref="${resolved_base_ref}"
|
||||
if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \
|
||||
&& ! git merge-base --is-ancestor "${base_ref}" HEAD; then
|
||||
echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2
|
||||
|
||||
@@ -48,6 +48,8 @@ temp*build*/
|
||||
/public/generated-characters
|
||||
/.codex-temp
|
||||
/.app/
|
||||
/.jenkins-source-commit
|
||||
/.jenkins-spacetime-schema-base
|
||||
/target/
|
||||
/logs
|
||||
/.claude/settings.local.json
|
||||
|
||||
@@ -3,6 +3,7 @@ node_modules
|
||||
.git
|
||||
.codex-logs
|
||||
public/Icons
|
||||
apps/ai-game-creator-shell/src/features/ui-editor/types/
|
||||
media
|
||||
*.log
|
||||
.preview.*
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
# AI Native Visual RPG
|
||||
# Genarrative / 陶泥儿
|
||||
|
||||
一个以“AI 叙事 + 本地规则 + 像素演出”为核心的视觉 RPG 原型。
|
||||
一个 AI 原生互动内容与小游戏平台,当前主站聚焦图片画布创作、项目与素材管理,以及账号、钱包和后台管理等平台公共能力。
|
||||
|
||||
当前已经具备这些主要能力:
|
||||
|
||||
- 世界与角色选择
|
||||
- AI 剧情推进与流式对话
|
||||
- 战斗演出、NPC 战斗、切磋
|
||||
- NPC 交易、送礼、求助、招募
|
||||
- 宝藏交互
|
||||
- 同伴跟随与战斗
|
||||
- 游戏主流程内嵌的角色资产工坊、自定义世界实体编辑与角色形象编辑
|
||||
- 自动存档与继续游戏
|
||||
- 图片画布编辑、项目与素材管理
|
||||
- 图片、视频、音频等外部生成任务
|
||||
- 账号、钱包、充值、兑换码与个人资料
|
||||
- 后台运营、配置与生产运维工具
|
||||
- AI 游戏创作独立 App
|
||||
|
||||
## 运行
|
||||
|
||||
@@ -30,11 +27,8 @@ npm install
|
||||
准备环境变量:
|
||||
|
||||
- 复制 `.env.example` 为 `.env.local`
|
||||
- 填入 `LLM_API_KEY` / `ARK_API_KEY`
|
||||
- 按需设置 `VITE_LLM_MODEL`
|
||||
- 如需启用阿里云短信验证码登录,填写 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET`,并确认 `SMS_AUTH_PROVIDER="aliyun"`
|
||||
- 本地联调短信登录时,建议将 `VITE_AUTH_ALLOW_DEV_GUEST` 设为 `false`,避免开发模式自动进入游客账号而跳过登录页
|
||||
- 如需打印完整 prompt/output,可把 `VITE_LLM_DEBUG_LOG` 设为 `true`
|
||||
- 只填写本次联调所需配置;不要提交 `.env.local`、密钥、Token 或其它本地认证信息
|
||||
- api-server 环境变量和 Provider 配置以 `.env.example` 及当前开发运维文档为准
|
||||
|
||||
启动开发环境:
|
||||
|
||||
@@ -69,51 +63,21 @@ npm run lint
|
||||
npm run check:encoding
|
||||
```
|
||||
|
||||
内容引用校验:
|
||||
|
||||
```bash
|
||||
npm run check:data
|
||||
```
|
||||
|
||||
编辑器 override 校验:
|
||||
|
||||
```bash
|
||||
npm run check:overrides
|
||||
```
|
||||
|
||||
关键内容 smoke 检查:
|
||||
|
||||
```bash
|
||||
npm run check:smoke
|
||||
```
|
||||
|
||||
一键内容检查:
|
||||
|
||||
```bash
|
||||
npm run check:content
|
||||
```
|
||||
|
||||
## 主要结构
|
||||
|
||||
主运行时:
|
||||
|
||||
- [src/App.tsx](./src/App.tsx)
|
||||
- [src/active-main.tsx](./src/active-main.tsx)
|
||||
- [src/ActiveApp.tsx](./src/ActiveApp.tsx)
|
||||
- [src/AuthenticatedApp.tsx](./src/AuthenticatedApp.tsx)
|
||||
- [src/routing/appRoutes.tsx](./src/routing/appRoutes.tsx)
|
||||
- [src/hooks/useCombatFlow.ts](./src/hooks/useCombatFlow.ts)
|
||||
- [src/routing/activeAppRoutes.tsx](./src/routing/activeAppRoutes.tsx)
|
||||
- [src/components/platform-entry/PlatformEntryActiveFlowShell.tsx](./src/components/platform-entry/PlatformEntryActiveFlowShell.tsx)
|
||||
|
||||
主流程内嵌编辑能力:
|
||||
创作与项目能力:
|
||||
|
||||
- [src/components/image-editor/ImageCanvasEditorView.tsx](./src/components/image-editor/ImageCanvasEditorView.tsx)
|
||||
- [src/components/rpg-creation-editor/RpgCreationEntityEditorModal.tsx](./src/components/rpg-creation-editor/RpgCreationEntityEditorModal.tsx)
|
||||
- [src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.tsx](./src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.tsx)
|
||||
|
||||
核心数据:
|
||||
|
||||
- [src/data/scenePresets.ts](./src/data/scenePresets.ts)
|
||||
- [src/data/characterPresets.ts](./src/data/characterPresets.ts)
|
||||
- [src/data/npcInteractions.ts](./src/data/npcInteractions.ts)
|
||||
- [src/data/treasureInteractions.ts](./src/data/treasureInteractions.ts)
|
||||
- [src/components/creation-home/CreationLandingView.tsx](./src/components/creation-home/CreationLandingView.tsx)
|
||||
- [src/components/project/ProjectGalleryView.tsx](./src/components/project/ProjectGalleryView.tsx)
|
||||
|
||||
## 文档入口
|
||||
|
||||
|
||||
+303
-12
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -117,6 +117,7 @@ const allowedUncalledTauriCommands = [
|
||||
'archive_failed_local_project_resource_edit',
|
||||
'archive_failed_local_project_asset_canvas_generation',
|
||||
'chat_with_game_creator_agent',
|
||||
'check_ui_editor_font_glyph_coverage',
|
||||
'commit_local_project_asset',
|
||||
'commit_local_project_asset_canvas_candidate',
|
||||
'confirm_local_project_asset_canvas_generation_service_identity',
|
||||
|
||||
+199
-1
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -862,8 +862,14 @@ async fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
|
||||
.expect("waiting main task exists");
|
||||
let wake_started = wake_waiting_static_delegate_parent_run_at(&root, &waiting)
|
||||
.expect("wake same main run after art receipt");
|
||||
cancel_game_creator_agent_runtime_task_at(&root, "code-prototype", &main.run_id)
|
||||
.expect("cancel resumed fixture run");
|
||||
if let Err(error) =
|
||||
cancel_game_creator_agent_runtime_task_at(&root, "code-prototype", &main.run_id)
|
||||
{
|
||||
assert!(
|
||||
error.contains("Agent Runtime 任务已结束,不能取消"),
|
||||
"cancel resumed fixture run: {error}"
|
||||
);
|
||||
}
|
||||
let release_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
if game_creator_agent_runtime_task_lock_is_available(&root, "code-prototype")
|
||||
|
||||
@@ -189,8 +189,9 @@ fn ensure_plain_private_external_editor_directory(
|
||||
}
|
||||
|
||||
/// Prepares the exact private directory before a one-time remote developer key
|
||||
/// is requested. Existing directories are verified, not adopted; only the
|
||||
/// directory created by this invocation may have its Windows owner initialized.
|
||||
/// is requested. Existing directories must already belong to the current user,
|
||||
/// but their DACL may be tightened locally; only a directory created by this
|
||||
/// invocation may have its Windows owner initialized.
|
||||
fn prepare_private_external_editor_api_credentials_parent_dir_at(
|
||||
path: &Path,
|
||||
) -> Result<(), String> {
|
||||
@@ -206,7 +207,7 @@ fn prepare_private_external_editor_api_credentials_parent_dir_at(
|
||||
if parent_created {
|
||||
initialize_windows_game_creator_directory_owner_for_current_user(parent)?;
|
||||
} else {
|
||||
secure_windows_game_creator_path_for_current_user(parent, true, false)?;
|
||||
secure_windows_game_creator_path_for_current_user(parent, true, true)?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
if parent_created {
|
||||
@@ -1737,6 +1738,33 @@ mod tests {
|
||||
.expect("new credential directory must be owned by TokenUser");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn existing_current_user_credential_directory_is_tightened_before_remote_creation() {
|
||||
let root = tempfile::tempdir().expect("temp dir");
|
||||
let parent = root.path().join("config").join("genarrative");
|
||||
fs::create_dir_all(&parent).expect("create existing credential directory");
|
||||
initialize_windows_game_creator_directory_owner_for_current_user(&parent)
|
||||
.expect("initialize current-user owner before inherited ACL fixture");
|
||||
|
||||
let status = std::process::Command::new("icacls.exe")
|
||||
.arg(&parent)
|
||||
.arg("/inheritance:e")
|
||||
.status()
|
||||
.expect("run inherited ACL fixture command");
|
||||
assert!(status.success(), "enable inherited ACL fixture");
|
||||
assert!(
|
||||
secure_windows_game_creator_path_for_current_user(&parent, true, false).is_err(),
|
||||
"fixture must reproduce the inherited ACL rejection"
|
||||
);
|
||||
|
||||
let path = parent.join("external-editor-api-test.json");
|
||||
prepare_private_external_editor_api_credentials_parent_dir_at(&path)
|
||||
.expect("current-user directory should be tightened locally before remote creation");
|
||||
secure_windows_game_creator_path_for_current_user(&parent, true, false)
|
||||
.expect("prepared existing directory must be private");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_runtime_private_credentials_force_external_v1_routes_without_session_copy() {
|
||||
let credentials = ExternalEditorApiCredentials {
|
||||
|
||||
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,
|
||||
@@ -2494,3 +2576,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();
|
||||
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 });
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user