From 4bf6c50a563115627ab4fa4d3d66971613b7607d Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:11:45 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E8=80=85=E5=87=AD=E6=8D=AE=E7=9B=AE=E5=BD=95=E6=9D=83=E9=99=90?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 允许客户端自动收紧当前用户持有目录的继承 ACL 保留 foreign owner 与异常路径的失败关闭边界 补充 Windows 回归测试并同步实施决策文档 --- .../src-tauri/src/assets.rs | 34 +++++++++++++++++-- ...计划】AGC直连Codex Runtime迁移-2026-08-15.md | 2 +- .../shared-memory/decision-log.md | 6 ++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index a7097a6ce..e6cc8ef2b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -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 { diff --git a/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md b/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md index d205aa1cc..4e871f892 100644 --- a/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md +++ b/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md @@ -241,7 +241,7 @@ 首次真实客户端验收表明,失败不是 GUI 登录态未同步:首次本机开发者凭据创建已经拿到远端响应,但 Windows 新建的私有目录 owner 是 `Administrators`,随后 TokenUser 私有 DACL 校验拒绝落盘。一次性显示的远端凭据无法恢复,旧顺序会留下孤儿凭据,因此不能用刷新登录态或自动重放掩盖。 -1. 缺失本机开发者凭据时,客户端必须先准备并验证精确的私有存储目录,再请求远端创建凭据。仅当该精确目录由当前调用以原子创建成功时,才允许初始化 owner 和私有 DACL 为当前 TokenUser;已有目录一律按严格 owner/DACL 校验,owner 不匹配时失败关闭,不自动接管、改 ACL、覆盖或删除。 +1. 缺失本机开发者凭据时,客户端必须先准备并验证精确的私有存储目录,再请求远端创建凭据。仅当该精确目录由当前调用以原子创建成功时,才允许初始化 owner 为当前 TokenUser;已有目录必须先严格核对 owner,owner 匹配当前 TokenUser 时由客户端自动收紧为禁止继承且仅当前用户 Full Control,owner 不匹配时仍失败关闭,不自动接管、改 ACL、覆盖或删除。 2. 若本机目录预检失败,客户端返回稳定的“本机开发者凭据存储目录未安全初始化;未创建远端凭据”分类,不请求远端创建接口、不发起美术生成、不写 operation 或账本,也不刷新登录态或自动重试。 3. 远端响应后原子写入仍可能因并发或磁盘故障失败;该极窄路径必须单独分类为“凭据已创建但未能安全保存”,提示用户在账户开发者凭据页面撤销后再试,不能自动创建第二把凭据。诊断和正式 UI 只展示上述安全摘要与恢复建议,不包含 access token、开发者凭据、响应正文、绝对路径或签名 URL。 4. 验收先在当前失败项目上恢复:对遗留的空且 owner 不匹配目录做可恢复隔离后,再复用同一项目发送“继续完成此前三消游戏”。成功标准包括本机私钥存在但不读取其内容、平台素材与版本登记完成、desktop/mobile Chromium 试玩证据和隔离进程恢复;此前已创建但无法恢复的远端孤儿凭据作为明确剩余风险,绝不自动撤销。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 88ba1980e..80a79b78a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -14271,3 +14271,9 @@ - 验证:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml canvas_generation_tests:: -- --test-threads=1`。 - 2026-08-19 追加:同一默认认证切换也覆盖资源编辑和 autonomous main-loop fixture。`resource_editor` 的 External Editor 视频提交/轮询/服务身份恢复测试同样使用 task-local 凭据;平台账号语义测试使用隔离测试会话。main-loop 的视觉任务配置测试不再通过旧 `editorApi` 文件伪造登录态。所有 loopback listener 在 accept 时设有 5 秒 deadline,并把 accepted stream 恢复为 blocking,避免 Windows `WouldBlock(10035)` 或请求未发出时无限等待。 - 定向验证:`project::asset_canvas::generation::tests::` 14/14、`project::resource_editor::tests::` 36/36、`agent::runtime_driver::main_loop_tests::` 48/48、`agent::runtime_protocol::autonomous_completion_contract_tests::` 107/107 通过。此前一次 Windows 全量 Native Rust 为 1811 passed、108 failed、15 ignored;失败集合仍包含 Provider/mock 调度与既有专业链断言。HEAD 基线独立复现 `tests::project::generate_platform_art_asset_downloads_and_registers_external_image` 的同一登录态缺失,故不能把全量结果伪报为本次 fixture 修复引入;本次新增认证/accept deadline 相关用例均已隔离通过,最后两个 autonomous completion fixture 的认证迁移已单独通过,完整套件未在该两行测试改动后重新执行。 + +## 2026-08-20 AGC 本机开发者凭据目录 ACL 自动收紧 + +- 缺失当前服务器对应的本机开发者凭据时,客户端仍必须在请求远端创建 Key 前完成私有目录准备。既有 `~/.config/genarrative` 若 owner 已严格匹配当前进程 `TokenUser`,客户端自动把 DACL 收紧为禁止继承且仅当前用户 Full Control,用户不再需要手工执行 PowerShell ACL 修复。 +- 自动收紧不等于接管:owner 不匹配、链接、reparse point、非目录或无法安全写入 DACL 时继续在远端请求前失败关闭;客户端不得删除、移动、覆盖或读取旧凭据内容,也不得因收紧失败自动创建远端 Key。 +- Windows 回归测试必须构造“owner 为当前用户但仍继承 ACL”的既有目录,先证明严格校验失败,再通过正式目录准备入口收紧并复核私有 DACL。 From 0797410eef9f157e67c7492198ddad4483b000ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 20 Aug 2026 19:07:25 +0800 Subject: [PATCH 2/5] =?UTF-8?q?Feat/UI=E8=87=AA=E5=8A=A8=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E5=92=8C=E5=B8=83=E5=B1=80=E7=BC=96=E8=BE=91=E5=99=A8=E7=AE=80?= =?UTF-8?q?=E5=8C=96=E7=89=88=20(#170)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 支持从项目, 本地电脑, 主站导入/引用素材. * 页面复杂, 需要足够空间, 作为资源打开时会占满右侧 * 编辑结果作为一种新的美术资源. * 新增素材按钮现在是空的实现, 临时在旁边加了一个新增UI设计的按钮 临时入口: ![shotmd-1787128060.jpg](/attachments/349a4fa8-7abe-4bbb-80e0-73b1717b6488) 简化: * 组件实现了最基本的图片和文本 * 容器式布局未接入llm编辑 * 不同阶段统一维护一个状态State, 任何阶段都可以人工调整, 不设单独的步骤 * 多图的UI合并功能暂时不要求, 隐藏入口 * 因为生成工具尚未从主站迁移, 主站地图子画布未实现, 以tab栏的形式容纳素材的子画布需求搁置 * 未实现llm绑定字体功能 * 识别会替换现有UI树, 暂时不是增量的 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/170 Co-authored-by: 王德宇 Co-committed-by: 王德宇 --- .eslintrc.cjs | 1 + .prettierignore | 1 + apps/ai-game-creator-shell/package-lock.json | 315 ++- apps/ai-game-creator-shell/package.json | 5 + .../scripts/check-config.mjs | 13 +- .../scripts/start-dev-stack.mjs | 2 +- .../src-tauri/Cargo.lock | 200 +- .../src-tauri/Cargo.toml | 5 + .../src-tauri/capabilities/developer.json | 7 + .../src-tauri/capabilities/main.json | 3 +- .../src-tauri/src/commands.rs | 1015 +++++++++ .../src-tauri/src/main.rs | 83 + .../src/ui_editor/commands/binding.rs | 418 ++++ .../src-tauri/src/ui_editor/commands/merge.rs | 630 ++++++ .../src-tauri/src/ui_editor/commands/mod.rs | 14 + .../src/ui_editor/commands/recognition.rs | 775 +++++++ .../commands/ui_design_suggestion.rs | 341 +++ .../src-tauri/src/ui_editor/commands/utils.rs | 140 ++ .../src/ui_editor/component/common/error.rs | 27 + .../src/ui_editor/component/common/mod.rs | 1 + .../src/ui_editor/component/image.rs | 278 +++ .../src-tauri/src/ui_editor/component/mod.rs | 11 + .../src-tauri/src/ui_editor/component/text.rs | 195 ++ .../ui_editor/layout/children_display_mode.rs | 9 + .../src/ui_editor/layout/control_layout.rs | 115 + .../src/ui_editor/layout/dimension.rs | 39 + .../src-tauri/src/ui_editor/layout/mod.rs | 5 + .../src-tauri/src/ui_editor/layout/node.rs | 57 + .../src/ui_editor/layout/transform.rs | 215 ++ .../src-tauri/src/ui_editor/mod.rs | 7 + .../src-tauri/src/ui_editor/persistence.rs | 1183 +++++++++++ .../src-tauri/src/ui_editor/resource/font.rs | 213 ++ .../src-tauri/src/ui_editor/resource/mod.rs | 3 + .../src/ui_editor/resource/sprite.rs | 137 ++ .../src/ui_editor/resource/ui_design_image.rs | 48 + .../src-tauri/src/ui_editor/state/mod.rs | 24 + .../src-tauri/src/ui_editor/utils.rs | 45 + apps/ai-game-creator-shell/src/App.tsx | 4 +- apps/ai-game-creator-shell/src/app/types.ts | 23 +- .../AssetImporter/FontImporterPreview.tsx | 82 + .../AssetImporter/ImageImporterPreview.tsx | 72 + .../src/components/AssetImporter/index.tsx | 442 ++++ .../AssetImporter/react-file-manager.d.ts | 35 + .../src/components/AssetImporter/settings.ts | 122 ++ .../src/components/AssetImporter/utils.ts | 271 +++ .../src/components/modal/ThemedModal.tsx | 118 ++ .../src/features/agent-runtime/model.ts | 60 +- .../src/features/agent-runtime/panels.tsx | 16 +- .../app-shell/useDeveloperAgentPanel.ts | 44 +- .../src/features/ui-editor/binding.ts | 19 + .../src/features/ui-editor/bindingOverview.ts | 119 ++ .../components/FontSamplePreview.tsx | 72 + .../components/SpriteImagePreview.tsx | 12 + .../src/features/ui-editor/importAdapter.ts | 120 ++ .../src/features/ui-editor/merge.ts | 10 + .../ui-editor/nodeTransformGeometry.ts | 203 ++ .../src/features/ui-editor/recognition.ts | 16 + .../src/features/ui-editor/requisites.ts | 230 ++ .../src/features/ui-editor/spriteBorder.ts | 73 + .../features/ui-editor/stageStatusOverview.ts | 100 + .../src/features/ui-editor/treeUtils.ts | 16 + .../ui-editor/types/BestFitFontSize.ts | 3 + .../features/ui-editor/types/BindingChange.ts | 6 + .../features/ui-editor/types/BindingDTO.ts | 4 + .../ui-editor/types/ChildrenDisplayMode.ts | 3 + .../src/features/ui-editor/types/Component.ts | 5 + .../src/features/ui-editor/types/Container.ts | 7 + .../ui-editor/types/ContainerAlignment.ts | 3 + .../features/ui-editor/types/ControlLayout.ts | 11 + .../features/ui-editor/types/FillMethod.ts | 8 + .../src/features/ui-editor/types/FontAsset.ts | 5 + .../features/ui-editor/types/FontAssetId.ts | 3 + .../ui-editor/types/FontAssetMetadata.ts | 4 + .../features/ui-editor/types/FontFormat.ts | 3 + .../features/ui-editor/types/FontSizing.ts | 4 + .../features/ui-editor/types/FontSource.ts | 7 + .../src/features/ui-editor/types/FontStyle.ts | 3 + .../ui-editor/types/HorizontalFillOrigin.ts | 3 + .../ui-editor/types/HorizontalTextOverflow.ts | 3 + .../ui-editor/types/ImageComponent.ts | 8 + .../src/features/ui-editor/types/ImageType.ts | 4 + .../src/features/ui-editor/types/MergeDTO.ts | 4 + .../src/features/ui-editor/types/Node.ts | 8 + .../src/features/ui-editor/types/NodeId.ts | 3 + .../features/ui-editor/types/NodeMetadata.ts | 5 + .../features/ui-editor/types/NodeSource.ts | 3 + .../ui-editor/types/Radial180Origin.ts | 3 + .../ui-editor/types/Radial360Origin.ts | 3 + .../ui-editor/types/Radial90Origin.ts | 3 + .../ui-editor/types/RecognitionDTO.ts | 4 + .../features/ui-editor/types/SpriteAsset.ts | 6 + .../features/ui-editor/types/SpriteAssetId.ts | 3 + .../ui-editor/types/SpriteAssetMetadata.ts | 3 + .../features/ui-editor/types/SpriteBorder.ts | 3 + .../features/ui-editor/types/StageStatus.ts | 3 + .../src/features/ui-editor/types/State.ts | 10 + .../features/ui-editor/types/TextAlignment.ts | 3 + .../features/ui-editor/types/TextComponent.ts | 12 + .../src/features/ui-editor/types/Transform.ts | 3 + .../features/ui-editor/types/UIDesignImage.ts | 4 + .../ui-editor/types/UIDesignImageId.ts | 3 + .../ui-editor/types/UIDesignImageMetadata.ts | 5 + .../ui-editor/types/UIDesignImageRole.ts | 3 + .../types/UIDesignSuggestionTreeNode.ts | 5 + .../src/features/ui-editor/types/UIRect.ts | 9 + .../src/features/ui-editor/types/UITree.ts | 5 + .../ui-editor/types/UiNodeMoveRequest.ts | 11 + .../ui-editor/types/VerticalFillOrigin.ts | 3 + .../ui-editor/types/VerticalTextOverflow.ts | 3 + .../features/ui-editor/uiDesignStateStore.ts | 81 + .../features/ui-editor/uiDesignSuggestions.ts | 52 + .../ui-editor/useUiEditorFontFaces.ts | 172 ++ .../features/ui-editor/useUiEditorState.ts | 1446 +++++++++++++ .../ui-editor/utils/componentToCss.ts | 260 +++ .../utils/layout/controlLayoutToCss.ts | 147 ++ .../ui-editor/utils/textStyleToCss.ts | 59 + .../ui-editor/utils/transform/tf2css.test.tsx | 171 ++ .../ui-editor/utils/transform/tf2css.ts | 98 + .../src/services/clientApi.ts | 102 + apps/ai-game-creator-shell/src/styles.css | 96 + .../src/view/project-development/index.tsx | 1843 +++++++++-------- .../resourceProjectionModel.ts | 3 +- .../ui-editor/components/BindingOverview.tsx | 129 ++ .../components/CollapsibleSidebarPanel.tsx | 54 + .../ui-editor/components/EditorDialogs.tsx | 100 + .../ui-editor/components/ImportOverview.tsx | 69 + .../ui-editor/components/InputSidebar.tsx | 262 +++ .../Inspector/Components/ComponentField.tsx | 95 + .../Inspector/Components/ComponentPanel.tsx | 318 +++ .../Inspector/Components/ImagePanel.tsx | 441 ++++ .../Components/ImagePanelDefaults.ts | 8 + .../Inspector/Components/TextPanel.tsx | 278 +++ .../Inspector/Components/TextPanelDefaults.ts | 15 + .../Components/componentEditorTypes.ts | 44 + .../Inspector/InspectorReadOnlyContext.ts | 7 + .../components/Inspector/InspectorSidebar.tsx | 1230 +++++++++++ .../SpriteBorder/SpriteBorderEditor.tsx | 119 ++ .../SpriteBorder/SpriteBorderFields.tsx | 139 ++ .../SpriteBorder/SpriteBorderGuideEditor.tsx | 245 +++ .../Inspector/Transform/AnchorPresetIcon.tsx | 142 ++ .../Inspector/Transform/TransformEditor.tsx | 550 +++++ .../components/PrerequisiteIssues.tsx | 27 + .../components/RecognitionOverview.tsx | 112 + .../ui-editor/components/ToolNavigation.tsx | 44 + .../components/UiNodeContextMenu.tsx | 137 ++ .../view/ui-editor/components/UiTreePanel.tsx | 260 +++ .../components/WorkflowActionCard.tsx | 83 + .../ui-editor/components/WorkflowChecks.ts | 65 + .../preview/ExclusiveChildrenTabs.tsx | 64 + .../components/preview/PreviewWorkspace.tsx | 496 +++++ .../components/preview/UiTreeRenderer.tsx | 242 +++ .../preview/components/ComponentView.tsx | 27 + .../preview/components/ImageComponentView.tsx | 45 + .../preview/components/MissingComponent.tsx | 11 + .../preview/components/TextComponentView.tsx | 64 + .../components/preview/components/types.ts | 8 + .../preview/components/useBestFitFontSize.ts | 97 + .../components/preview/exclusiveVisibility.ts | 8 + .../preview/useNodeTransformInteraction.ts | 326 +++ .../src/view/ui-editor/index.tsx | 294 +++ .../src/view/ui-editor/model.ts | 65 + .../view/ui-editor/useUiEditorNodeFocus.ts | 41 + .../src/view/ui-editor/useUiEditorPage.ts | 1230 +++++++++++ .../src/view/ui-editor/useUiTreeNodeCycle.ts | 36 + .../tests/appSurface/home.suite.ts | 60 +- .../appSurface/project-development.suite.ts | 3 +- .../tests/assetCanvasSurface.test.tsx | 3 +- .../tests/assetImporter.test.ts | 231 +++ .../tests/assetImporterSettings.test.ts | 17 + .../tests/bindingOverview.test.ts | 138 ++ .../tests/collapsibleSidebarPanel.test.tsx | 37 + .../tests/exclusiveVisibility.test.ts | 27 + .../tests/nodeTransformGeometry.test.ts | 119 ++ .../tests/proportionalResize.test.ts | 41 + .../tests/stageStatusOverview.test.ts | 98 + .../tests/themedModal.test.tsx | 108 + .../tests/uiDesignStateStore.test.ts | 73 + .../tests/uiDesignSuggestions.test.ts | 93 + .../tests/uiEditorFontFaces.test.ts | 80 + .../tests/uiEditorPage.test.ts | 589 ++++++ .../tests/uiEditorPreview.test.tsx | 360 ++++ .../tests/uiEditorState.test.ts | 606 ++++++ .../tests/uiEditorTextRendering.test.tsx | 178 ++ .../useNodeTransformInteraction.test.tsx | 187 ++ apps/ai-game-creator-shell/vite.config.ts | 47 +- .../shared-memory/decision-log.md | 37 +- docs/project-memory/shared-memory/pitfalls.md | 8 + ...】UI编辑器字体跨运行时像素一致性-2026-08-19.md | 11 + ...前端架构】UI编辑会话模块边界-2026-08-19.md | 16 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 96 +- ...方案】UI编辑器Godot容器布局模型-2026-08-18.md | 41 + ...术方案】UI编辑器子节点显示规则-2026-08-18.md | 26 + packages/image-canvas-react/src/Minimap.tsx | 14 +- .../shared/src/contracts/gameCreationApp.ts | 32 +- .../ImageCanvasGenerationComposerView.tsx | 2 +- .../ImageCanvasGenerationModel.ts | 24 +- .../useImageCanvasGenerationWorkflow.test.tsx | 48 +- .../useImageCanvasGenerationWorkflow.ts | 8 +- vite.config.ts | 46 +- vitest.config.ts | 67 +- 200 files changed, 24075 insertions(+), 1142 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/capabilities/developer.json create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/ui_design_suggestion.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/utils.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/common/error.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/common/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/image.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/text.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/children_display_mode.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/control_layout.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/dimension.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/transform.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/font.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/sprite.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/ui_design_image.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/state/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/utils.rs create mode 100644 apps/ai-game-creator-shell/src/components/AssetImporter/FontImporterPreview.tsx create mode 100644 apps/ai-game-creator-shell/src/components/AssetImporter/ImageImporterPreview.tsx create mode 100644 apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx create mode 100644 apps/ai-game-creator-shell/src/components/AssetImporter/react-file-manager.d.ts create mode 100644 apps/ai-game-creator-shell/src/components/AssetImporter/settings.ts create mode 100644 apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts create mode 100644 apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/binding.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/bindingOverview.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/components/FontSamplePreview.tsx create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/components/SpriteImagePreview.tsx create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/importAdapter.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/merge.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/nodeTransformGeometry.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/recognition.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/requisites.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/spriteBorder.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/stageStatusOverview.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/treeUtils.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/BestFitFontSize.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/BindingChange.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/BindingDTO.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/ChildrenDisplayMode.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/Component.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/Container.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/ContainerAlignment.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/ControlLayout.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/FillMethod.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/FontAsset.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/FontAssetId.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/FontAssetMetadata.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/FontFormat.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/FontSizing.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/FontSource.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/FontStyle.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/HorizontalFillOrigin.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/HorizontalTextOverflow.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/ImageComponent.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/ImageType.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/MergeDTO.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/NodeId.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/NodeMetadata.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/NodeSource.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/Radial180Origin.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/Radial360Origin.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/Radial90Origin.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/RecognitionDTO.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/SpriteAsset.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/SpriteAssetId.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/SpriteAssetMetadata.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/SpriteBorder.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/StageStatus.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/State.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/TextAlignment.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/TextComponent.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/Transform.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignImage.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignImageId.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignImageMetadata.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignImageRole.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignSuggestionTreeNode.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/UIRect.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/UITree.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/UiNodeMoveRequest.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/VerticalFillOrigin.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/VerticalTextOverflow.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/uiDesignStateStore.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/uiDesignSuggestions.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorFontFaces.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/utils/layout/controlLayoutToCss.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/utils/textStyleToCss.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/utils/transform/tf2css.test.tsx create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/utils/transform/tf2css.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/BindingOverview.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/CollapsibleSidebarPanel.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/EditorDialogs.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/ImportOverview.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentField.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentPanel.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImagePanel.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImagePanelDefaults.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanelDefaults.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/componentEditorTypes.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorReadOnlyContext.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/SpriteBorder/SpriteBorderEditor.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/SpriteBorder/SpriteBorderFields.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/SpriteBorder/SpriteBorderGuideEditor.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/AnchorPresetIcon.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/PrerequisiteIssues.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/RecognitionOverview.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/ToolNavigation.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/UiNodeContextMenu.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowActionCard.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowChecks.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/preview/ExclusiveChildrenTabs.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/ComponentView.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/ImageComponentView.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/MissingComponent.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/TextComponentView.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/types.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/preview/components/useBestFitFontSize.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/preview/exclusiveVisibility.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/index.tsx create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/model.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorNodeFocus.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts create mode 100644 apps/ai-game-creator-shell/src/view/ui-editor/useUiTreeNodeCycle.ts create mode 100644 apps/ai-game-creator-shell/tests/assetImporter.test.ts create mode 100644 apps/ai-game-creator-shell/tests/assetImporterSettings.test.ts create mode 100644 apps/ai-game-creator-shell/tests/bindingOverview.test.ts create mode 100644 apps/ai-game-creator-shell/tests/collapsibleSidebarPanel.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/exclusiveVisibility.test.ts create mode 100644 apps/ai-game-creator-shell/tests/nodeTransformGeometry.test.ts create mode 100644 apps/ai-game-creator-shell/tests/proportionalResize.test.ts create mode 100644 apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts create mode 100644 apps/ai-game-creator-shell/tests/themedModal.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/uiDesignStateStore.test.ts create mode 100644 apps/ai-game-creator-shell/tests/uiDesignSuggestions.test.ts create mode 100644 apps/ai-game-creator-shell/tests/uiEditorFontFaces.test.ts create mode 100644 apps/ai-game-creator-shell/tests/uiEditorPage.test.ts create mode 100644 apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/uiEditorState.test.ts create mode 100644 apps/ai-game-creator-shell/tests/uiEditorTextRendering.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx create mode 100644 docs/project-memory/todos/【待评估】UI编辑器字体跨运行时像素一致性-2026-08-19.md create mode 100644 docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md create mode 100644 docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md create mode 100644 docs/technical/【技术方案】UI编辑器子节点显示规则-2026-08-18.md diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 3dbd5cd07..793e2b4a4 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -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', diff --git a/.prettierignore b/.prettierignore index 4592c0b69..0f7f7cfd3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,6 +3,7 @@ node_modules .git .codex-logs public/Icons +apps/ai-game-creator-shell/src/features/ui-editor/types/ media *.log .preview.* diff --git a/apps/ai-game-creator-shell/package-lock.json b/apps/ai-game-creator-shell/package-lock.json index d0e770d7d..fe4de9a94 100644 --- a/apps/ai-game-creator-shell/package-lock.json +++ b/apps/ai-game-creator-shell/package-lock.json @@ -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", diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 69bd74590..d84a570f2 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -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", diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index c624b7aa8..80ff162fa 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -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', ); diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 687587b18..1c98bd559 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -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), ); } diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index bf5707012..3500627af 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -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" diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 6fc26eb61..1fb68b5d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -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" diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/developer.json b/apps/ai-game-creator-shell/src-tauri/capabilities/developer.json new file mode 100644 index 000000000..ca9fdae42 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/capabilities/developer.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "developer", + "description": "开发窗口允许打开本地素材选择对话框。", + "windows": ["developer"], + "permissions": ["dialog:allow-open"] +} diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json index c8255bc4c..2c0510b8c 100644 --- a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json +++ b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json @@ -19,6 +19,7 @@ { "url": "http://127.0.0.1:*/*" } ] }, - "opener:default" + "opener:default", + "dialog:allow-open" ] } diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 5e41e3cd6..9335db148 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1,4 +1,234 @@ use super::*; +use crate::ui_editor::resource::font::FontAsset; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; + +const UI_EDITOR_FONT_MAX_FILE_SIZE: u64 = 8 * 1024 * 1024; +const UI_EDITOR_FONT_MAX_TOTAL_SIZE: u64 = 32 * 1024 * 1024; +const UI_EDITOR_FONT_MAX_COUNT: usize = 64; +const UI_EDITOR_IMAGE_MAX_FILE_SIZE: u64 = 20 * 1024 * 1024; +const UI_EDITOR_IMAGE_MAX_TOTAL_SIZE: u64 = 256 * 1024 * 1024; +const UI_EDITOR_IMAGE_MAX_COUNT: usize = 100; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AssetImportRequirements { + max_items: usize, + max_file_size_bytes: Option, + max_total_size_bytes: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AssetImportPolicy { + destination: String, + accepted_media_types: Vec, + accepted_extensions: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde( + tag = "source", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub(crate) enum UiEditorAssetImportRequest { + Local { + project_path: String, + local_source_paths: Vec, + local_policy: AssetImportPolicy, + local_requirements: AssetImportRequirements, + }, + Remote { + project_path: String, + remote_assets: Vec, + remote_policy: AssetImportPolicy, + remote_requirements: AssetImportRequirements, + }, +} + +#[cfg(test)] +mod ui_editor_asset_import_request_tests { + use super::*; + + #[test] + fn local_request_accepts_tauri_camel_case_fields() { + let request = serde_json::from_value::(serde_json::json!({ + "source": "local", + "projectPath": "/project", + "localSourcePaths": ["/source/font.ttf"], + "localPolicy": { + "destination": "assets/fonts", + "acceptedMediaTypes": ["font/ttf"], + "acceptedExtensions": ["ttf"] + }, + "localRequirements": { "maxItems": 64 } + })) + .expect("deserialize local import request"); + + assert!(matches!(request, UiEditorAssetImportRequest::Local { .. })); + } + + #[tokio::test] + async fn asset_import_async_command_completes_local_file_import() { + let project = tempfile::tempdir().expect("create project directory"); + let root = project.path(); + init_local_game_project_at(root, "ui-editor-import", "UI 编辑器导入测试") + .expect("initialize project"); + let source = tempfile::NamedTempFile::new().expect("create image source"); + fs::write( + source.path(), + [ + 0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n', 0, 0, 0, 0, + ], + ) + .expect("write image source"); + + let result = import_ui_editor_assets(UiEditorAssetImportRequest::Local { + project_path: root.to_string_lossy().into_owned(), + local_source_paths: vec![source.path().to_string_lossy().into_owned()], + local_policy: AssetImportPolicy { + destination: "assets/uploads".to_string(), + accepted_media_types: vec!["image/png".to_string()], + accepted_extensions: vec!["png".to_string()], + }, + local_requirements: AssetImportRequirements { + max_items: 1, + max_file_size_bytes: Some(1024), + max_total_size_bytes: Some(1024), + }, + }) + .await + .expect("import local image through blocking worker"); + + assert_eq!(result.assets.len(), 1); + assert!(root.join(&result.assets[0].local_path).is_file()); + } +} + +fn validate_asset_import_policy(policy: &AssetImportPolicy) -> Result { + let normalized_destination = policy.destination.trim(); + let is_font = normalized_destination == "assets/fonts"; + let is_image = normalized_destination == "assets/uploads"; + if !is_font && !is_image { + return Err("不支持的资产导入目录".to_string()); + } + let allowed_media_types = if is_font { + ["font/ttf", "font/otf", "font/woff", "font/woff2"] + .into_iter() + .collect::>() + } else { + ["image/png", "image/jpeg", "image/webp"] + .into_iter() + .collect::>() + }; + if policy + .accepted_media_types + .iter() + .any(|value| !allowed_media_types.contains(value.trim().to_ascii_lowercase().as_str())) + { + return Err("导入策略包含不支持的媒体类型".to_string()); + } + let allowed_extensions = if is_font { + ["ttf", "otf", "woff", "woff2"] + .into_iter() + .collect::>() + } else { + ["png", "jpg", "jpeg", "webp"] + .into_iter() + .collect::>() + }; + if policy.accepted_extensions.iter().any(|value| { + !allowed_extensions.contains( + value + .trim() + .trim_start_matches('.') + .to_ascii_lowercase() + .as_str(), + ) + }) { + return Err("导入策略包含不支持的文件扩展名".to_string()); + } + Ok(is_font) +} + +fn validate_local_asset_import_requirements( + source_paths: &[String], + requirements: &AssetImportRequirements, +) -> Result { + if source_paths.len() > requirements.max_items { + return Err(format!("最多导入 {} 个文件", requirements.max_items)); + } + let mut total_size = 0u64; + for source in source_paths { + let metadata = + fs::symlink_metadata(source.trim()).map_err(|_| "读取本地文件失败".to_string())?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("只能导入普通文件".to_string()); + } + if let Some(max_size) = requirements.max_file_size_bytes { + if metadata.len() > max_size { + return Err(format!("文件超过 {} 字节限制", max_size)); + } + } + total_size = total_size.saturating_add(metadata.len()); + } + if let Some(max_total_size) = requirements.max_total_size_bytes { + if total_size > max_total_size { + return Err(format!("文件总量超过 {} 字节限制", max_total_size)); + } + } + Ok(total_size) +} + +fn validate_remote_asset_import_requirements( + assets: &[serde_json::Value], + requirements: &AssetImportRequirements, +) -> Result<(), String> { + if assets.len() > requirements.max_items { + return Err(format!("最多导入 {} 个文件", requirements.max_items)); + } + Ok(()) +} + +#[tauri::command] +pub(crate) async fn import_ui_editor_assets( + request: UiEditorAssetImportRequest, +) -> Result { + match request { + UiEditorAssetImportRequest::Local { + project_path, + local_source_paths, + local_policy, + local_requirements, + } => { + let is_font = validate_asset_import_policy(&local_policy)?; + validate_local_asset_import_requirements(&local_source_paths, &local_requirements)?; + tokio::task::spawn_blocking(move || { + if is_font { + import_ui_editor_local_fonts(project_path, local_source_paths) + } else { + import_ui_editor_local_files(project_path, local_source_paths) + } + }) + .await + .map_err(|error| format!("UI 编辑器素材导入任务意外终止:{error}"))? + } + UiEditorAssetImportRequest::Remote { + project_path, + remote_assets, + remote_policy, + remote_requirements, + } => { + let is_font = validate_asset_import_policy(&remote_policy)?; + if is_font { + return Err("字体导入不支持云端素材".to_string()); + } + validate_remote_asset_import_requirements(&remote_assets, &remote_requirements)?; + import_ui_editor_remote_assets(project_path, remote_assets).await + } + } +} const AUTOMATIC_PROJECTS_DIRECTORY_NAME: &str = "Genarrative GameAgent"; @@ -1370,6 +1600,109 @@ pub(crate) fn register_local_asset( ) } +#[tauri::command] +pub(crate) fn create_ui_design_resource( + project_path: String, + expected_project_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + let _lock = acquire_project_write_lock(root, "asset.register")?; + let manifest = read_existing_manifest_for_project(root)?; + if manifest.project_id != expected_project_id.trim() { + return Err("project-identity-conflict".to_string()); + } + let next_index = manifest + .assets + .iter() + .filter(|asset| asset.kind == "UI") + .count() + + 1; + let resource_name = format!("UI 设计 {next_index}"); + let relative_path = format!("ui/{resource_name}.json"); + let absolute_path = resolve_local_project_path(root, &relative_path)?; + if let Some(parent) = absolute_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建 UI 资源目录失败:{}: {error}", parent.display()))?; + } + if absolute_path.exists() { + return Err("UI 设计资源路径已存在,拒绝覆盖".to_string()); + } + fs::write(&absolute_path, "") + .map_err(|error| format!("创建 UI 资源失败:{}: {error}", absolute_path.display()))?; + let asset = match register_local_asset_at( + root, + &relative_path, + "UI", + "application/json", + "generated", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: Some(format!("ui:{next_index}")), + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) { + Ok(asset) => asset, + Err(error) => { + let rollback = (|| { + write_manifest(&root.join(".agent/manifest.json"), &manifest)?; + fs::remove_file(&absolute_path).map_err(|remove_error| { + format!( + "删除未完成 UI 设计资源失败:{}: {remove_error}", + absolute_path.display() + ) + }) + })(); + return match rollback { + Ok(()) => Err(error), + Err(rollback_error) => Err(format!( + "UI 设计资源登记失败:{error};reconciliation-required: 回滚未完成:{rollback_error}" + )), + }; + } + }; + if let Err(error) = ui_editor::persistence::initialize_ui_design_state_at( + root, + expected_project_id.trim(), + &asset.id, + ) { + let rollback = (|| { + let mut current = read_existing_manifest_for_project(root)?; + current.assets.retain(|entry| entry.id != asset.id); + write_manifest(&root.join(".agent/manifest.json"), ¤t)?; + fs::remove_file(&absolute_path).map_err(|remove_error| { + format!( + "删除未完成 UI 设计资源失败:{}: {remove_error}", + absolute_path.display() + ) + }) + })(); + return match rollback { + Ok(()) => Err(error), + Err(rollback_error) => Err(format!( + "UI 设计资源初始化失败:{error};reconciliation-required: 回滚未完成:{rollback_error}" + )), + }; + } + advance_agent_runtime_project_revision_locked(root).map_err(|error| { + format!("reconciliation-required: UI 设计资源已创建,但项目 revision 未能推进:{error}") + })?; + let manifest = read_existing_manifest_for_project(root)?; + let revision = read_game_creator_agent_runtime_project_revision(root)?.revision; + Ok(CreateUiDesignResourceResult { + asset, + manifest, + committed_project_revision: revision, + }) +} + #[tauri::command] pub(crate) async fn derive_local_project_resource( input: DeriveLocalProjectResourceInput, @@ -1495,6 +1828,688 @@ pub(crate) async fn sync_canvas_project_assets( sync_canvas_project_assets_at(root, canvas_project_id.trim(), api_base_url, api_key).await } +pub(crate) fn import_ui_editor_local_files( + project_path: String, + source_paths: Vec, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.upload")?; + let _lock = acquire_project_write_lock(root, "asset.upload")?; + if source_paths.len() > UI_EDITOR_IMAGE_MAX_COUNT { + return Err(format!("一次最多选择 {UI_EDITOR_IMAGE_MAX_COUNT} 张图片")); + } + // V1 有意采用增量导入:每个文件独立写入并登记,后续失败不回滚此前成功项。 + // 调用方必须以返回结果和 manifest 为准重建已提交集合;整批原子语义需另行定义 transaction/reconciliation 合同。 + let mut inputs = Vec::new(); + let mut total_size = 0u64; + for source in source_paths { + let path = Path::new(source.trim()); + let metadata = fs::symlink_metadata(path).map_err(|e| format!("读取本地图片失败:{e}"))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("只能导入普通图片文件".to_string()); + } + if metadata.len() > UI_EDITOR_IMAGE_MAX_FILE_SIZE { + return Err(format!( + "图片超过 {} 字节限制", + UI_EDITOR_IMAGE_MAX_FILE_SIZE + )); + } + let bytes = fs::read(path).map_err(|e| format!("读取本地图片失败:{e}"))?; + if bytes.len() as u64 > UI_EDITOR_IMAGE_MAX_FILE_SIZE { + return Err(format!( + "图片超过 {} 字节限制", + UI_EDITOR_IMAGE_MAX_FILE_SIZE + )); + } + total_size = total_size + .checked_add(bytes.len() as u64) + .filter(|size| *size <= UI_EDITOR_IMAGE_MAX_TOTAL_SIZE) + .ok_or_else(|| { + format!( + "图片批次总量超过 {} 字节限制", + UI_EDITOR_IMAGE_MAX_TOTAL_SIZE + ) + })?; + let media_type = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + "image/png" + } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + "image/jpeg" + } else if bytes.starts_with(b"RIFF") && bytes.len() > 12 && &bytes[8..12] == b"WEBP" { + "image/webp" + } else { + return Err("本地文件不是受支持的 PNG/JPEG/WEBP 图片".to_string()); + }; + let name = path + .file_name() + .and_then(|v| v.to_str()) + .unwrap_or("image") + .to_string(); + inputs.push((name, media_type.to_string(), bytes)); + } + let mut assets = Vec::with_capacity(inputs.len()); + for (name, media, bytes) in inputs { + let asset = upload_local_asset_at(root, &name, &media, &bytes)?; + // 参考 save_ui_design_state_at:只有文件和 manifest 写入成功后才推进 revision。 + // 每个成功项目独立提交,后续失败不会让已落盘项目停留在旧 revision。 + advance_agent_runtime_project_revision_locked(root).map_err(|error| { + format!("reconciliation-required: 图片已导入,但项目 revision 未能推进:{error}") + })?; + assets.push(ImportedAsset { + id: asset.id, + local_path: asset.local_path, + asset_kind: None, + }); + } + Ok(LocalImportResult { assets }) +} + +fn is_ui_editor_font_manifest_asset(asset: &GameCreationAppAssetManifestEntry) -> bool { + let media_type = asset.media_type.trim().to_ascii_lowercase(); + let extension = Path::new(&asset.local_path) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + matches!( + media_type.as_str(), + "font/ttf" | "font/otf" | "font/woff" | "font/woff2" + ) || matches!(extension.as_str(), "ttf" | "otf" | "woff" | "woff2") +} + +fn read_registered_ui_editor_font( + root: &Path, + asset: &GameCreationAppAssetManifestEntry, +) -> Result<(FontAsset, Vec), String> { + if !is_ui_editor_font_manifest_asset(asset) { + return Err("项目资产不是受支持的字体候选".to_string()); + } + let target = resolve_local_project_path(root, &asset.local_path)?; + let metadata = fs::symlink_metadata(&target).map_err(|_| "读取项目字体失败".to_string())?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("项目字体必须是普通文件".to_string()); + } + if metadata.len() > UI_EDITOR_FONT_MAX_FILE_SIZE { + return Err("项目字体超过 8 MiB 限制".to_string()); + } + let bytes = fs::read(&target).map_err(|_| "读取项目字体失败".to_string())?; + let source_file_name = target + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("font") + .to_string(); + let mut font = FontAsset::from_verified_bytes( + asset.id.clone(), + asset.local_path.clone(), + source_file_name, + &bytes, + )?; + if let Some(copied_name) = target.file_name().and_then(|value| value.to_str()) { + let hash_prefix = format!("{}-", font.content_sha256); + if let Some(original_name) = copied_name.strip_prefix(&hash_prefix) { + if !original_name.is_empty() { + font.metadata.source_file_name = original_name.to_string(); + } + } + } + Ok((font, bytes)) +} + +fn find_registered_ui_editor_font<'a>( + manifest: &'a GameCreationAppManifest, + asset_id: &str, + relative_path: &str, +) -> Result<&'a GameCreationAppAssetManifestEntry, String> { + manifest + .assets + .iter() + .find(|asset| asset.id == asset_id && asset.local_path == relative_path) + .ok_or_else(|| "字体不是当前项目已登记资产".to_string()) +} + +#[tauri::command] +pub(crate) fn prepare_ui_editor_project_fonts( + project_path: String, + assets: Vec, +) -> Result, String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + if assets.len() > UI_EDITOR_FONT_MAX_COUNT { + return Err("一次最多选择 64 个字体面".to_string()); + } + let manifest = read_existing_manifest_for_project(root)?; + let mut fonts = Vec::with_capacity(assets.len()); + for selected in assets { + let asset = find_registered_ui_editor_font(&manifest, &selected.id, &selected.local_path)?; + fonts.push(read_registered_ui_editor_font(root, asset)?.0); + } + Ok(fonts) +} + +pub(crate) fn import_ui_editor_local_fonts( + project_path: String, + source_paths: Vec, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.upload")?; + let _lock = acquire_project_write_lock(root, "asset.upload")?; + if source_paths.len() > UI_EDITOR_FONT_MAX_COUNT { + return Err("一次最多选择 64 个字体面".to_string()); + } + + let mut inputs = Vec::new(); + let mut input_hashes = std::collections::BTreeSet::new(); + for source in source_paths { + let path = Path::new(source.trim()); + let metadata = fs::symlink_metadata(path).map_err(|_| "读取本地字体失败".to_string())?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("只能导入普通字体文件".to_string()); + } + if metadata.len() > UI_EDITOR_FONT_MAX_FILE_SIZE { + return Err("字体超过 8 MiB 限制".to_string()); + } + let bytes = fs::read(path).map_err(|_| "读取本地字体失败".to_string())?; + let source_file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("font") + .to_string(); + let validated = FontAsset::from_verified_bytes( + "font-validation", + "assets/fonts/validation.ttf", + source_file_name.clone(), + &bytes, + )?; + if input_hashes.insert(validated.content_sha256.clone()) { + inputs.push((source_file_name, bytes, validated)); + } + } + + let manifest = read_existing_manifest_for_project(root)?; + let mut existing_by_hash = std::collections::BTreeMap::new(); + let mut existing_total_size = 0u64; + let mut existing_count = 0usize; + for asset in manifest + .assets + .iter() + .filter(|asset| is_ui_editor_font_manifest_asset(asset)) + { + let Ok((font, bytes)) = read_registered_ui_editor_font(root, asset) else { + continue; + }; + existing_total_size = existing_total_size.saturating_add(bytes.len() as u64); + existing_count += 1; + existing_by_hash + .entry(font.content_sha256.clone()) + .or_insert(font); + } + let new_inputs = inputs + .iter() + .filter(|(_, _, font)| !existing_by_hash.contains_key(&font.content_sha256)) + .collect::>(); + let new_total_size = new_inputs.iter().fold(0u64, |total, (_, bytes, _)| { + total.saturating_add(bytes.len() as u64) + }); + if existing_count.saturating_add(new_inputs.len()) > UI_EDITOR_FONT_MAX_COUNT { + return Err("项目字体面最多 64 个".to_string()); + } + if existing_total_size.saturating_add(new_total_size) > UI_EDITOR_FONT_MAX_TOTAL_SIZE { + return Err("项目字体总量超过 32 MiB 限制".to_string()); + } + + if !new_inputs.is_empty() { + let font_root = root.join("assets/fonts"); + fs::create_dir_all(&font_root).map_err(|_| "创建项目字体目录失败".to_string())?; + } + // 字体批次同样是增量提交合同:已经复制并登记的字体在后续失败时保留。 + let mut result = Vec::with_capacity(inputs.len()); + for (source_file_name, bytes, validated) in inputs { + if let Some(existing) = existing_by_hash.get(&validated.content_sha256) { + result.push(existing.clone()); + continue; + } + let safe_stem = sanitize_file_name( + Path::new(&source_file_name) + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("font"), + ); + let relative_path = format!( + "assets/fonts/{}-{safe_stem}.{}", + validated.content_sha256, + validated.metadata.format.extension() + ); + let target = resolve_local_project_path(root, &relative_path)?; + fs::write(&target, &bytes).map_err(|_| "写入项目字体失败".to_string())?; + let registered = register_local_asset_entry( + root, + &relative_path, + "font", + validated.metadata.format.media_type(), + "font", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + )?; + let font = FontAsset::from_verified_bytes( + registered.id, + registered.local_path, + source_file_name, + &bytes, + )?; + existing_by_hash.insert(font.content_sha256.clone(), font.clone()); + result.push(font); + // 参考 save_ui_design_state_at:登记成功后才推进 revision;失败项不会伪造提交版本。 + advance_agent_runtime_project_revision_locked(root).map_err(|error| { + format!("reconciliation-required: 字体已复制并登记,但项目 revision 未能推进:{error}") + })?; + } + Ok(LocalImportResult { + assets: result + .into_iter() + .map(|font| ImportedAsset { + id: font.asset_id.as_str().to_string(), + local_path: font.path, + asset_kind: Some("font".to_string()), + }) + .collect(), + }) +} + +fn read_checked_ui_editor_font( + project_path: &str, + asset_id: &str, + relative_path: &str, + expected_sha256: &str, +) -> Result<(FontAsset, Vec), String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "file.read")?; + let manifest = read_existing_manifest_for_project(root)?; + let asset = find_registered_ui_editor_font(&manifest, asset_id, relative_path)?; + let (font, bytes) = read_registered_ui_editor_font(root, asset)?; + if font.content_sha256 != expected_sha256 { + return Err("字体文件内容与资源摘要不一致".to_string()); + } + Ok((font, bytes)) +} + +#[tauri::command] +pub(crate) fn read_ui_editor_font_bytes( + project_path: String, + asset_id: String, + relative_path: String, + expected_sha256: String, +) -> Result { + read_checked_ui_editor_font( + &project_path, + asset_id.trim(), + relative_path.trim(), + expected_sha256.trim(), + ) + .map(|(_, bytes)| tauri::ipc::Response::new(bytes)) +} + +#[tauri::command] +pub(crate) fn check_ui_editor_font_glyph_coverage( + project_path: String, + asset_id: String, + relative_path: String, + expected_sha256: String, + content: String, +) -> Result { + let (font, bytes) = read_checked_ui_editor_font( + &project_path, + asset_id.trim(), + relative_path.trim(), + expected_sha256.trim(), + )?; + font.has_missing_glyphs(&bytes, &content) +} + +#[cfg(test)] +mod ui_editor_font_tests { + use super::*; + + fn fixture_font_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../public/fusion-pixel.ttf") + } + + #[test] + fn registered_font_reader_requires_manifest_identity_and_verified_bytes() { + let project = tempfile::tempdir().expect("project tempdir"); + let root = project.path(); + init_local_game_project_at(root, "font-project", "Font Project") + .expect("initialize project"); + let relative_path = "assets/fonts/fusion-pixel.ttf"; + let target = root.join(relative_path); + fs::create_dir_all(target.parent().expect("font parent")).expect("create font parent"); + fs::copy(fixture_font_path(), &target).expect("copy font fixture"); + let registered = register_local_asset_entry( + root, + relative_path, + "font", + "font/ttf", + "font", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) + .expect("register font"); + let manifest = read_existing_manifest_for_project(root).expect("read manifest"); + let entry = + find_registered_ui_editor_font(&manifest, ®istered.id, ®istered.local_path) + .expect("find registered font"); + let (font, bytes) = read_registered_ui_editor_font(root, entry).expect("read font"); + + assert_eq!(font.asset_id.as_str(), registered.id); + assert_eq!(font.path, relative_path); + assert_eq!(font.content_sha256.len(), 64); + assert!(!bytes.is_empty()); + assert!(find_registered_ui_editor_font(&manifest, "missing", relative_path).is_err()); + } + + #[test] + fn local_font_import_copies_registers_and_reuses_identical_content() { + let project = tempfile::tempdir().expect("project tempdir"); + let root = project.path(); + init_local_game_project_at(root, "font-project", "Font Project") + .expect("initialize project"); + let source_path = fixture_font_path().to_string_lossy().into_owned(); + let revision_before = read_game_creator_agent_runtime_project_revision(root) + .expect("read initial revision") + .revision; + + let first = import_ui_editor_local_fonts( + root.to_string_lossy().into_owned(), + vec![source_path.clone()], + ) + .expect("import font"); + assert_eq!(first.assets.len(), 1); + let imported = &first.assets[0]; + assert!(imported.local_path.starts_with("assets/fonts/")); + assert!(imported.local_path.ends_with("-fusion-pixel.ttf")); + assert!(root.join(&imported.local_path).is_file()); + let manifest = read_existing_manifest_for_project(root).expect("read manifest"); + assert_eq!( + manifest + .assets + .iter() + .filter(|asset| is_ui_editor_font_manifest_asset(asset)) + .count(), + 1 + ); + assert!(manifest + .assets + .iter() + .any(|asset| { asset.id == imported.id && asset.local_path == imported.local_path })); + let revision_after_first = read_game_creator_agent_runtime_project_revision(root) + .expect("read imported revision") + .revision; + assert_eq!(revision_after_first, revision_before + 1); + + let second = + import_ui_editor_local_fonts(root.to_string_lossy().into_owned(), vec![source_path]) + .expect("reimport identical font"); + assert_eq!(second, first); + let manifest = read_existing_manifest_for_project(root).expect("read reused manifest"); + assert_eq!( + manifest + .assets + .iter() + .filter(|asset| is_ui_editor_font_manifest_asset(asset)) + .count(), + 1 + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(root) + .expect("read reused revision") + .revision, + revision_after_first + ); + } + + #[cfg(unix)] + #[test] + fn registered_font_reader_rejects_symlink() { + use std::os::unix::fs::symlink; + + let project = tempfile::tempdir().expect("project tempdir"); + let root = project.path(); + let relative_path = "assets/fonts/linked.ttf"; + let target = root.join(relative_path); + fs::create_dir_all(target.parent().expect("font parent")).expect("create font parent"); + symlink(fixture_font_path(), &target).expect("create font symlink"); + let entry = GameCreationAppAssetManifestEntry { + id: "font-linked".to_string(), + kind: "font".to_string(), + media_type: "font/ttf".to_string(), + local_path: relative_path.to_string(), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + }; + + assert_eq!( + read_registered_ui_editor_font(root, &entry).expect_err("reject symlink"), + "项目文件路径不能包含符号链接" + ); + } +} + +#[cfg(test)] +mod remote_asset_path_tests { + use super::*; + + #[test] + fn remote_asset_paths_distinguish_ids_that_sanitize_to_the_same_name() { + let slash_path = remote_asset_local_path("a/b", "png"); + let colon_path = remote_asset_local_path("a:b", "png"); + + assert_ne!(slash_path, colon_path); + assert_eq!(slash_path, remote_asset_local_path("a/b", "png")); + assert!(slash_path.starts_with("assets/uploads/remote-a_b-")); + assert!(slash_path.ends_with(".png")); + } + + #[test] + fn duplicate_remote_asset_destination_is_rejected() { + let mut destinations = HashSet::new(); + let local_path = remote_asset_local_path("stable-id", "webp"); + + reserve_remote_asset_destination(&mut destinations, &local_path) + .expect("reserve first destination"); + assert_eq!( + reserve_remote_asset_destination(&mut destinations, &local_path) + .expect_err("reject duplicate destination"), + format!("平台素材目标路径重复:{local_path}") + ); + } +} + +fn remote_asset_local_path(asset_id: &str, extension: &str) -> String { + let readable_id = sanitize_file_name(asset_id); + let identity_digest = format!("{:x}", Sha256::digest(asset_id.as_bytes())); + let short_digest = &identity_digest[..12]; + format!("assets/uploads/remote-{readable_id}-{short_digest}.{extension}") +} + +fn reserve_remote_asset_destination( + destinations: &mut HashSet, + local_path: &str, +) -> Result<(), String> { + if destinations.insert(local_path.to_string()) { + Ok(()) + } else { + Err(format!("平台素材目标路径重复:{local_path}")) + } +} + +pub(crate) async fn import_ui_editor_remote_assets( + project_path: String, + assets: Vec, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "canvas.asset_import")?; + if assets.len() > UI_EDITOR_IMAGE_MAX_COUNT { + return Err(format!("一次最多选择 {UI_EDITOR_IMAGE_MAX_COUNT} 张图片")); + } + // IPC 只传稳定引用/签名 URL,由 Rust 流式下载并限制响应体;下载期间不占有项目写锁。 + let mut total_size = 0u64; + let mut downloads = Vec::with_capacity(assets.len()); + let mut destinations = HashSet::with_capacity(assets.len()); + for asset in assets { + let asset_id = json_string_field(&asset, "assetId") + .or_else(|| json_string_field(&asset, "objectKey")) + .ok_or_else(|| "平台素材缺少稳定 assetId/objectKey,拒绝导入".to_string())?; + let download_url = json_string_field(&asset, "downloadUrl") + .ok_or_else(|| format!("平台素材 {asset_id} 缺少 downloadUrl"))?; + let remaining = UI_EDITOR_IMAGE_MAX_TOTAL_SIZE.saturating_sub(total_size); + let bytes = download_ui_editor_remote_asset(&download_url, remaining).await?; + total_size = total_size + .checked_add(bytes.len() as u64) + .filter(|size| *size <= UI_EDITOR_IMAGE_MAX_TOTAL_SIZE) + .ok_or_else(|| { + format!( + "图片批次总量超过 {} 字节限制", + UI_EDITOR_IMAGE_MAX_TOTAL_SIZE + ) + })?; + let media_type = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + "image/png" + } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + "image/jpeg" + } else if bytes.starts_with(b"RIFF") && bytes.len() > 12 && &bytes[8..12] == b"WEBP" { + "image/webp" + } else { + return Err("平台素材不是受支持的 PNG/JPEG/WEBP 图片".to_string()); + }; + let extension = infer_file_extension( + json_string_field(&asset, "objectKey") + .or_else(|| json_string_field(&asset, "imageSrc")) + .as_deref(), + media_type, + ); + let local_path = remote_asset_local_path(&asset_id, extension); + reserve_remote_asset_destination(&mut destinations, &local_path)?; + downloads.push((asset, asset_id, media_type.to_string(), local_path, bytes)); + } + + let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; + // 远程素材导入保持与本地图片/字体相同的增量语义,不对已成功项目文件做整批回滚。 + let mut imported = Vec::with_capacity(downloads.len()); + for (asset, asset_id, media_type, local_path, bytes) in downloads { + let target = resolve_local_project_path(root, &local_path)?; + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|e| format!("创建导入目录失败:{e}"))?; + } + fs::write(&target, &bytes).map_err(|e| format!("写入平台素材失败:{e}"))?; + let registered = register_local_asset_entry( + root, + &local_path, + "ui", + &media_type, + "canvas", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: None, + resource_id: json_string_field(&asset, "assetId"), + asset_object_id: json_string_field(&asset, "assetObjectId"), + task_id: None, + prompt: None, + model: None, + generation_route: Some("editor.asset-library.import".to_string()), + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + )?; + imported.push(ImportedAsset { + id: registered.id, + local_path: registered.local_path, + asset_kind: json_string_field(&asset, "assetKind"), + }); + // 参考 save_ui_design_state_at:远程文件与 manifest 成功后才推进 revision。 + advance_agent_runtime_project_revision_locked(root).map_err(|error| { + format!("reconciliation-required: 远程素材已导入,但项目 revision 未能推进:{error}") + })?; + } + Ok(RemoteImportResult { assets: imported }) +} + +async fn download_ui_editor_remote_asset(url: &str, max_bytes: u64) -> Result, String> { + if max_bytes == 0 { + return Err("图片批次总量已达到服务端限制".to_string()); + } + let parsed = validate_external_asset_download_url(url, "", false)?; + let client = build_external_asset_download_client(&parsed, "", false).await?; + let mut response = client + .get(parsed) + .send() + .await + .map_err(|error| format!("下载平台素材失败:{error}"))?; + if response.status().is_redirection() { + return Err("平台素材下载地址发生重定向,已拒绝继续请求".to_string()); + } + if !response.status().is_success() { + return Err(format!( + "下载平台素材失败:HTTP {}", + response.status().as_u16() + )); + } + if response + .content_length() + .is_some_and(|size| size > UI_EDITOR_IMAGE_MAX_FILE_SIZE || size > max_bytes) + { + return Err(format!( + "平台素材超过单文件 {} 或批次剩余 {} 字节限制", + UI_EDITOR_IMAGE_MAX_FILE_SIZE, max_bytes + )); + } + let capacity = response + .content_length() + .and_then(|size| usize::try_from(size).ok()) + .unwrap_or_default() + .min(UI_EDITOR_IMAGE_MAX_FILE_SIZE as usize); + let mut bytes = Vec::with_capacity(capacity); + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| format!("读取平台素材失败:{error}"))? + { + let next_size = bytes.len().saturating_add(chunk.len()); + if next_size as u64 > UI_EDITOR_IMAGE_MAX_FILE_SIZE || next_size as u64 > max_bytes { + return Err("平台素材超过服务端下载限制".to_string()); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + #[tauri::command] pub(crate) async fn generate_platform_art_asset( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 651654b6f..c89b86136 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -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, 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::recognize_ui_impl(project_path, state).await +} + +#[tauri::command] +async fn merge_ui(state: ui_editor::state::State) -> Result { + 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, +) -> Result { + 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 { + 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 { + 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, } +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalImportResult { + assets: Vec, +} + +type RemoteImportResult = LocalImportResult; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ImportedAsset { + id: String, + local_path: String, + asset_kind: Option, +} + #[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; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs new file mode 100644 index 000000000..92ecb2157 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs @@ -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, + components_status: DraftStatus, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +struct BindingResponse { + changes: Vec, +} + +#[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, + 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, +} + +#[derive(Serialize)] +struct EditableNodeContext<'a> { + node_id: &'a NodeId, + name: &'a str, + description: &'a str, + components: &'a [Component], +} + +fn binding_json_schema() -> Result { + strict_json_schema::() +} + +fn collect_editable_nodes<'a>(node: &'a Node, output: &mut Vec>) { + 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 { + 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, + editable_ids: &HashSet, + known_sprite_ids: &HashSet, +) -> Result { + 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, +) -> Result { + 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::, _>>() + .map_err(|error| format!("独立素材 ID 无效:{error}"))?; + let batch_sprite_ids = sprite_ids.iter().cloned().collect::>(); + 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::>(); + 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::>(); + 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::>() + }] + }); + 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"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs new file mode 100644 index 000000000..53739558d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs @@ -0,0 +1,630 @@ +use crate::config::build_game_creator_llm_client_from_config; +use crate::ui_editor::commands::utils::{parse_limited_llm_tool_arguments, strict_json_schema}; +use crate::ui_editor::state::{State, UITree}; +use platform_llm::{LlmFunctionTool, LlmMessage, LlmRunRequest, LlmToolChoice}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +const MERGE_TOOL_NAME: &str = "merge_ui_trees"; +const MAX_MERGE_INPUT_NODES: usize = 512; +const MAX_MERGE_INPUT_DEPTH: usize = 32; +const MAX_MERGE_INPUT_BYTES: usize = 2 * 1024 * 1024; +const MAX_MERGE_PLAN_NODES: usize = 512; +const MAX_MERGE_PLAN_DEPTH: usize = 32; + +const SYSTEM_PROMPT: &str = r#" +角色: +你是游戏 UI 多树结构合并器。 + +任务: +用户会提供同一个 UI 系统中多张参考图各自的 UI 树。请返回一棵新的合并计划树。 + +规则: +* Simple 引用一个原始节点 id,并用返回的 children 定义它在新树中的子节点。 +* Merged 表示多个节点描述同一个共同组件(它们可能是同一组件的不同状态),merged_from 中按语义排列需要放入容器的节点。 +* 可以在任意层级使用 Merged,不限于各输入树的根节点。 +* 一些共用的框架/层次/...在树中只保留一个, 优先保留优先级高的树中的节点 +* 面向用户的节点名称和结构判断使用中文语义理解。 +"#; + +mod llm_contract { + use super::strict_json_schema; + use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata}; + use crate::ui_editor::state::State; + use crate::ui_editor::utils::NodeId; + use schemars::JsonSchema; + use serde::{Deserialize, Serialize}; + + #[derive(Clone, Debug, Serialize)] + pub(super) struct OriginalNode { + id: NodeId, + metadata: NodeMetadata, + children: Vec, + } + + #[derive(Clone, Debug, Serialize)] + pub(super) struct OriginalTree { + priority: u32, + root: OriginalNode, + } + + #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] + #[serde(deny_unknown_fields)] + #[schemars(deny_unknown_fields)] + pub(super) struct MergedNode { + pub(super) name: String, + pub(super) description: String, + pub(super) merged_from: Vec, + } + + #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] + #[serde(deny_unknown_fields)] + #[schemars(deny_unknown_fields)] + pub(super) struct SimpleNode { + pub(super) id: NodeId, + pub(super) children: Vec, + } + + #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] + pub(super) enum Node { + Simple(SimpleNode), + Merged(MergedNode), + } + + #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] + #[serde(deny_unknown_fields)] + #[schemars(deny_unknown_fields)] + pub(super) struct MergeResponse { + pub(super) root: Node, + } + + pub(super) fn schema() -> Result { + strict_json_schema::() + } + + fn project_node(node: &LayoutNode) -> OriginalNode { + OriginalNode { + id: node.id.clone(), + metadata: node.metadata.clone(), + children: node.children.iter().map(project_node).collect(), + } + } + + pub(super) fn input_trees( + state: &State, + priorities: &[u32], + ) -> Result, String> { + if state.ui_trees.len() != priorities.len() { + return Err("UI 树优先级数量不匹配".to_string()); + } + Ok(state + .ui_trees + .iter() + .zip(priorities) + .map(|(tree, priority)| OriginalTree { + priority: *priority, + root: project_node(&tree.root), + }) + .collect()) + } +} + +mod priority { + use crate::ui_editor::resource::ui_design_image::UIDesignImage; + use crate::ui_editor::state::State; + use crate::ui_editor::utils::UIDesignImageId; + use std::collections::{HashMap, HashSet}; + + fn ancestor_chain( + image_id: &UIDesignImageId, + images: &HashMap, + ) -> Result, String> { + let mut chain = Vec::new(); + let mut seen = HashSet::new(); + let mut current = image_id.clone(); + loop { + if !seen.insert(current.clone()) { + return Err(format!( + "界面图 slave_to 关系存在循环:{}", + current.as_str() + )); + } + chain.push(current.clone()); + let image = images + .get(¤t) + .ok_or_else(|| format!("界面图 slave_to 引用了缺失界面图:{}", current.as_str()))?; + match &image.metadata.slave_to { + Some(parent) => current = parent.clone(), + None => return Ok(chain), + } + } + } + + pub(super) fn for_state(state: &State) -> Result, String> { + let source_ids = state + .ui_trees + .iter() + .map(|tree| tree.src_ui_design.clone()) + .collect::>(); + let chains = source_ids + .iter() + .map(|source_id| ancestor_chain(source_id, &state.ui_design_images)) + .collect::, _>>()?; + Ok(source_ids + .iter() + .map(|candidate| { + chains + .iter() + .filter(|chain| chain.contains(candidate)) + .count() as u32 + }) + .collect()) + } +} + +mod materialize { + use super::llm_contract::Node; + use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode; + use crate::ui_editor::layout::node::{ + Node as LayoutNode, NodeMetadata, NodeSource, StageStatus, + }; + use crate::ui_editor::layout::transform::Transform; + use crate::ui_editor::state::{State, UITree}; + use crate::ui_editor::utils::{NodeId, UIDesignImageId}; + use std::collections::{HashMap, HashSet}; + + #[derive(Clone)] + struct OriginalNodeRecord { + node: LayoutNode, + priority: u32, + src_ui_design: UIDesignImageId, + } + + struct BuiltNode { + node: LayoutNode, + priority: u32, + src_ui_design: UIDesignImageId, + } + + fn collect_original_nodes( + node: &LayoutNode, + priority: u32, + src_ui_design: &UIDesignImageId, + records: &mut HashMap, + ) -> Result<(), String> { + if records + .insert( + node.id.clone(), + OriginalNodeRecord { + node: node.clone(), + priority, + src_ui_design: src_ui_design.clone(), + }, + ) + .is_some() + { + return Err(format!("输入 UI 树包含重复节点 ID:{}", node.id.as_str())); + } + for child in &node.children { + collect_original_nodes(child, priority, src_ui_design, records)?; + } + Ok(()) + } + + fn highest_priority_member_index(members: &[BuiltNode]) -> Result { + if members.is_empty() { + return Err("MergedNode.merged_from 不能为空".to_string()); + } + let mut best_index = 0; + let mut best_priority = 0; + for (candidate_index, candidate) in members.iter().enumerate() { + if candidate.priority > best_priority { + best_index = candidate_index; + best_priority = candidate.priority; + } + } + Ok(best_index) + } + + fn random_node_id(occupied: &mut HashSet) -> Result { + loop { + let id = NodeId::new(uuid::Uuid::new_v4().simple().to_string()) + .map_err(|error| format!("生成合并容器节点 ID 失败:{error}"))?; + if occupied.insert(id.clone()) { + return Ok(id); + } + } + } + + fn build_node( + plan: Node, + records: &HashMap, + used_original_ids: &mut HashSet, + occupied_ids: &mut HashSet, + ) -> Result { + match plan { + Node::Simple(simple) => { + if !used_original_ids.insert(simple.id.clone()) { + return Err(format!("合并计划重复引用节点 ID:{}", simple.id.as_str())); + } + let original = records + .get(&simple.id) + .ok_or_else(|| format!("合并计划引用了未知节点 ID:{}", simple.id.as_str()))?; + let mut node = original.node.clone(); + node.children = simple + .children + .into_iter() + .map(|child| { + build_node(child, records, used_original_ids, occupied_ids) + .map(|built| built.node) + }) + .collect::, _>>()?; + Ok(BuiltNode { + node, + priority: original.priority, + src_ui_design: original.src_ui_design.clone(), + }) + } + Node::Merged(merged) => { + let container_name = merged.name; + let container_description = merged.description; + let mut members = merged + .merged_from + .into_iter() + .map(|member| build_node(member, records, used_original_ids, occupied_ids)) + .collect::, _>>()?; + let highest_priority_index = highest_priority_member_index(&members)?; + let original_transform = members[highest_priority_index].node.layout.transform; + let priority = members[highest_priority_index].priority; + let src_ui_design = members[highest_priority_index].src_ui_design.clone(); + for member in &mut members { + member.node.layout.transform = Transform::stretch(); + } + Ok(BuiltNode { + node: LayoutNode { + id: random_node_id(occupied_ids)?, + layout: + crate::ui_editor::layout::control_layout::ControlLayout::with_transform( + original_transform, + ), + metadata: NodeMetadata { + name: container_name, + description: container_description, + layout_status: StageStatus::NoProblem, + components_status: StageStatus::NoProblem, + allow_llm_edit_layout: true, + allow_llm_edit_component: true, + source: NodeSource::Llm, + }, + components: Vec::new(), + children_display_mode: ChildrenDisplayMode::Exclusive, + children: members.into_iter().map(|member| member.node).collect(), + }, + priority, + src_ui_design, + }) + } + } + } + + pub(super) fn plan(plan: Node, state: &State, priorities: &[u32]) -> Result { + if state.ui_trees.len() != priorities.len() { + return Err("UI 树优先级数量不匹配".to_string()); + } + let mut records = HashMap::new(); + for (tree, priority) in state.ui_trees.iter().zip(priorities) { + collect_original_nodes(&tree.root, *priority, &tree.src_ui_design, &mut records)?; + } + let mut occupied_ids = records.keys().cloned().collect::>(); + // 合并计划是语义投影,不要求覆盖全部源节点;未引用节点表示本次合并明确丢弃。 + // 这里只拒绝重复/未知引用,避免把“公共结构合并”误收紧为全量复制。 + let mut used_original_ids = HashSet::new(); + let built = build_node(plan, &records, &mut used_original_ids, &mut occupied_ids)?; + Ok(UITree { + src_ui_design: built.src_ui_design, + root: built.node, + }) + } +} + +fn validate_merge_input_state(state: &State) -> Result<(), String> { + for tree in &state.ui_trees { + let mut stack = vec![(&tree.root, 1usize)]; + let mut node_count = 0usize; + while let Some((node, depth)) = stack.pop() { + if depth > MAX_MERGE_INPUT_DEPTH { + return Err(format!( + "单棵待合并 UI 树最大深度不能超过 {MAX_MERGE_INPUT_DEPTH}" + )); + } + node_count += 1; + if node_count > MAX_MERGE_INPUT_NODES { + return Err(format!( + "单棵待合并 UI 树最多包含 {MAX_MERGE_INPUT_NODES} 个节点" + )); + } + stack.extend(node.children.iter().map(|child| (child, depth + 1))); + } + } + Ok(()) +} + +fn validate_merge_plan_shape(value: &serde_json::Value) -> Result<(), String> { + let root = value + .get("root") + .ok_or_else(|| "UI 合并工具参数缺少 root".to_string())?; + let mut stack = vec![(root, 1usize)]; + let mut node_count = 0usize; + while let Some((node, depth)) = stack.pop() { + if depth > MAX_MERGE_PLAN_DEPTH { + return Err(format!( + "UI 合并计划最大深度不能超过 {MAX_MERGE_PLAN_DEPTH}" + )); + } + node_count += 1; + if node_count > MAX_MERGE_PLAN_NODES { + return Err(format!("UI 合并计划最多包含 {MAX_MERGE_PLAN_NODES} 个节点")); + } + let object = node + .as_object() + .filter(|object| object.len() == 1) + .ok_or_else(|| "UI 合并计划节点结构无效".to_string())?; + let children = if let Some(simple) = object.get("Simple") { + simple + .get("children") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "Simple 合并计划节点缺少 children 数组".to_string())? + } else if let Some(merged) = object.get("Merged") { + merged + .get("merged_from") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "Merged 合并计划节点缺少 merged_from 数组".to_string())? + } else { + return Err("UI 合并计划节点类型无效".to_string()); + }; + stack.extend(children.iter().map(|child| (child, depth + 1))); + } + Ok(()) +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct MergeDTO { + pub ui_tree: UITree, +} + +pub(crate) async fn merge_ui_impl(state: State) -> Result { + if state.ui_trees.is_empty() { + eprintln!("ui_merge.error stage=validate reason=no_trees"); + return Err("请先完成 UI 结构识别".to_string()); + } + validate_merge_input_state(&state).map_err(|error| { + eprintln!("ui_merge.error stage=validate_input error={error}"); + error + })?; + let priorities = priority::for_state(&state).map_err(|error| { + eprintln!("ui_merge.error stage=build_priority error={error}"); + error + })?; + let trees = llm_contract::input_trees(&state, &priorities).map_err(|error| { + eprintln!("ui_merge.error stage=build_input error={error}"); + error + })?; + let records_json = serde_json::to_string(&trees).map_err(|error| { + eprintln!("ui_merge.error stage=serialize_input error={error}"); + format!("序列化 UI 合并输入失败:{error}") + })?; + if records_json.len() > MAX_MERGE_INPUT_BYTES { + eprintln!( + "ui_merge.error stage=serialize_input reason=too_large bytes={} limit={MAX_MERGE_INPUT_BYTES}", + records_json.len() + ); + return Err(format!("UI 合并输入超过 {MAX_MERGE_INPUT_BYTES} 字节上限")); + } + let client = build_game_creator_llm_client_from_config().map_err(|error| { + eprintln!("ui_merge.error stage=build_client error={error}"); + error + })?; + let schema = llm_contract::schema().map_err(|error| { + eprintln!("ui_merge.error stage=build_schema error={error}"); + error + })?; + let tool = LlmFunctionTool::new( + MERGE_TOOL_NAME, + "把多张参考图各自的 UI 树合并为一棵新的 UI 计划树", + schema, + ) + .with_strict(true); + let response = client + .run( + LlmRunRequest::new(vec![ + LlmMessage::system(SYSTEM_PROMPT), + LlmMessage::user(format!("待合并 UI 树:\n{records_json}")), + ]) + .with_function_tools(vec![tool]) + .with_tool_choice(LlmToolChoice::Required), + ) + .await + .map_err(|error| { + eprintln!("ui_merge.error stage=llm_request error={error}"); + format!("UI 树合并失败:{error}") + })?; + let call = response + .tool_calls + .iter() + .find(|call| call.name == MERGE_TOOL_NAME) + .ok_or_else(|| { + eprintln!("ui_merge.error stage=parse_tool_call reason=missing_tool_call"); + format!("LLM 未返回 {MERGE_TOOL_NAME} 工具调用") + })?; + let arguments = parse_limited_llm_tool_arguments(&call.arguments).map_err(|error| { + eprintln!("ui_merge.error stage=parse_arguments error={error}"); + format!("UI 合并工具参数无效:{error}") + })?; + validate_merge_plan_shape(&arguments).map_err(|error| { + eprintln!("ui_merge.error stage=validate_arguments error={error}"); + format!("UI 合并工具参数无效:{error}") + })?; + let parsed = + serde_json::from_value::(arguments).map_err(|error| { + eprintln!("ui_merge.error stage=parse_arguments error={error}"); + format!("UI 合并工具参数无效:{error}") + })?; + let ui_tree = materialize::plan(parsed.root, &state, &priorities).map_err(|error| { + eprintln!("ui_merge.error stage=materialize error={error}"); + error + })?; + Ok(MergeDTO { ui_tree }) +} + +#[cfg(test)] +mod tests { + use super::llm_contract::{MergedNode, Node as PlanNode, SimpleNode}; + use super::{ + materialize, validate_merge_input_state, validate_merge_plan_shape, MAX_MERGE_INPUT_DEPTH, + MAX_MERGE_INPUT_NODES, MAX_MERGE_PLAN_DEPTH, MAX_MERGE_PLAN_NODES, + }; + use crate::ui_editor::component::Component; + use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode; + use crate::ui_editor::layout::control_layout::ControlLayout; + use crate::ui_editor::layout::node::{Node, NodeMetadata, NodeSource, StageStatus}; + use crate::ui_editor::layout::transform::Transform; + use crate::ui_editor::state::{State, UITree}; + use crate::ui_editor::utils::{NodeId, UIDesignImageId}; + use std::collections::HashMap; + + fn node(id: &str, children: Vec) -> Node { + Node { + id: NodeId::new(id).expect("valid node id"), + layout: ControlLayout::with_transform(Transform::stretch()), + metadata: NodeMetadata { + name: id.to_string(), + description: String::new(), + layout_status: StageStatus::NoProblem, + components_status: StageStatus::NoProblem, + allow_llm_edit_layout: true, + allow_llm_edit_component: true, + source: NodeSource::Human, + }, + components: Vec::::new(), + children_display_mode: ChildrenDisplayMode::Stack, + children, + } + } + + #[test] + fn merged_materialization_sets_exclusive_children_display_mode() { + let page_id = UIDesignImageId::new("page").expect("valid page id"); + let state = State { + ui_trees: vec![UITree { + src_ui_design: page_id.clone(), + root: node("root", vec![node("a", vec![]), node("b", vec![])]), + }], + ui_design_images: HashMap::new(), + sprite_assets: HashMap::new(), + font_assets: HashMap::new(), + }; + let plan = PlanNode::Merged(MergedNode { + name: "状态容器".to_string(), + description: String::new(), + merged_from: vec![ + PlanNode::Simple(SimpleNode { + id: NodeId::new("a").expect("valid node id"), + children: vec![], + }), + PlanNode::Simple(SimpleNode { + id: NodeId::new("b").expect("valid node id"), + children: vec![], + }), + ], + }); + + let result = materialize::plan(plan, &state, &[1]).expect("materialize plan"); + assert_eq!( + result.root.children_display_mode, + ChildrenDisplayMode::Exclusive + ); + assert_eq!( + result.root.metadata.components_status, + StageStatus::NoProblem + ); + assert_eq!(result.root.children.len(), 2); + } + + #[test] + fn merge_plan_rejects_node_and_depth_overflow() { + let leaf = || serde_json::json!({"Simple": {"id": "leaf", "children": []}}); + let oversized = (0..MAX_MERGE_PLAN_NODES) + .map(|_| leaf()) + .collect::>(); + assert!(validate_merge_plan_shape(&serde_json::json!({ + "root": {"Simple": {"id": "root", "children": oversized}} + })) + .is_err()); + + let mut nested = leaf(); + for _ in 0..MAX_MERGE_PLAN_DEPTH { + nested = serde_json::json!({ + "Merged": {"name": "层", "description": "", "merged_from": [nested]} + }); + } + assert!(validate_merge_plan_shape(&serde_json::json!({"root": nested})).is_err()); + } + + #[test] + fn merge_input_limits_apply_per_tree_without_summing_trees() { + let tree = |page: &str, prefix: &str| UITree { + src_ui_design: UIDesignImageId::new(page).expect("valid page id"), + root: node( + &format!("{prefix}-root"), + (1..MAX_MERGE_INPUT_NODES) + .map(|index| node(&format!("{prefix}-{index}"), vec![])) + .collect(), + ), + }; + let state = State { + ui_trees: vec![tree("page-a", "a"), tree("page-b", "b")], + ui_design_images: HashMap::new(), + sprite_assets: HashMap::new(), + font_assets: HashMap::new(), + }; + validate_merge_input_state(&state) + .expect("each source tree independently fits the node limit"); + } + + #[test] + fn merge_input_rejects_node_and_depth_overflow() { + let page_id = UIDesignImageId::new("page").expect("valid page id"); + let wide_root = node( + "root", + (0..MAX_MERGE_INPUT_NODES) + .map(|index| node(&format!("child-{index}"), vec![])) + .collect(), + ); + let wide_state = State { + ui_trees: vec![UITree { + src_ui_design: page_id.clone(), + root: wide_root, + }], + ui_design_images: HashMap::new(), + sprite_assets: HashMap::new(), + font_assets: HashMap::new(), + }; + assert!(validate_merge_input_state(&wide_state).is_err()); + + let mut deep_root = node("leaf", vec![]); + for depth in 0..MAX_MERGE_INPUT_DEPTH { + deep_root = node(&format!("depth-{depth}"), vec![deep_root]); + } + let deep_state = State { + ui_trees: vec![UITree { + src_ui_design: page_id, + root: deep_root, + }], + ui_design_images: HashMap::new(), + sprite_assets: HashMap::new(), + font_assets: HashMap::new(), + }; + assert!(validate_merge_input_state(&deep_state).is_err()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs new file mode 100644 index 000000000..b355afebb --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs @@ -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; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs new file mode 100644 index 000000000..8779b985e --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs @@ -0,0 +1,775 @@ +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::layout::children_display_mode::ChildrenDisplayMode; +use crate::ui_editor::layout::control_layout::ControlLayout; +use crate::ui_editor::layout::dimension::UIRect; +use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata, NodeSource, StageStatus}; +use crate::ui_editor::layout::transform::Transform; +use crate::ui_editor::resource::ui_design_image::UIDesignImage; +use crate::ui_editor::state::{State, UITree}; +use crate::ui_editor::utils::{NodeId, UIDesignImageId}; +use nalgebra::{Point2, Vector2}; +use platform_llm::{ + LlmFunctionTool, LlmMessage, LlmMessageContentPart, LlmRunRequest, LlmToolChoice, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use ts_rs::TS; + +const MAX_REFERENCES: usize = 4; +const MAX_RECOGNITION_TREE_NODES: usize = 512; +const MAX_RECOGNITION_TREE_DEPTH: usize = 32; + +const SYSTEM_PROMPT: &str = r#" +角色: +你是游戏 UI 多图结构识别器。 + +任务: +同时分析同一 UI 系统的全部参考图,建立UI树 + +用户会给你一些UI截图(它们从属于同一个UI系统)和对应的元数据, 请用给定的工具描述UI结构 + +识别规则: +* 只识别 UI,不识别场景人物、地形、建筑、光影和背景装饰。 +* 无法确定类型、层级、关系时,在 UnSure 中写明原因。 +* 返回的 trees 必须与输入图片一一对应,每张输入图片只能有一棵树,不能合并多张图片的树。 每棵树的 src_ui_design_image_id 必须等于对应输入图片标注的 id。 +* 每棵树必须使用自己的输入图片原始像素坐标系(0,0 as left top)输出 + global_pos_x_px、global_pos_y_px、width_px、height_px; +* 为了响应式布局, 我们提供了类似godot的Anchor参数, 可以使用语义化的预设或者可custom的直接操作min max, 请准确地根据父子布局的关系使用 +* 面向用户的字段如名称描述等请用中文 +* 由于每个截图未必是完整的, 可能是局部的, 每棵树描述清楚每个截图上UI的层次结构即可 +* 不同树的共用框架/层次/...请使用使用相同的名称描述. 不同状态/变体名称使用相同的前缀, 用后缀区别 +* 粒度要求: 尽可能细致, 最小单元举例: 进度条的底槽、填充和外框; slider的底槽, dragger等 + +"#; + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +enum HorizontalAnchor { + Left, + Center, + Right, + Stretch, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +enum VerticalAnchor { + Top, + Center, + Bottom, + Stretch, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +struct PresetAnchor { + horizontal: HorizontalAnchor, + vertical: VerticalAnchor, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +struct CustomMinMaxAnchor { + min_x: f32, + min_y: f32, + max_x: f32, + max_y: f32, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +enum Anchor { + Preset(PresetAnchor), + CustomMinMax(CustomMinMaxAnchor), +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub enum Confidence { + Confident, + UnSure(String), +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +struct RecognitionNode { + global_pos_x_px: u32, + global_pos_y_px: u32, + width_px: u32, + height_px: u32, + local_anchor: Anchor, + name: String, + description: String, + children: Vec, + confidence: Confidence, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +struct RecognitionTree { + src_ui_design_image_id: UIDesignImageId, + children: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +struct RecognitionResponse { + trees: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct RecognitionDTO { + pub ui_trees: Vec, +} + +fn recognition_json_schema() -> Result { + strict_json_schema::() +} + +fn validate_recognition_response_shape(value: &serde_json::Value) -> Result<(), String> { + let trees = value + .get("trees") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "识别工具参数缺少 trees 数组".to_string())?; + if trees.len() > MAX_REFERENCES { + return Err(format!("识别结果最多包含 {MAX_REFERENCES} 棵界面树")); + } + for tree in trees { + let children = tree + .get("children") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "识别树缺少 children 数组".to_string())?; + let mut stack = children + .iter() + .map(|node| (node, 1usize)) + .collect::>(); + let mut node_count = 0usize; + while let Some((node, depth)) = stack.pop() { + if depth > MAX_RECOGNITION_TREE_DEPTH { + return Err(format!( + "单棵识别树最大深度不能超过 {MAX_RECOGNITION_TREE_DEPTH}" + )); + } + node_count += 1; + if node_count > MAX_RECOGNITION_TREE_NODES { + return Err(format!( + "单棵识别树最多包含 {MAX_RECOGNITION_TREE_NODES} 个节点" + )); + } + let children = node + .get("children") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "识别节点缺少 children 数组".to_string())?; + stack.extend(children.iter().map(|child| (child, depth + 1))); + } + } + Ok(()) +} + +fn anchor_ranges(anchor: &Anchor) -> Result<(Vector2, Vector2), String> { + fn horizontal(anchor: &HorizontalAnchor) -> (f32, f32) { + match anchor { + HorizontalAnchor::Left => (0.0, 0.0), + HorizontalAnchor::Center => (0.5, 0.5), + HorizontalAnchor::Right => (1.0, 1.0), + HorizontalAnchor::Stretch => (0.0, 1.0), + } + } + fn vertical(anchor: &VerticalAnchor) -> (f32, f32) { + match anchor { + VerticalAnchor::Top => (0.0, 0.0), + VerticalAnchor::Center => (0.5, 0.5), + VerticalAnchor::Bottom => (1.0, 1.0), + VerticalAnchor::Stretch => (0.0, 1.0), + } + } + let (min, max) = match anchor { + Anchor::Preset(value) => { + let (min_x, max_x) = horizontal(&value.horizontal); + let (min_y, max_y) = vertical(&value.vertical); + (Vector2::new(min_x, min_y), Vector2::new(max_x, max_y)) + } + Anchor::CustomMinMax(value) => ( + Vector2::new(value.min_x, value.min_y), + Vector2::new(value.max_x, value.max_y), + ), + }; + if !min.iter().chain(max.iter()).all(|value| value.is_finite()) + || min.x < 0.0 + || min.y < 0.0 + || max.x > 1.0 + || max.y > 1.0 + || min.x > max.x + || min.y > max.y + { + return Err("锚点范围无效".to_string()); + } + Ok((min, max)) +} + +fn random_node_id() -> Result { + NodeId::new(uuid::Uuid::new_v4().simple().to_string()) + .map_err(|error| format!("生成节点 ID 失败:{error}")) +} + +fn image_layout_size(image: &UIDesignImage) -> Result, String> { + let pixels_per_unit = image.pixels_per_unit.get(); + if !pixels_per_unit.is_finite() || pixels_per_unit <= 0.0 { + return Err("界面图 pixels_per_unit 无效".to_string()); + } + let size = image.pixel_size / pixels_per_unit; + if !size.iter().all(|value| value.is_finite() && *value > 0.0) { + return Err("界面图尺寸无效".to_string()); + } + Ok(size) +} + +fn convert_node( + source: &RecognitionNode, + image_id: &UIDesignImageId, + image: &UIDesignImage, + parent_rect: UIRect, +) -> Result { + // LLM 输出的是当前树所属图片的原始像素。Rust 只按该图片自己的 + // pixels_per_unit 转成布局单位,绝不把坐标归一化或映射到 Page 图片。 + let image_size_px = image.pixel_size; + let image_size_valid = image_size_px + .iter() + .all(|value| value.is_finite() && *value > 0.0); + if !image_size_valid { + return Err("界面图尺寸无效".to_string()); + } + let source_min_px = Vector2::new(source.global_pos_x_px as f32, source.global_pos_y_px as f32); + let source_size_node_px = Vector2::new(source.width_px as f32, source.height_px as f32); + let source_max_px = source_min_px + source_size_node_px; + let mut blocked_reasons = Vec::new(); + if source.width_px == 0 || source.height_px == 0 { + blocked_reasons.push("节点宽度或高度为 0"); + } + if source_min_px.x < 0.0 + || source_min_px.y < 0.0 + || source_max_px.x > image_size_px.x + || source_max_px.y > image_size_px.y + { + blocked_reasons.push("节点像素范围超出界面图边界"); + } + let ppu = image.pixels_per_unit.get(); + if !ppu.is_finite() || ppu <= 0.0 { + return Err("界面图 pixels_per_unit 无效".to_string()); + } + let target_min = Point2::new(source_min_px.x / ppu, source_min_px.y / ppu); + let target_rect = UIRect::new( + target_min, + Vector2::new(source_size_node_px.x / ppu, source_size_node_px.y / ppu), + ); + let (anchor_min, anchor_max) = anchor_ranges(&source.local_anchor) + .unwrap_or((Vector2::new(0.5, 0.5), Vector2::new(0.5, 0.5))); + if target_rect.min.x < parent_rect.min.x + || target_rect.min.y < parent_rect.min.y + || target_rect.max().x > parent_rect.max().x + || target_rect.max().y > parent_rect.max().y + { + blocked_reasons.push("节点布局范围超出父节点边界"); + } + if anchor_ranges(&source.local_anchor).is_err() { + blocked_reasons.push("锚点范围无效"); + } + if source.name.trim().is_empty() { + blocked_reasons.push("节点名称为空"); + } + let mut transform = Transform::new(anchor_min, anchor_max, Vector2::zeros(), Vector2::zeros()); + transform.set_resolved_rect(&parent_rect, target_rect); + let status = if blocked_reasons.is_empty() { + match &source.confidence { + Confidence::Confident => StageStatus::NoProblem, + Confidence::UnSure(reason) => StageStatus::NeedReview(reason.clone()), + } + } else { + StageStatus::Blocked(blocked_reasons.join(";")) + }; + let children = source + .children + .iter() + .map(|child| convert_node(child, image_id, image, target_rect)) + .collect::, _>>()?; + Ok(LayoutNode { + id: random_node_id()?, + layout: ControlLayout::with_transform(transform), + metadata: NodeMetadata { + name: source.name.clone(), + description: source.description.clone(), + layout_status: status, + components_status: StageStatus::NoProblem, + allow_llm_edit_layout: true, + allow_llm_edit_component: true, + source: NodeSource::Llm, + }, + // V1 有意把识别结果限定为“结构草稿”:组件绑定属于后续独立阶段。 + // 因此空组件不是丢失数据,而是等待 visual-binding 阶段补齐 Image/Text。 + // 约定见 docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md。 + components: Vec::new(), + children_display_mode: ChildrenDisplayMode::Stack, + children, + }) +} + +fn validate_confidence(nodes: &[RecognitionNode]) -> Result<(), String> { + for node in nodes { + if let Confidence::UnSure(reason) = &node.confidence { + if reason.trim().is_empty() { + return Err("UnSure 必须包含审阅原因".to_string()); + } + } + validate_confidence(&node.children)?; + } + Ok(()) +} + +fn validate_tree_image_ids( + trees: &[RecognitionTree], + allowed_ids: &[UIDesignImageId], +) -> Result<(), String> { + let allowed = allowed_ids.iter().cloned().collect::>(); + let mut seen = HashSet::new(); + for tree in trees { + if !allowed.contains(&tree.src_ui_design_image_id) { + return Err("LLM 返回了不属于当前识别上下文的界面图 ID".to_string()); + } + if !seen.insert(tree.src_ui_design_image_id.clone()) { + return Err("LLM 为同一界面图返回了重复 UI 树".to_string()); + } + validate_confidence(&tree.children)?; + } + if seen.len() != allowed.len() { + return Err("LLM 未为当前识别上下文的每张界面图返回 UI 树".to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_image() -> UIDesignImage { + UIDesignImage { + metadata: crate::ui_editor::resource::ui_design_image::UIDesignImageMetadata { + name: "测试图".to_string(), + description: String::new(), + role: None, + slave_to: None, + }, + path: "test.png".to_string(), + pixel_size: Vector2::new(1000.0, 500.0), + pixels_per_unit: typed_floats::tf32::StrictlyPositiveFinite::new(2.0) + .expect("valid pixels per unit"), + } + } + + fn test_node() -> RecognitionNode { + RecognitionNode { + global_pos_x_px: 100, + global_pos_y_px: 50, + width_px: 200, + height_px: 100, + local_anchor: Anchor::Preset(PresetAnchor { + horizontal: HorizontalAnchor::Left, + vertical: VerticalAnchor::Top, + }), + name: "节点".to_string(), + description: String::new(), + children: Vec::new(), + confidence: Confidence::Confident, + } + } + + fn assert_rect_close(actual: UIRect, expected: UIRect) { + assert!((actual.min - expected.min).norm() <= 1.0e-4); + assert!((actual.size - expected.size).norm() <= 1.0e-4); + } + + fn assert_no_one_of(value: &serde_json::Value) { + match value { + serde_json::Value::Object(object) => { + assert!(!object.contains_key("oneOf")); + for child in object.values() { + assert_no_one_of(child); + } + } + serde_json::Value::Array(values) => { + for child in values { + assert_no_one_of(child); + } + } + _ => {} + } + } + + #[test] + fn strict_schema_rewrites_union_variants_for_provider() { + let schema = recognition_json_schema().expect("recognition schema"); + assert_no_one_of(&schema); + } + + #[test] + fn recognition_limits_apply_per_tree_without_summing_trees() { + let leaves = (0..MAX_RECOGNITION_TREE_NODES) + .map(|_| serde_json::json!({"children": []})) + .collect::>(); + let response = serde_json::json!({ + "trees": [ + {"children": leaves.clone()}, + {"children": leaves} + ] + }); + validate_recognition_response_shape(&response) + .expect("each tree independently fits the node limit"); + } + + #[test] + fn recognition_rejects_tree_node_and_depth_overflow() { + let oversized = (0..=MAX_RECOGNITION_TREE_NODES) + .map(|_| serde_json::json!({"children": []})) + .collect::>(); + assert!(validate_recognition_response_shape(&serde_json::json!({ + "trees": [{"children": oversized}] + })) + .is_err()); + + let mut nested = serde_json::json!({"children": []}); + for _ in 0..MAX_RECOGNITION_TREE_DEPTH { + nested = serde_json::json!({"children": [nested]}); + } + assert!(validate_recognition_response_shape(&serde_json::json!({ + "trees": [{"children": [nested]}] + })) + .is_err()); + } + + #[test] + fn preset_anchor_ranges_use_top_left_image_coordinates() { + let (min, max) = anchor_ranges(&Anchor::Preset(PresetAnchor { + horizontal: HorizontalAnchor::Center, + vertical: VerticalAnchor::Top, + })) + .expect("preset anchor is valid"); + assert_eq!(min, Vector2::new(0.5, 0.0)); + assert_eq!(max, Vector2::new(0.5, 0.0)); + } + + #[test] + fn invalid_custom_anchor_is_rejected() { + let result = anchor_ranges(&Anchor::CustomMinMax(CustomMinMaxAnchor { + min_x: 0.8, + min_y: 0.0, + max_x: 0.2, + max_y: 1.0, + })); + assert!(result.is_err()); + } + + #[test] + fn unsure_without_reason_is_rejected() { + let node = RecognitionNode { + global_pos_x_px: 0, + global_pos_y_px: 0, + width_px: 1, + height_px: 1, + local_anchor: Anchor::Preset(PresetAnchor { + horizontal: HorizontalAnchor::Left, + vertical: VerticalAnchor::Top, + }), + name: "node".to_string(), + description: String::new(), + children: Vec::new(), + confidence: Confidence::UnSure(String::new()), + }; + assert!(validate_confidence(&[node]).is_err()); + } + + #[test] + fn conversion_stays_in_the_tree_image_coordinate_system() { + let image_id = UIDesignImageId::new("slave").expect("valid image id"); + let image = test_image(); + let root_rect = UIRect::new(Point2::origin(), image_layout_size(&image).unwrap()); + let converted = + convert_node(&test_node(), &image_id, &image, root_rect).expect("node conversion"); + + assert_rect_close( + converted.layout.transform.resolve(&root_rect), + UIRect::new(Point2::new(50.0, 25.0), Vector2::new(100.0, 50.0)), + ); + assert_eq!(converted.metadata.components_status, StageStatus::NoProblem); + } + + #[test] + fn conversion_records_every_blocking_reason_for_the_inspector() { + let image_id = UIDesignImageId::new("page").expect("valid image id"); + let image = test_image(); + let root_rect = UIRect::new(Point2::origin(), image_layout_size(&image).unwrap()); + let mut node = test_node(); + node.width_px = 0; + node.global_pos_x_px = 1001; + node.local_anchor = Anchor::CustomMinMax(CustomMinMaxAnchor { + min_x: 0.9, + min_y: 0.0, + max_x: 0.1, + max_y: 1.0, + }); + node.name = " ".to_string(); + + let converted = convert_node(&node, &image_id, &image, root_rect) + .expect("blocking recognition node still materializes"); + assert_eq!( + converted.metadata.layout_status, + StageStatus::Blocked( + "节点宽度或高度为 0;节点像素范围超出界面图边界;节点布局范围超出父节点边界;锚点范围无效;节点名称为空" + .to_string(), + ), + ); + } + + #[test] + fn tree_validation_requires_exactly_one_tree_per_context_image() { + let page = UIDesignImageId::new("page").expect("valid image id"); + let slave = UIDesignImageId::new("slave").expect("valid image id"); + let tree = |id: UIDesignImageId| RecognitionTree { + src_ui_design_image_id: id, + children: Vec::new(), + }; + + assert!(validate_tree_image_ids( + &[tree(page.clone()), tree(slave.clone())], + &[page.clone(), slave.clone()], + ) + .is_ok()); + assert!( + validate_tree_image_ids(&[tree(page.clone())], &[page.clone(), slave.clone()]).is_err() + ); + assert!( + validate_tree_image_ids(&[tree(page.clone()), tree(page.clone())], &[page, slave],) + .is_err() + ); + } +} + +fn recognition_root_image_ids(state: &State) -> Vec { + state + .ui_design_images + .iter() + .filter_map(|(id, image)| { + let is_page = image.metadata.role + == Some(crate::ui_editor::resource::ui_design_image::UIDesignImageRole::Page); + let is_direct_slave_of_page = image.metadata.slave_to.as_ref().is_some_and(|parent| { + state + .ui_design_images + .get(parent) + .and_then(|parent_image| parent_image.metadata.role) + == Some(crate::ui_editor::resource::ui_design_image::UIDesignImageRole::Page) + }); + (is_page || !is_direct_slave_of_page).then(|| id.clone()) + }) + .collect() +} + +fn slave_image_ids(state: &State, root_id: &UIDesignImageId) -> Vec { + state + .ui_design_images + .iter() + .filter_map(|(id, image)| { + (image.metadata.slave_to.as_ref() == Some(root_id)).then(|| id.clone()) + }) + .collect() +} + +pub(crate) async fn recognize_ui_impl( + project_path: String, + state: State, +) -> Result { + if state.ui_design_images.is_empty() { + eprintln!("ui_recognition.error stage=validate reason=no_images"); + return Err("请先导入界面图".to_string()); + } + if state.ui_design_images.len() > MAX_REFERENCES { + eprintln!( + "ui_recognition.error stage=validate reason=too_many_images count={}", + state.ui_design_images.len() + ); + return Err("界面图最多 4 张".to_string()); + } + let root_ids = recognition_root_image_ids(&state); + if root_ids.is_empty() { + eprintln!("ui_recognition.error stage=validate reason=no_root_image"); + return Err("至少需要一张可作为识别上下文根的界面图".to_string()); + } + let client = build_game_creator_llm_client_from_config().map_err(|error| { + eprintln!("ui_recognition.error stage=build_client error={error}"); + error + })?; + let schema = recognition_json_schema().map_err(|error| { + eprintln!("ui_recognition.error stage=build_schema error={error}"); + error + })?; + let root = Path::new(project_path.trim()); + let mut ui_trees = Vec::with_capacity(root_ids.len()); + for root_id in root_ids { + let mut context_ids = vec![root_id.clone()]; + context_ids.extend(slave_image_ids(&state, &root_id)); + // Page 仍可把直接 slave 参考图放在同一识别上下文;没有 Page + // 归属关系的其它界面图各自作为独立上下文根。 + let mut parts = Vec::with_capacity(context_ids.len() * 2); + for (index, context_id) in context_ids.iter().enumerate() { + let image = state + .ui_design_images + .get(context_id) + .ok_or_else(|| "缺少识别上下文界面图资源".to_string())?; + let absolute = + crate::project::resolve_local_project_path(root, &image.path).map_err(|error| { + eprintln!( + "ui_recognition.error stage=resolve_image root={} image={} error={error}", + root_id.as_str(), + context_id.as_str() + ); + error + })?; + let image_url = read_ui_reference_image_data_url(absolute) + .await + .map_err(|error| { + eprintln!( + "ui_recognition.error stage=read_image root={} image={} error={error}", + root_id.as_str(), + context_id.as_str() + ); + error + })?; + parts.push(LlmMessageContentPart::InputText { + text: format!( + "{} id={} pixel_size={:?}", + if index == 0 { "ROOT" } else { "SLAVE" }, + context_id.as_str(), + image.pixel_size + ), + }); + parts.push(LlmMessageContentPart::InputImage { image_url }); + } + let tool = LlmFunctionTool::new( + "recognize_ui_structure", + "识别当前界面图上下文,并为每张输入图片各返回一棵 UI 树", + schema.clone(), + ) + .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_recognition.error stage=llm_request root={} error={error}", + root_id.as_str() + ); + format!("UI 结构识别失败(根界面图 {}):{error}", root_id.as_str()) + })?; + eprintln!( + "ui_recognition.llm_output root={} text_present={} tool_call_count={}", + root_id.as_str(), + !response.text.trim().is_empty(), + response.tool_calls.len() + ); + let call = response + .tool_calls + .iter() + .find(|call| call.name == "recognize_ui_structure") + .ok_or_else(|| { + eprintln!( + "ui_recognition.error stage=parse_tool_call root={} reason=missing_tool_call", + root_id.as_str() + ); + format!( + "根界面图 {} 的 LLM 未返回 recognize_ui_structure 工具调用", + root_id.as_str() + ) + })?; + let arguments = parse_limited_llm_tool_arguments(&call.arguments).map_err(|error| { + eprintln!( + "ui_recognition.error stage=parse_arguments root={} error={error}", + root_id.as_str() + ); + format!("根界面图 {} 的识别工具参数无效:{error}", root_id.as_str()) + })?; + validate_recognition_response_shape(&arguments).map_err(|error| { + eprintln!( + "ui_recognition.error stage=validate_arguments root={} error={error}", + root_id.as_str() + ); + format!("根界面图 {} 的识别工具参数无效:{error}", root_id.as_str()) + })?; + let parsed = serde_json::from_value::(arguments).map_err(|error| { + eprintln!( + "ui_recognition.error stage=parse_arguments root={} error={error}", + root_id.as_str() + ); + format!("根界面图 {} 的识别工具参数无效:{error}", root_id.as_str()) + })?; + validate_tree_image_ids(&parsed.trees, &context_ids).map_err(|error| { + eprintln!( + "ui_recognition.error stage=validate_trees root={} error={error}", + root_id.as_str() + ); + error + })?; + let mut parsed_trees = parsed + .trees + .into_iter() + .map(|tree| (tree.src_ui_design_image_id.clone(), tree)) + .collect::>(); + for image_id in context_ids { + let image = state + .ui_design_images + .get(&image_id) + .ok_or_else(|| "缺少识别上下文界面图资源".to_string())?; + let tree = parsed_trees + .remove(&image_id) + .ok_or_else(|| format!("缺少界面图 {} 的识别树", image_id.as_str()))?; + let size = image_layout_size(image)?; + let root_rect = UIRect::new(Point2::origin(), size); + let children = tree + .children + .iter() + .map(|node| convert_node(node, &image_id, image, root_rect)) + .collect::, _>>()?; + let root = LayoutNode { + id: random_node_id()?, + layout: ControlLayout::with_transform(Transform::stretch()), + metadata: NodeMetadata { + name: "页面根节点".to_string(), + description: String::new(), + layout_status: StageStatus::NoProblem, + components_status: StageStatus::NoProblem, + allow_llm_edit_layout: true, + allow_llm_edit_component: true, + source: NodeSource::System, + }, + components: Vec::new(), + children_display_mode: ChildrenDisplayMode::Stack, + children, + }; + // Tree identity and root identity are assigned by Rust, never chosen by the model. + ui_trees.push(UITree { + src_ui_design: image_id, + root, + }); + } + } + Ok(RecognitionDTO { ui_trees }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/ui_design_suggestion.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/ui_design_suggestion.rs new file mode 100644 index 000000000..296835e62 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/ui_design_suggestion.rs @@ -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, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +#[serde(deny_unknown_fields)] +struct UIDesignSuggestion { + ui_designs: Vec, +} + +fn ui_design_suggestion_json_schema() -> Result { + strict_json_schema::() +} + +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::>(); + 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, +) -> Result<(), String> { + let mut seen = HashSet::new(); + + fn visit( + nodes: &[UIDesignSuggestionTreeNode], + is_root: bool, + image_ids: &HashSet, + seen: &mut HashSet, + ) -> 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, 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::(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 { + id: id(value), + name: value.to_string(), + description: format!("{value} description"), + role, + children, + } + } + + fn image_ids(values: &[&str]) -> HashSet { + 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::>(); + 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::>(); + assert_eq!( + required, + ["id", "name", "description", "role", "children"] + .into_iter() + .collect() + ); + assert_eq!(node_schema["additionalProperties"], false); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/utils.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/utils.rs new file mode 100644 index 000000000..5468f60a6 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/utils.rs @@ -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 { + 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 { + 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 { + 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() -> Result { + 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::()) + .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::>() + }) + { + 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()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/common/error.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/common/error.rs new file mode 100644 index 000000000..28091e353 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/common/error.rs @@ -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 {} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/common/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/common/mod.rs new file mode 100644 index 000000000..a91e73517 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/common/mod.rs @@ -0,0 +1 @@ +pub mod error; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/image.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/image.rs new file mode 100644 index 000000000..e22370f9d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/image.rs @@ -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 +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 { + 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, + 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, 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::(amount).unwrap() + } + }); + assert!(serde_json::from_value::(value).is_err()); + } + } +} + +impl Default for ImageComponent { + fn default() -> Self { + Self::new() + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/mod.rs new file mode 100644 index 000000000..14ab9c854 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/mod.rs @@ -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), +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/text.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/text.rs new file mode 100644 index 000000000..46291e831 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/text.rs @@ -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(deserializer: D) -> Result + 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 { + 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) -> 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::( + serde_json::json!({ "min": 24, "max": 12 }), + ) + .is_err()); + assert!(serde_json::from_value::( + 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" }) + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/children_display_mode.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/children_display_mode.rs new file mode 100644 index 000000000..d57d6d272 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/children_display_mode.rs @@ -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, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/control_layout.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/control_layout.rs new file mode 100644 index 000000000..bf7e69c27 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/control_layout.rs @@ -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, + #[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, + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/dimension.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/dimension.rs new file mode 100644 index 000000000..d21c36eab --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/dimension.rs @@ -0,0 +1,39 @@ +use nalgebra::{Point2, Vector2}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// UI 布局在父级坐标系中的轴对齐矩形。 +/// +/// `min` 是矩形的最小角,`size` 是沿两个坐标轴的尺寸。这里不规定 Y 轴方向, +/// 因而既能用于 Y 轴向上的游戏坐标,也能用于 Y 轴向下的画布坐标。 +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct UIRect { + #[ts(as = "[f32; 2]")] + pub min: Point2, + #[ts(as = "[f32; 2]")] + pub size: Vector2, +} + +impl UIRect { + pub fn new(min: Point2, size: Vector2) -> Self { + Self { min, size } + } + + pub fn from_min_max(min: Point2, max: Point2) -> Self { + Self::new(min, max - min) + } + + pub fn max(&self) -> Point2 { + self.min + self.size + } + + pub fn center(&self) -> Point2 { + self.min + self.size * 0.5 + } + + /// 把矩形内部的归一化坐标转换为当前坐标系中的点。 + pub fn point_at(&self, normalized_position: Vector2) -> Point2 { + self.min + self.size.component_mul(&normalized_position) + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs new file mode 100644 index 000000000..b0932dc49 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs @@ -0,0 +1,5 @@ +pub mod children_display_mode; +pub mod control_layout; +pub mod dimension; +pub mod node; +pub mod transform; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs new file mode 100644 index 000000000..bd9fc0443 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs @@ -0,0 +1,57 @@ +use crate::ui_editor::component::Component; +use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode; +use crate::ui_editor::layout::control_layout::ControlLayout; +use crate::ui_editor::utils::NodeId; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct Node { + pub id: NodeId, + pub layout: ControlLayout, + pub metadata: NodeMetadata, + pub components: Vec, + pub children_display_mode: ChildrenDisplayMode, + pub children: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub enum NodeSource { + System, + Human, + Llm, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub enum StageStatus { + NoProblem, + NeedReview(String), // reason inside + Blocked(String), // reason inside +} + +#[cfg(test)] +mod tests { + use super::StageStatus; + + #[test] + fn rejects_retired_status_values() { + for value in ["\"Pending\"", "\"Passed\""] { + assert!(serde_json::from_str::(value).is_err()); + } + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct NodeMetadata { + pub name: String, + pub description: String, + pub layout_status: StageStatus, + pub components_status: StageStatus, + pub allow_llm_edit_layout: bool, + pub allow_llm_edit_component: bool, + pub source: NodeSource, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/transform.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/transform.rs new file mode 100644 index 000000000..23f390317 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/transform.rs @@ -0,0 +1,215 @@ +use crate::ui_editor::layout::dimension::UIRect; +use nalgebra::Vector2; +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 struct Transform { + #[ts(as = "[f32; 2]")] + pub anchor_min: Vector2, + #[ts(as = "[f32; 2]")] + pub anchor_max: Vector2, + #[ts(as = "[f32; 2]")] + pub offset_min: Vector2, + #[ts(as = "[f32; 2]")] + pub offset_max: Vector2, +} + +impl Transform { + pub fn new( + anchor_min: Vector2, + anchor_max: Vector2, + offset_min: Vector2, + offset_max: Vector2, + ) -> Self { + Self { + anchor_min, + anchor_max, + offset_min, + offset_max, + } + } + + /// 创建中心锚定、指定尺寸的矩形。 + pub fn centered(size: Vector2) -> Self { + let half_size = size * 0.5; + Self::new( + Vector2::new(0.5, 0.5), + Vector2::new(0.5, 0.5), + -half_size, + half_size, + ) + } + + /// 创建铺满父矩形的拉伸布局。 + pub fn stretch() -> Self { + Self::new( + Vector2::zeros(), + Vector2::repeat(1.0), + Vector2::zeros(), + Vector2::zeros(), + ) + } + + /// 锚点在父矩形中覆盖的尺寸。 + pub fn anchor_span_size(&self, parent: &UIRect) -> Vector2 { + parent + .size + .component_mul(&(self.anchor_max - self.anchor_min)) + } + + /// 在给定父矩形下得到最终尺寸。 + pub fn resolved_size(&self, parent: &UIRect) -> Vector2 { + self.anchor_span_size(parent) + self.offset_max - self.offset_min + } + + /// 在给定父矩形下解析出最终轴对齐矩形。 + pub fn resolve(&self, parent: &UIRect) -> UIRect { + let min = parent.point_at(self.anchor_min) + self.offset_min; + let max = parent.point_at(self.anchor_max) + self.offset_max; + UIRect::from_min_max(min, max) + } + + /// 调整 offsets,使解析结果匹配目标矩形,anchors 保持不变。 + pub fn set_resolved_rect(&mut self, parent: &UIRect, rect: UIRect) { + self.offset_min = rect.min - parent.point_at(self.anchor_min); + self.offset_max = rect.max() - parent.point_at(self.anchor_max); + } + + /// 修改 anchors,同时保持当前可见矩形不变。 + pub fn set_anchors_preserving_rect( + &mut self, + parent: &UIRect, + anchor_min: Vector2, + anchor_max: Vector2, + ) { + let rect = self.resolve(parent); + self.anchor_min = anchor_min; + self.anchor_max = anchor_max; + self.set_resolved_rect(parent, rect); + } + + /// 保持最小边不变,直接设置最终尺寸。 + pub fn set_size(&mut self, size: Vector2, parent: &UIRect) { + self.offset_max = self.offset_min + size - self.anchor_span_size(parent); + } +} + +impl Default for Transform { + fn default() -> Self { + Self::centered(Vector2::new(100.0, 100.0)) + } +} + +#[cfg(test)] +mod tests { + use crate::ui_editor::layout::dimension::UIRect; + use crate::ui_editor::layout::transform::Transform; + use nalgebra::{Point2, Vector2}; + + fn assert_vector_close(actual: Vector2, expected: Vector2) { + assert!( + (actual - expected).norm() <= 1.0e-4, + "actual={actual:?}, expected={expected:?}" + ); + } + + fn assert_point_close(actual: Point2, expected: Point2) { + assert_vector_close(actual - expected, Vector2::zeros()); + } + + fn assert_rect_close(actual: UIRect, expected: UIRect) { + assert_point_close(actual.min, expected.min); + assert_vector_close(actual.size, expected.size); + } + + #[test] + fn fixed_anchors_use_offsets_as_final_rect() { + let parent = UIRect::new(Point2::new(10.0, 20.0), Vector2::new(800.0, 600.0)); + let control = Transform::new( + Vector2::new(0.5, 0.5), + Vector2::new(0.5, 0.5), + Vector2::new(-20.0, -30.0), + Vector2::new(80.0, 10.0), + ); + + assert_rect_close( + control.resolve(&parent), + UIRect::new(Point2::new(390.0, 290.0), Vector2::new(100.0, 40.0)), + ); + } + + #[test] + fn split_anchors_add_parent_span_to_offsets() { + let parent = UIRect::new(Point2::new(10.0, 20.0), Vector2::new(800.0, 600.0)); + let control = Transform::new( + Vector2::new(0.1, 0.2), + Vector2::new(0.9, 0.8), + Vector2::new(10.0, 25.0), + Vector2::new(-10.0, -15.0), + ); + + assert_rect_close( + control.resolve(&parent), + UIRect::new(Point2::new(100.0, 165.0), Vector2::new(620.0, 320.0)), + ); + } + + #[test] + fn stretch_fills_a_parent_with_a_non_zero_origin() { + let parent = UIRect::new(Point2::new(-20.0, 35.0), Vector2::new(320.0, 180.0)); + + assert_rect_close(Transform::stretch().resolve(&parent), parent); + } + + #[test] + fn target_rect_can_be_converted_back_to_layout_properties() { + let parent = UIRect::new(Point2::new(100.0, -50.0), Vector2::new(900.0, 500.0)); + let expected = UIRect::new(Point2::new(240.0, 80.0), Vector2::new(360.0, 140.0)); + let mut control = Transform::new( + Vector2::new(0.2, 0.1), + Vector2::new(0.8, 0.9), + Vector2::zeros(), + Vector2::zeros(), + ); + + control.set_resolved_rect(&parent, expected); + + assert_rect_close(control.resolve(&parent), expected); + } + + #[test] + fn changing_anchors_can_preserve_the_visible_rect() { + let parent = UIRect::new(Point2::new(20.0, 30.0), Vector2::new(1280.0, 720.0)); + let mut control = Transform::new( + Vector2::new(0.0, 0.0), + Vector2::new(0.0, 0.0), + Vector2::new(120.0, 80.0), + Vector2::new(420.0, 240.0), + ); + let expected = control.resolve(&parent); + + control.set_anchors_preserving_rect( + &parent, + Vector2::new(0.2, 0.3), + Vector2::new(0.8, 0.7), + ); + assert_rect_close(control.resolve(&parent), expected); + } + + #[test] + fn set_size_accounts_for_parent_anchor_span() { + let parent = UIRect::new(Point2::origin(), Vector2::new(1000.0, 600.0)); + let mut control = Transform::new( + Vector2::new(0.25, 0.2), + Vector2::new(0.75, 0.8), + Vector2::zeros(), + Vector2::zeros(), + ); + + control.set_size(Vector2::new(640.0, 240.0), &parent); + + assert_vector_close(control.resolved_size(&parent), Vector2::new(640.0, 240.0)); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs new file mode 100644 index 000000000..08848ee92 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs @@ -0,0 +1,7 @@ +pub mod commands; +pub mod component; +pub mod layout; +pub mod persistence; +pub mod resource; +pub mod state; +mod utils; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs new file mode 100644 index 000000000..fbdf0970c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs @@ -0,0 +1,1183 @@ +use crate::ui_editor::component::text::FontSource; +use crate::ui_editor::component::Component; +use crate::ui_editor::layout::node::Node; +use crate::ui_editor::state::State; +use crate::*; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +const UI_DESIGN_STATE_SCHEMA_VERSION: &str = "game-creator-ui-design-state.v1"; +const UI_DESIGN_STATE_MAX_BYTES: usize = 2 * 1024 * 1024; +const UI_DESIGN_STATE_MAX_IMAGES: usize = 4; +const UI_DESIGN_STATE_MAX_SPRITES: usize = 1_024; +pub(crate) const UI_DESIGN_STATE_MAX_NODES: usize = 10_000; +const UI_DESIGN_STATE_MAX_DEPTH: usize = 128; +pub(crate) const UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE: usize = 64; +const UI_DESIGN_STATE_MAX_SAFE_REVISION: u64 = 9_007_199_254_740_991; + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct PersistedUiDesignState { + schema_version: String, + project_id: String, + asset_id: String, + revision: u64, + state: State, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct LoadUiDesignStateInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) asset_id: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct SaveUiDesignStateInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) asset_id: String, + pub(crate) expected_revision: u64, + pub(crate) state: State, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UiDesignStateSnapshot { + pub(crate) revision: u64, + pub(crate) state: State, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "status", rename_all = "kebab-case")] +pub(crate) enum SaveUiDesignStateResult { + Saved { + state: State, + revision: u64, + committed_project_revision: u64, + }, + Unchanged { + state: State, + revision: u64, + committed_project_revision: u64, + }, + Conflict { + current: UiDesignStateSnapshot, + }, +} + +pub(crate) fn initialize_ui_design_state_at( + root: &Path, + project_id: &str, + asset_id: &str, +) -> Result<(), String> { + let asset = ui_design_asset(root, project_id, asset_id)?; + let document = empty_document(project_id, asset_id); + write_ui_design_document(root, &asset.local_path, &document)?; + let installed = read_ui_design_document(root, &asset.local_path, project_id, asset_id)?; + validate_document(&installed, project_id, asset_id)?; + Ok(()) +} + +pub(crate) fn load_ui_design_state_at( + input: LoadUiDesignStateInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?; + let asset_id = required_identifier(&input.asset_id, "assetId")?; + let asset = ui_design_asset(root, &expected_project_id, &asset_id)?; + let document = + read_ui_design_document(root, &asset.local_path, &expected_project_id, &asset_id)?; + validate_document(&document, &expected_project_id, &asset_id)?; + Ok(UiDesignStateSnapshot { + revision: document.revision, + state: document.state, + }) +} + +pub(crate) fn save_ui_design_state_at( + input: SaveUiDesignStateInput, +) -> Result { + if input.expected_revision > UI_DESIGN_STATE_MAX_SAFE_REVISION { + return Err("UI 设计 State revision 超出 JavaScript 安全整数范围".to_string()); + } + validate_state(&input.state)?; + let root = Path::new(input.project_path.trim()); + let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?; + let asset_id = required_identifier(&input.asset_id, "assetId")?; + + let preflight_asset = ui_design_asset(root, &expected_project_id, &asset_id)?; + let _lock = acquire_project_write_lock(root, "ui_design.state_save")?; + let asset = ui_design_asset(root, &expected_project_id, &asset_id)?; + if asset.local_path != preflight_asset.local_path { + return Err("UI 设计资源在保存锁获取期间发生变化,请重试".to_string()); + } + let current = + read_ui_design_document_locked(root, &asset.local_path, &expected_project_id, &asset_id)?; + validate_document(¤t, &expected_project_id, &asset_id)?; + if current.revision != input.expected_revision { + return Ok(SaveUiDesignStateResult::Conflict { + current: UiDesignStateSnapshot { + revision: current.revision, + state: current.state, + }, + }); + } + if current.state == input.state { + let committed_project_revision = + read_game_creator_agent_runtime_project_revision(root)?.revision; + return Ok(SaveUiDesignStateResult::Unchanged { + state: current.state, + revision: current.revision, + committed_project_revision, + }); + } + if current.revision == UI_DESIGN_STATE_MAX_SAFE_REVISION { + return Err( + "UI 设计 State revision 已达到 JavaScript 安全整数上限,无法继续保存".to_string(), + ); + } + let next = PersistedUiDesignState { + schema_version: UI_DESIGN_STATE_SCHEMA_VERSION.to_string(), + project_id: expected_project_id.clone(), + asset_id: asset_id.clone(), + revision: current.revision + 1, + state: input.state, + }; + validate_document(&next, &expected_project_id, &asset_id)?; + write_ui_design_document(root, &asset.local_path, &next)?; + let installed = + read_ui_design_document_locked(root, &asset.local_path, &expected_project_id, &asset_id)?; + validate_document(&installed, &expected_project_id, &asset_id)?; + if installed != next { + return Err("UI 设计 State 安装后回读与待写内容不一致".to_string()); + } + let committed_project_revision = + advance_agent_runtime_project_revision_locked(root).map_err(|error| { + format!( + "reconciliation-required: UI 设计 State 已保存,但项目 revision 未能推进:{error}" + ) + })?; + Ok(SaveUiDesignStateResult::Saved { + state: installed.state, + revision: installed.revision, + committed_project_revision, + }) +} + +fn empty_document(project_id: &str, asset_id: &str) -> PersistedUiDesignState { + PersistedUiDesignState { + schema_version: UI_DESIGN_STATE_SCHEMA_VERSION.to_string(), + project_id: project_id.to_string(), + asset_id: asset_id.to_string(), + revision: 0, + state: State { + ui_trees: Vec::new(), + ui_design_images: Default::default(), + sprite_assets: Default::default(), + font_assets: Default::default(), + }, + } +} + +fn required_identifier(value: &str, label: &str) -> Result { + let value = value.trim(); + if value.is_empty() || value.chars().any(char::is_control) { + return Err(format!("UI 设计 State {label} 无效")); + } + Ok(value.to_string()) +} + +fn ui_design_asset( + root: &Path, + expected_project_id: &str, + asset_id: &str, +) -> Result { + let manifest = read_existing_manifest_for_project(root)?; + if manifest.project_id != expected_project_id { + return Err("UI 设计 State expectedProjectId 与当前项目不匹配".to_string()); + } + let asset = manifest + .assets + .into_iter() + .find(|asset| asset.id == asset_id) + .ok_or_else(|| "UI 设计资源不存在".to_string())?; + if asset.kind != "UI" || asset.media_type != "application/json" { + return Err("目标资源不是 UI 设计 JSON 资产".to_string()); + } + normalize_relative_path(&asset.local_path)?; + Ok(asset) +} + +fn read_ui_design_document( + root: &Path, + relative_path: &str, + project_id: &str, + asset_id: &str, +) -> Result { + let primary_path = resolve_local_project_path(root, relative_path)?; + match read_valid_ui_design_document_path(&primary_path, project_id, asset_id) { + Ok(document) => Ok(document), + Err(_) => { + let _lock = acquire_project_write_lock(root, "ui_design.state_recover")?; + read_ui_design_document_locked(root, relative_path, project_id, asset_id) + } + } +} + +fn read_ui_design_document_locked( + root: &Path, + relative_path: &str, + project_id: &str, + asset_id: &str, +) -> Result { + let primary_path = resolve_local_project_path(root, relative_path)?; + let backup_path = agent_runtime_json_sidecar_backup_path(&primary_path); + match read_valid_ui_design_document_path(&primary_path, project_id, asset_id) { + Ok(document) => Ok(document), + Err(primary_error) => { + match read_valid_ui_design_document_path(&backup_path, project_id, asset_id) { + Ok(document) => { + write_ui_design_document(root, relative_path, &document)?; + Ok(document) + } + Err(_) => Err(primary_error), + } + } + } +} + +fn read_valid_ui_design_document_path( + path: &Path, + project_id: &str, + asset_id: &str, +) -> Result { + let document = read_ui_design_document_path(path)?; + validate_document(&document, project_id, asset_id)?; + Ok(document) +} + +fn read_ui_design_document_path(path: &Path) -> Result { + let (mut file, metadata) = open_project_snapshot_regular_file(path, "UI 设计 State")?; + if metadata.len() > UI_DESIGN_STATE_MAX_BYTES as u64 { + return Err(format!( + "UI 设计 State 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限" + )); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + Read::by_ref(&mut file) + .take((UI_DESIGN_STATE_MAX_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| format!("读取 UI 设计 State 失败:{}: {error}", path.display()))?; + if bytes.len() > UI_DESIGN_STATE_MAX_BYTES { + return Err(format!( + "UI 设计 State 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限" + )); + } + let value: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("解析 UI 设计 State 失败:{}: {error}", path.display()))?; + let document: PersistedUiDesignState = serde_json::from_value(value.clone()) + .map_err(|error| format!("解析 UI 设计 State 契约失败:{}: {error}", path.display()))?; + let canonical: serde_json::Value = + serde_json::from_slice(&serialize_ui_design_document(&document)?) + .map_err(|error| format!("序列化 UI 设计 State 契约失败:{error}"))?; + if value != canonical { + return Err("UI 设计 State 包含不受当前 schema 支持的字段或值".to_string()); + } + Ok(document) +} + +fn write_ui_design_document( + root: &Path, + relative_path: &str, + document: &PersistedUiDesignState, +) -> Result<(), String> { + let primary_path = resolve_local_project_path(root, relative_path)?; + let backup_path = agent_runtime_json_sidecar_backup_path(&primary_path); + // Best-effort recovery keeps the last parseable, canonical envelope as a + // candidate. Semantic identity, revision and State validation is deferred + // to read_valid_ui_design_document_path before a recovery candidate is installed. + if let Ok(previous) = read_ui_design_document_path(&primary_path) { + write_ui_design_raw_file( + &backup_path, + "UI 设计 State 恢复副本", + &serialize_ui_design_document(&previous)?, + )?; + } + write_ui_design_raw_file( + &primary_path, + "UI 设计 State", + &serialize_ui_design_document(document)?, + ) +} + +fn serialize_ui_design_document(document: &PersistedUiDesignState) -> Result, String> { + let mut content = serde_json::to_vec_pretty(document) + .map_err(|error| format!("序列化 UI 设计 State 失败:{error}"))?; + content.push(b'\n'); + if content.len() > UI_DESIGN_STATE_MAX_BYTES { + return Err(format!( + "UI 设计 State 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限" + )); + } + Ok(content) +} + +/// Installs a single JSON document without touching the sibling `.previous` +/// recovery copy. The replacement path deliberately follows the shared +/// runtime sidecar protocol: a stable `.previous` is recoverable after a +/// process crash, unlike a random `.replace..` file. +fn write_ui_design_raw_file(path: &Path, label: &str, bytes: &[u8]) -> Result<(), String> { + if bytes.len() > UI_DESIGN_STATE_MAX_BYTES { + return Err(format!("{label} 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限")); + } + let parent = path.parent().ok_or_else(|| format!("{label} 缺少父目录"))?; + fs::create_dir_all(parent).map_err(|error| format!("创建 {label} 目录失败:{error}"))?; + if fs::symlink_metadata(path).is_ok() { + open_project_snapshot_regular_file(path, label)?; + } + + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("ui-design-state.json"); + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("生成 {label} 临时文件名失败:{error}"))? + .as_nanos(); + let temp_path = path.with_file_name(format!(".{name}.tmp.{}.{}", std::process::id(), nonce)); + let replacement_path = agent_runtime_json_sidecar_backup_path(path); + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW).mode(0o600); + } + let mut file = options.open(&temp_path).map_err(|error| { + format!( + "创建 {label} 临时文件失败:{}: {error}", + temp_path.display() + ) + })?; + file.write_all(bytes) + .and_then(|_| file.sync_data()) + .map_err(|error| { + let _ = fs::remove_file(&temp_path); + format!( + "写入 {label} 临时文件失败:{}: {error}", + temp_path.display() + ) + })?; + drop(file); + + match fs::rename(&temp_path, path) { + // `.previous` is prepared by write_ui_design_document before this call; + // preserve it on the normal atomic-rename path for startup recovery. + Ok(()) => {} + Err(first_error) => { + let metadata = fs::symlink_metadata(path).map_err(|error| { + let _ = fs::remove_file(&temp_path); + format!("替换 {label} 失败:{first_error};读取原文件失败:{error}") + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + let _ = fs::remove_file(&temp_path); + return Err(format!("{label} 必须是普通文件")); + } + // 与 agent/runtime_protocol/json_sidecar.rs 的通用写入器保持一致: + // 先清理旧恢复副本,再把旧主文件放到稳定 `.previous`,使崩溃窗口可恢复。 + remove_agent_runtime_json_sidecar_backup(&replacement_path, label)?; + fs::rename(path, &replacement_path).map_err(|error| { + let _ = fs::remove_file(&temp_path); + format!("准备替换 {label} 失败:{first_error};{error}") + })?; + if let Err(error) = fs::rename(&temp_path, path) { + let restore_error = fs::rename(&replacement_path, path).err(); + let _ = fs::remove_file(&temp_path); + let restore_detail = restore_error + .map(|restore| format!(";恢复旧文件失败:{restore}")) + .unwrap_or_default(); + return Err(format!("替换 {label} 失败:{error}{restore_detail}")); + } + remove_agent_runtime_json_sidecar_backup(&replacement_path, label)?; + } + } + #[cfg(unix)] + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| format!("同步 {label} 目录失败:{}: {error}", parent.display()))?; + Ok(()) +} + +fn validate_document( + document: &PersistedUiDesignState, + project_id: &str, + asset_id: &str, +) -> Result<(), String> { + if document.schema_version != UI_DESIGN_STATE_SCHEMA_VERSION { + return Err(format!( + "不支持的 UI 设计 State schema:{}", + document.schema_version + )); + } + if document.project_id != project_id || document.asset_id != asset_id { + return Err("UI 设计 State 身份与当前资源不匹配".to_string()); + } + if document.revision > UI_DESIGN_STATE_MAX_SAFE_REVISION { + return Err("UI 设计 State revision 超出 JavaScript 安全整数范围".to_string()); + } + validate_state(&document.state) +} + +fn validate_state(state: &State) -> Result<(), String> { + if state.ui_design_images.len() > UI_DESIGN_STATE_MAX_IMAGES { + return Err(format!( + "UI 设计最多支持 {UI_DESIGN_STATE_MAX_IMAGES} 张界面图" + )); + } + if state.sprite_assets.len() > UI_DESIGN_STATE_MAX_SPRITES { + return Err(format!( + "UI 设计最多支持 {UI_DESIGN_STATE_MAX_SPRITES} 项独立素材" + )); + } + for (id, image) in &state.ui_design_images { + validate_id(id.as_str(), "界面图 ID")?; + normalize_relative_path(&image.path)?; + if !image + .pixel_size + .iter() + .all(|value| value.is_finite() && *value > 0.0) + { + return Err("界面图像素尺寸必须为正有限数值".to_string()); + } + if image + .metadata + .slave_to + .as_ref() + .is_some_and(|owner| !state.ui_design_images.contains_key(owner)) + { + return Err("界面图 slaveTo 引用了不存在的界面图".to_string()); + } + } + validate_slave_to_acyclic(state)?; + for (id, sprite) in &state.sprite_assets { + validate_id(id.as_str(), "独立素材 ID")?; + if sprite.asset_id != *id { + return Err("独立素材 map key 与 assetId 不一致".to_string()); + } + normalize_relative_path(&sprite.path)?; + if !sprite + .pixel_size + .iter() + .all(|value| value.is_finite() && *value > 0.0) + { + return Err("独立素材像素尺寸必须为正有限数值".to_string()); + } + } + let mut node_ids = HashSet::new(); + let mut node_count = 0usize; + for tree in &state.ui_trees { + if !state.ui_design_images.contains_key(&tree.src_ui_design) { + return Err("UI 树引用了不存在的界面图".to_string()); + } + validate_node(&tree.root, state, 1, &mut node_count, &mut node_ids)?; + } + Ok(()) +} + +fn validate_slave_to_acyclic(state: &State) -> Result<(), String> { + for start in state.ui_design_images.keys() { + let mut current = Some(start); + let mut visited = HashSet::new(); + while let Some(id) = current { + if !visited.insert(id) { + return Err("界面图 slaveTo 不能形成循环".to_string()); + } + current = state + .ui_design_images + .get(id) + .and_then(|image| image.metadata.slave_to.as_ref()); + } + } + Ok(()) +} + +fn validate_node( + node: &Node, + state: &State, + depth: usize, + node_count: &mut usize, + node_ids: &mut HashSet, +) -> Result<(), String> { + if depth > UI_DESIGN_STATE_MAX_DEPTH { + return Err(format!("UI 树最大深度不能超过 {UI_DESIGN_STATE_MAX_DEPTH}")); + } + *node_count += 1; + if *node_count > UI_DESIGN_STATE_MAX_NODES { + return Err(format!( + "UI 设计最多支持 {UI_DESIGN_STATE_MAX_NODES} 个节点" + )); + } + validate_id(node.id.as_str(), "节点 ID")?; + if !node_ids.insert(node.id.as_str().to_string()) { + return Err("UI 设计节点 ID 不能重复".to_string()); + } + if !node + .layout + .transform + .anchor_min + .iter() + .all(|value| value.is_finite()) + || !node + .layout + .transform + .anchor_max + .iter() + .all(|value| value.is_finite()) + || !node + .layout + .transform + .offset_min + .iter() + .all(|value| value.is_finite()) + || !node + .layout + .transform + .offset_max + .iter() + .all(|value| value.is_finite()) + { + return Err("UI 节点 Transform 必须是有限数值".to_string()); + } + if node + .layout + .transform + .anchor_min + .iter() + .zip(node.layout.transform.anchor_max.iter()) + .any(|(min, max)| min > max) + { + return Err("UI 节点 Transform 的 anchor 必须按 min 到 max 排序".to_string()); + } + if !node + .layout + .custom_minimum_size + .iter() + .all(|value| value.is_finite() && *value >= 0.0) + { + return Err("UI 节点 custom_minimum_size 必须是有限非负数".to_string()); + } + if !node.layout.size_flags_stretch_ratio.is_finite() + || node.layout.size_flags_stretch_ratio <= 0.0 + { + return Err("UI 节点 size_flags_stretch_ratio 必须是有限正数".to_string()); + } + if !crate::ui_editor::layout::control_layout::is_valid_size_flag( + node.layout.size_flags_horizontal, + ) || !crate::ui_editor::layout::control_layout::is_valid_size_flag( + node.layout.size_flags_vertical, + ) { + return Err("UI 节点 size flags 无效".to_string()); + } + match &node.layout.container { + crate::ui_editor::layout::control_layout::Container::None + | crate::ui_editor::layout::control_layout::Container::Center { .. } => {} + crate::ui_editor::layout::control_layout::Container::HBox { separation, .. } + | crate::ui_editor::layout::control_layout::Container::VBox { separation, .. } => { + if !separation.is_finite() || *separation < 0.0 { + return Err("Container separation 必须是有限非负数".to_string()); + } + } + crate::ui_editor::layout::control_layout::Container::Grid { + columns, + h_separation, + v_separation, + } => { + if *columns == 0 { + return Err("GridContainer columns 必须大于 0".to_string()); + } + if !h_separation.is_finite() + || *h_separation < 0.0 + || !v_separation.is_finite() + || *v_separation < 0.0 + { + return Err("GridContainer separation 必须是有限非负数".to_string()); + } + } + crate::ui_editor::layout::control_layout::Container::Margin { + margin_left, + margin_top, + margin_right, + margin_bottom, + } => { + if ![*margin_left, *margin_top, *margin_right, *margin_bottom] + .iter() + .all(|value| value.is_finite() && *value >= 0.0) + { + return Err("MarginContainer 边距必须是有限非负数".to_string()); + } + } + } + if node.components.len() > UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE { + return Err(format!( + "单个 UI 节点最多支持 {UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE} 个组件" + )); + } + for component in &node.components { + match component { + Component::Image(image) => { + if image + .target_graphic + .as_ref() + .is_some_and(|id| !state.sprite_assets.contains_key(id)) + { + return Err("Image 组件引用了不存在的独立素材".to_string()); + } + } + Component::Text(text) => { + if let FontSource::Bound(id) = &text.font { + if !state.font_assets.contains_key(id) { + return Err("Text 组件引用了不存在的字体素材".to_string()); + } + } + } + } + } + for child in &node.children { + validate_node(child, state, depth + 1, node_count, node_ids)?; + } + Ok(()) +} + +fn validate_id(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() || value.trim() != value || value.chars().any(char::is_control) { + return Err(format!("{label} 无效")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PROJECT_ID: &str = "ui-design-persistence-project"; + + fn fixture() -> (tempfile::TempDir, String) { + let directory = tempfile::tempdir().expect("create UI design persistence fixture"); + init_local_game_project_at(directory.path(), PROJECT_ID, "UI 设计持久化测试") + .expect("init project"); + let relative_path = "ui/design.json"; + let absolute_path = resolve_local_project_path(directory.path(), relative_path) + .expect("resolve UI design path"); + fs::create_dir_all(absolute_path.parent().expect("UI design parent")) + .expect("create UI design parent"); + fs::write(&absolute_path, b"").expect("create UI design file"); + let asset = register_local_asset_at( + directory.path(), + relative_path, + "UI", + "application/json", + "test", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: Some("ui:test".to_string()), + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) + .expect("register UI design asset"); + initialize_ui_design_state_at(directory.path(), PROJECT_ID, &asset.id) + .expect("initialize UI design state"); + (directory, asset.id) + } + + fn input(root: &Path, asset_id: &str, revision: u64, state: State) -> SaveUiDesignStateInput { + SaveUiDesignStateInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id: asset_id.to_string(), + expected_revision: revision, + state, + } + } + + fn state_with_unavailable_image(path: &str) -> State { + serde_json::from_value(serde_json::json!({ + "ui_trees": [], + "ui_design_images": { + "page": { + "metadata": { + "name": "主界面", + "description": "", + "role": "Page", + "slave_to": null + }, + "path": path, + "pixel_size": [1280.0, 720.0], + "pixels_per_unit": 1.0 + } + }, + "sprite_assets": {}, + "font_assets": {} + })) + .expect("deserialize valid UI design state") + } + + fn state_with_sprite_and_dragged_transform() -> State { + serde_json::from_value(serde_json::json!({ + "ui_trees": [{ + "src_ui_design": "page", + "root": { + "id": "root", + "layout": { + "transform": { + "anchor_min": [0.0, 0.0], + "anchor_max": [1.0, 1.0], + "offset_min": [0.0, 0.0], + "offset_max": [0.0, 0.0] + }, + "custom_minimum_size": [0.0, 0.0], + "size_flags_horizontal": 1, + "size_flags_vertical": 1, + "size_flags_stretch_ratio": 1.0, + "container": "None" + }, + "metadata": { + "name": "页面根节点", + "description": "", + "layout_status": "NoProblem", + "components_status": "NoProblem", + "allow_llm_edit_layout": true, + "allow_llm_edit_component": true, + "source": "System" + }, + "components": [], + "children_display_mode": "Stack", + "children": [{ + "id": "dragged-node", + "layout": { + "transform": { + "anchor_min": [0.5, 0.5], + "anchor_max": [0.5, 0.5], + "offset_min": [-248.53181076672104, -104.40456769983687], + "offset_max": [348.53181076672104, 204.40456769983687] + }, + "custom_minimum_size": [0.0, 0.0], + "size_flags_horizontal": 1, + "size_flags_vertical": 1, + "size_flags_stretch_ratio": 1.0, + "container": "None" + }, + "metadata": { + "name": "拖拽节点", + "description": "", + "layout_status": "NoProblem", + "components_status": "NoProblem", + "allow_llm_edit_layout": true, + "allow_llm_edit_component": true, + "source": "Human" + }, + "components": [{ + "Image": { + "target_graphic": "spirit", + "image_type": { "Simple": { "preserve_aspect": false } } + } + }], + "children_display_mode": "Stack", + "children": [] + }] + } + }], + "ui_design_images": { + "page": { + "metadata": { + "name": "主界面", + "description": "", + "role": "Page", + "slave_to": null + }, + "path": "assets/page.png", + "pixel_size": [1280.0, 720.0], + "pixels_per_unit": 1.0 + } + }, + "sprite_assets": { + "spirit": { + "asset_id": "spirit", + "metadata": { "name": "灵体", "asset_type": "角色" }, + "path": "assets/spirit.png", + "pixel_size": [128.0, 256.0], + "pixels_per_unit": 1.0, + "border": { "left": 0, "right": 0, "top": 0, "bottom": 0 } + } + }, + "font_assets": {} + })) + .expect("deserialize dragged UI design state") + } + + #[test] + fn saves_loads_and_compares_per_resource_revisions() { + let (directory, asset_id) = fixture(); + let initial = load_ui_design_state_at(LoadUiDesignStateInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id: asset_id.clone(), + }) + .expect("load initialized state"); + assert_eq!(initial.revision, 0); + + let state = state_with_unavailable_image("assets/missing-reference.png"); + let saved = save_ui_design_state_at(input(directory.path(), &asset_id, 0, state.clone())) + .expect("save state"); + assert!(matches!( + saved, + SaveUiDesignStateResult::Saved { revision: 1, .. } + )); + + let unchanged = + save_ui_design_state_at(input(directory.path(), &asset_id, 1, state.clone())) + .expect("save identical state"); + assert!(matches!( + unchanged, + SaveUiDesignStateResult::Unchanged { revision: 1, .. } + )); + + let conflict = save_ui_design_state_at(input( + directory.path(), + &asset_id, + 0, + State { + ui_trees: Vec::new(), + ui_design_images: Default::default(), + sprite_assets: Default::default(), + font_assets: Default::default(), + }, + )) + .expect("return CAS conflict"); + assert!( + matches!(conflict, SaveUiDesignStateResult::Conflict { current } if current.revision == 1 && current.state == state) + ); + } + + #[test] + fn preserves_sprite_asset_when_saving_a_dragged_node_transform() { + let (directory, asset_id) = fixture(); + let state = state_with_sprite_and_dragged_transform(); + + let expected_document = PersistedUiDesignState { + schema_version: UI_DESIGN_STATE_SCHEMA_VERSION.to_string(), + project_id: PROJECT_ID.to_string(), + asset_id: asset_id.clone(), + revision: 1, + state: state.clone(), + }; + let serialized = serialize_ui_design_document(&expected_document) + .expect("serialize dragged state fixture"); + let decoded: PersistedUiDesignState = + serde_json::from_slice(&serialized).expect("deserialize dragged state fixture"); + assert_eq!(decoded, expected_document); + + let saved = save_ui_design_state_at(input(directory.path(), &asset_id, 0, state.clone())) + .expect("save dragged state with sprite asset"); + assert!(matches!( + saved, + SaveUiDesignStateResult::Saved { + revision: 1, + state: ref installed, + .. + } if installed == &state + )); + + let loaded = load_ui_design_state_at(LoadUiDesignStateInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id, + }) + .expect("reload dragged state with sprite asset"); + assert_eq!(loaded.revision, 1); + assert_eq!(loaded.state, state); + } + + #[test] + fn rejects_inverted_transform_anchors() { + let state: State = serde_json::from_value(serde_json::json!({ + "ui_trees": [{ + "src_ui_design": "page", + "root": { + "id": "root", + "layout": { + "transform": { + "anchor_min": [0.8, 0.0], + "anchor_max": [0.2, 1.0], + "offset_min": [0.0, 0.0], + "offset_max": [0.0, 0.0] + }, + "custom_minimum_size": [0.0, 0.0], + "size_flags_horizontal": 1, + "size_flags_vertical": 1, + "size_flags_stretch_ratio": 1.0, + "container": "None" + }, + "metadata": { + "name": "根节点", + "description": "", + "layout_status": "NoProblem", + "components_status": "NoProblem", + "allow_llm_edit_layout": true, + "allow_llm_edit_component": true, + "source": "System" + }, + "components": [], + "children_display_mode": "Stack", + "children": [] + } + }], + "ui_design_images": { + "page": { + "metadata": { + "name": "主界面", + "description": "", + "role": "Page", + "slave_to": null + }, + "path": "missing.png", + "pixel_size": [1280.0, 720.0], + "pixels_per_unit": 1.0 + } + }, + "sprite_assets": {}, + "font_assets": {} + })) + .expect("deserialize state with inverted anchors"); + + assert_eq!( + validate_state(&state), + Err("UI 节点 Transform 的 anchor 必须按 min 到 max 排序".to_string()) + ); + } + + #[test] + fn rejects_cyclic_slave_to_relationships() { + let state: State = serde_json::from_value(serde_json::json!({ + "ui_trees": [], + "ui_design_images": { + "page": { + "metadata": { + "name": "主界面", + "description": "", + "role": "Page", + "slave_to": "section" + }, + "path": "page.png", + "pixel_size": [1280.0, 720.0], + "pixels_per_unit": 1.0 + }, + "section": { + "metadata": { + "name": "子页面", + "description": "", + "role": "Section", + "slave_to": "page" + }, + "path": "section.png", + "pixel_size": [1280.0, 720.0], + "pixels_per_unit": 1.0 + } + }, + "sprite_assets": {}, + "font_assets": {} + })) + .expect("deserialize cyclic state"); + + assert_eq!( + validate_state(&state), + Err("界面图 slaveTo 不能形成循环".to_string()) + ); + } + + #[test] + fn recovers_last_valid_state_when_primary_is_corrupt() { + let (directory, asset_id) = fixture(); + let first = state_with_unavailable_image("assets/first-missing.png"); + let second = state_with_unavailable_image("assets/second-missing.png"); + save_ui_design_state_at(input(directory.path(), &asset_id, 0, first.clone())) + .expect("save first state"); + save_ui_design_state_at(input(directory.path(), &asset_id, 1, second)) + .expect("save second state"); + + let asset = ui_design_asset(directory.path(), PROJECT_ID, &asset_id).expect("find asset"); + let primary = resolve_local_project_path(directory.path(), &asset.local_path) + .expect("resolve primary"); + fs::write(&primary, b"{broken").expect("corrupt primary"); + + let recovered = load_ui_design_state_at(LoadUiDesignStateInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id, + }) + .expect("recover previous state"); + assert_eq!(recovered.revision, 1); + assert_eq!(recovered.state, first); + assert_eq!( + read_ui_design_document_path(&primary) + .expect("restored primary") + .revision, + 1 + ); + } + + #[test] + fn recovery_install_respects_the_project_write_lock() { + let (directory, asset_id) = fixture(); + let first = state_with_unavailable_image("assets/first-missing.png"); + let second = state_with_unavailable_image("assets/second-missing.png"); + save_ui_design_state_at(input(directory.path(), &asset_id, 0, first.clone())) + .expect("save first state"); + save_ui_design_state_at(input(directory.path(), &asset_id, 1, second)) + .expect("save second state"); + + let asset = ui_design_asset(directory.path(), PROJECT_ID, &asset_id).expect("find asset"); + let primary = resolve_local_project_path(directory.path(), &asset.local_path) + .expect("resolve primary"); + fs::write(&primary, b"{broken").expect("corrupt primary"); + + let project_lock = acquire_project_write_lock(directory.path(), "test.concurrent-save") + .expect("hold project write lock"); + let error = load_ui_design_state_at(LoadUiDesignStateInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id: asset_id.clone(), + }) + .expect_err("recovery must not install while another writer holds the lock"); + assert!(error.contains("项目正在被其他写操作占用")); + assert!(read_ui_design_document_path(&primary).is_err()); + drop(project_lock); + + let recovered = load_ui_design_state_at(LoadUiDesignStateInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id, + }) + .expect("recover after project lock is released"); + assert_eq!(recovered.revision, 1); + assert_eq!(recovered.state, first); + } + + #[test] + fn rejects_legacy_empty_object_and_unknown_envelope_fields() { + let (directory, asset_id) = fixture(); + let asset = ui_design_asset(directory.path(), PROJECT_ID, &asset_id).expect("find asset"); + let path = + resolve_local_project_path(directory.path(), &asset.local_path).expect("resolve asset"); + let backup = agent_runtime_json_sidecar_backup_path(&path); + if backup.exists() { + fs::remove_file(&backup).expect("remove recovery copy for strict schema test"); + } + + fs::write(&path, b"{}\n").expect("write legacy empty object"); + assert!(load_ui_design_state_at(LoadUiDesignStateInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id: asset_id.clone(), + }) + .is_err()); + + let mut value = serde_json::to_value(empty_document(PROJECT_ID, &asset_id)) + .expect("serialize envelope"); + value["unexpected"] = serde_json::json!(true); + fs::write( + &path, + serde_json::to_vec(&value).expect("serialize bad envelope"), + ) + .expect("write unknown field"); + assert!(load_ui_design_state_at(LoadUiDesignStateInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id, + }) + .is_err()); + } + + #[test] + fn rejects_text_component_with_missing_font_asset() { + let (directory, asset_id) = fixture(); + let state: State = serde_json::from_value(serde_json::json!({ + "ui_trees": [{ + "src_ui_design": "page", + "root": { + "id": "root", + "layout": { + "transform": { + "anchor_min": [0.0, 0.0], + "anchor_max": [1.0, 1.0], + "offset_min": [0.0, 0.0], + "offset_max": [0.0, 0.0] + }, + "custom_minimum_size": [0.0, 0.0], + "size_flags_horizontal": 1, + "size_flags_vertical": 1, + "size_flags_stretch_ratio": 1.0, + "container": "None" + }, + "metadata": { + "name": "根节点", + "description": "", + "layout_status": "NoProblem", + "components_status": "NoProblem", + "allow_llm_edit_layout": false, + "allow_llm_edit_component": false, + "source": "System" + }, + "components": [{ + "Text": { + "content": "标题", + "font": {"Bound": "missing-font"}, + "font_style": "Normal", + "font_sizing": {"Fixed": 14}, + "color": [255, 255, 255, 255], + "alignment": "UpperLeft", + "horizontal_overflow": "Wrap", + "vertical_overflow": "Truncate", + "line_spacing": 1.0 + } + }], + "children_display_mode": "Stack", + "children": [] + } + }], + "ui_design_images": { + "page": { + "metadata": { + "name": "主界面", + "description": "", + "role": "Page", + "slave_to": null + }, + "path": "assets/page.png", + "pixel_size": [1280.0, 720.0], + "pixels_per_unit": 1.0 + } + }, + "sprite_assets": {}, + "font_assets": {} + })) + .expect("deserialize state with missing font reference"); + + let error = save_ui_design_state_at(input(directory.path(), &asset_id, 0, state)) + .expect_err("missing Text font reference must be rejected"); + assert!(error.contains("Text 组件引用了不存在的字体素材")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/font.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/font.rs new file mode 100644 index 000000000..776f4b2e5 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/font.rs @@ -0,0 +1,213 @@ +use crate::ui_editor::utils::FontAssetId; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use ts_rs::TS; + +#[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 FontFormat { + TrueType, + OpenType, + Woff, + Woff2, +} + +impl FontFormat { + pub fn media_type(self) -> &'static str { + match self { + Self::TrueType => "font/ttf", + Self::OpenType => "font/otf", + Self::Woff => "font/woff", + Self::Woff2 => "font/woff2", + } + } + + pub fn extension(self) -> &'static str { + match self { + Self::TrueType => "ttf", + Self::OpenType => "otf", + Self::Woff => "woff", + Self::Woff2 => "woff2", + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct FontAssetMetadata { + pub(crate) family_name: String, + pub(crate) face_name: String, + pub(crate) weight: u16, + pub(crate) italic: bool, + pub(crate) format: FontFormat, + pub(crate) source_file_name: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct FontAsset { + pub(crate) asset_id: FontAssetId, + pub(crate) metadata: FontAssetMetadata, + pub(crate) path: String, + pub(crate) content_sha256: String, +} + +impl FontAsset { + pub(crate) fn from_verified_bytes( + asset_id: impl Into, + path: impl Into, + source_file_name: impl Into, + bytes: &[u8], + ) -> Result { + let asset_id = + FontAssetId::new(asset_id.into()).map_err(|_| "字体资源 ID 无效".to_string())?; + let path = path.into(); + if path.trim().is_empty() || path.trim() != path { + return Err("字体资源路径无效".to_string()); + } + let source_file_name = source_file_name.into(); + let format = detect_font_format(bytes)?; + let face = ttf_parser::Face::parse(bytes, 0) + .map_err(|_| format!("当前 Rust 解析器无法安全解析 {} 字体", format.extension()))?; + if ttf_parser::fonts_in_collection(bytes).is_some() { + return Err("暂不支持字体集合文件".to_string()); + } + let family_name = font_name( + &face, + &[ + ttf_parser::name_id::TYPOGRAPHIC_FAMILY, + ttf_parser::name_id::FAMILY, + ], + ) + .ok_or_else(|| "字体缺少可用的 family 名称".to_string())?; + let face_name = font_name( + &face, + &[ + ttf_parser::name_id::TYPOGRAPHIC_SUBFAMILY, + ttf_parser::name_id::SUBFAMILY, + ], + ) + .unwrap_or_else(|| "Regular".to_string()); + let content_sha256 = format!("{:x}", Sha256::digest(bytes)); + Ok(Self { + asset_id, + metadata: FontAssetMetadata { + family_name, + face_name, + weight: face.weight().to_number().clamp(1, 1000), + italic: face.is_italic() || face.is_oblique(), + format, + source_file_name, + }, + path, + content_sha256, + }) + } + + pub(crate) fn has_missing_glyphs(&self, bytes: &[u8], content: &str) -> Result { + let digest = format!("{:x}", Sha256::digest(bytes)); + if digest != self.content_sha256 { + return Err("字体文件内容与资源摘要不一致".to_string()); + } + let face = + ttf_parser::Face::parse(bytes, 0).map_err(|_| "字体文件无法安全解析".to_string())?; + Ok(content.chars().any(|character| { + !character.is_whitespace() + && !character.is_control() + && !matches!(character, '\u{fe0e}' | '\u{fe0f}') + && face.glyph_index(character).is_none() + })) + } +} + +fn detect_font_format(bytes: &[u8]) -> Result { + if bytes.starts_with(b"wOFF") { + return Ok(FontFormat::Woff); + } + if bytes.starts_with(b"wOF2") { + return Ok(FontFormat::Woff2); + } + if bytes.starts_with(b"OTTO") { + return Ok(FontFormat::OpenType); + } + if bytes.starts_with(&[0x00, 0x01, 0x00, 0x00]) || bytes.starts_with(b"true") { + return Ok(FontFormat::TrueType); + } + if bytes.starts_with(b"ttcf") { + return Err("暂不支持字体集合文件".to_string()); + } + Err("文件不是受支持的 TTF/OTF/WOFF/WOFF2 字体".to_string()) +} + +fn font_name(face: &ttf_parser::Face<'_>, ids: &[u16]) -> Option { + ids.iter().find_map(|id| { + face.names() + .into_iter() + .filter(|name| name.name_id == *id) + .find_map(|name| name.to_string()) + .map(|name| name.trim().to_string()) + .filter(|name| { + !name.is_empty() + && name.chars().count() <= 200 + && !name.chars().any(char::is_control) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_font() -> Vec { + std::fs::read( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../public/fusion-pixel.ttf"), + ) + .expect("read checked-in font fixture") + } + + #[test] + fn verified_font_asset_extracts_face_metadata_and_digest() { + let bytes = fixture_font(); + let font = FontAsset::from_verified_bytes( + "font-asset", + "assets/fonts/fusion-pixel.ttf", + "fusion-pixel.ttf", + &bytes, + ) + .expect("parse font fixture"); + + assert!(!font.metadata.family_name.is_empty()); + assert!(!font.metadata.face_name.is_empty()); + assert_eq!(font.metadata.format, FontFormat::TrueType); + assert_eq!(font.content_sha256.len(), 64); + assert!(!font + .has_missing_glyphs(&bytes, "ABC 123") + .expect("check common glyphs")); + assert!(font + .has_missing_glyphs(&bytes, "\u{10ffff}") + .expect("check missing glyph")); + } + + #[test] + fn verified_font_asset_rejects_collections_and_unparseable_web_fonts() { + assert_eq!( + FontAsset::from_verified_bytes( + "font-asset", + "assets/fonts/font.ttc", + "font.ttc", + b"ttcf\0\x01\0\0", + ) + .expect_err("reject collection"), + "暂不支持字体集合文件" + ); + assert!(FontAsset::from_verified_bytes( + "font-asset", + "assets/fonts/font.woff2", + "font.woff2", + b"wOF2invalid", + ) + .expect_err("reject unparseable web font") + .contains("无法安全解析")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/mod.rs new file mode 100644 index 000000000..3a90efe25 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/mod.rs @@ -0,0 +1,3 @@ +pub mod font; +pub mod sprite; +pub mod ui_design_image; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/sprite.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/sprite.rs new file mode 100644 index 000000000..498fb0437 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/sprite.rs @@ -0,0 +1,137 @@ +use crate::ui_editor::component::common::error::ComponentValueError; +use crate::ui_editor::utils::SpriteAssetId; +use nalgebra::Vector2; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use typed_floats::tf32::StrictlyPositiveFinite; + +pub type Color = [u8; 4]; + +pub(in crate::ui_editor) const WHITE: Color = [255, 255, 255, 255]; + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct SpriteBorder { + left: u32, + right: u32, + top: u32, + bottom: u32, +} + +impl SpriteBorder { + pub const NONE: Self = Self { + left: 0, + right: 0, + top: 0, + bottom: 0, + }; + + pub const fn new(left: u32, right: u32, top: u32, bottom: u32) -> Self { + Self { + left, + right, + top, + bottom, + } + } + + pub fn left(self) -> u32 { + self.left + } + + pub fn right(self) -> u32 { + self.right + } + + pub fn top(self) -> u32 { + self.top + } + + pub fn bottom(self) -> u32 { + self.bottom + } + + pub fn has_border(self) -> bool { + self != Self::NONE + } +} + +impl Default for SpriteBorder { + fn default() -> Self { + Self::NONE + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct SpriteAssetMetadata { + pub(crate) name: String, + pub(crate) asset_type: String, +} +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct SpriteAsset { + pub(crate) asset_id: SpriteAssetId, + pub(crate) metadata: SpriteAssetMetadata, + pub(crate) path: String, + #[ts(as = "[f32; 2]")] + pub(crate) pixel_size: Vector2, + #[ts(as = "f32")] + pixels_per_unit: StrictlyPositiveFinite, + border: SpriteBorder, +} + +impl SpriteAsset { + pub fn new( + asset_id: SpriteAssetId, + pixel_size: Vector2, + pixels_per_unit: StrictlyPositiveFinite, + border: SpriteBorder, + ) -> Result { + if !pixel_size.x.is_finite() || !pixel_size.y.is_finite() { + return Err(ComponentValueError::NonFinite); + } + if pixel_size.x <= 0.0 || pixel_size.y <= 0.0 { + return Err(ComponentValueError::MustBePositive); + } + let available_width = f64::from(pixel_size.x.floor()); + let available_height = f64::from(pixel_size.y.floor()); + if border.has_border() + && (f64::from(border.left) + f64::from(border.right) + 1.0 > available_width + || f64::from(border.top) + f64::from(border.bottom) + 1.0 > available_height) + { + return Err(ComponentValueError::SpriteBorderExceedsSprite); + } + Ok(Self { + asset_id, + metadata: SpriteAssetMetadata { + name: String::new(), + asset_type: String::new(), + }, + path: String::new(), + pixel_size, + pixels_per_unit, + border, + }) + } + + pub fn asset_id(&self) -> &SpriteAssetId { + &self.asset_id + } + + pub fn pixel_size(&self) -> Vector2 { + self.pixel_size + } + + pub fn pixels_per_unit(&self) -> StrictlyPositiveFinite { + self.pixels_per_unit + } + + pub fn border(&self) -> SpriteBorder { + self.border + } + + pub fn native_size(&self) -> Vector2 { + self.pixel_size / self.pixels_per_unit.get() + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/ui_design_image.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/ui_design_image.rs new file mode 100644 index 000000000..d1f3b7714 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/ui_design_image.rs @@ -0,0 +1,48 @@ +use crate::ui_editor::utils::UIDesignImageId; +use nalgebra::Vector2; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter}; +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 UIDesignImageRole { + Page, + Section, + Modal, + Drawer, + Popover, + State, + Scrolled, + Detail, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct UIDesignImage { + pub(crate) metadata: UIDesignImageMetadata, + pub(crate) path: String, + #[ts(as = "[f32; 2]")] + pub(crate) pixel_size: Vector2, + #[ts(as = "f32")] + pub(crate) pixels_per_unit: StrictlyPositiveFinite, +} +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct UIDesignImageMetadata { + pub(crate) name: String, + pub(crate) description: String, + pub(crate) role: Option, + pub(crate) slave_to: Option, +} +impl Display for UIDesignImageMetadata { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "name: {}, description: {}, role: {:?}, slave_to: {:?}", + self.name, self.description, self.role, self.slave_to + ) + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/state/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/state/mod.rs new file mode 100644 index 000000000..7c6c46a7a --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/state/mod.rs @@ -0,0 +1,24 @@ +use crate::ui_editor::layout::node::Node; +use crate::ui_editor::resource::font::FontAsset; +use crate::ui_editor::resource::sprite::SpriteAsset; +use crate::ui_editor::resource::ui_design_image::UIDesignImage; +use crate::ui_editor::utils::{FontAssetId, SpriteAssetId, UIDesignImageId}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use ts_rs::TS; + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct UITree { + pub src_ui_design: UIDesignImageId, + pub root: Node, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct State { + pub ui_trees: Vec, + pub ui_design_images: HashMap, + pub sprite_assets: HashMap, + pub font_assets: HashMap, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/utils.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/utils.rs new file mode 100644 index 000000000..de48e841b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/utils.rs @@ -0,0 +1,45 @@ +use crate::ui_editor::component::common::error::ComponentValueError; + +macro_rules! define_id { + ($name:ident) => { + #[derive( + Clone, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, + serde::Deserialize, + serde::Serialize, + schemars::JsonSchema, + ts_rs::TS, + )] + #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] + pub struct $name(String); + + impl $name { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() || value.trim() != value { + return Err(ComponentValueError::InvalidAssetId); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl AsRef for $name { + fn as_ref(&self) -> &str { + self.as_str() + } + } + }; +} +define_id!(FontAssetId); +define_id!(SpriteAssetId); +define_id!(UIDesignImageId); +define_id!(NodeId); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 7bbcc3fe5..aa1b897f0 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -11209,8 +11209,8 @@ export function App({ ); const projectSupervisorHasConversationControls = Boolean( projectSupervisorRuntime?.pendingToolAction || - projectSupervisorRuntime?.userInputRequest || - projectSupervisorNeedsUserInput, + projectSupervisorRuntime?.userInputRequest || + projectSupervisorNeedsUserInput, ); const visibleAgentConversationMessages = latestVisibleItems( agentConversationMessages, diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index caa7ee2bf..498b9bb23 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -366,11 +366,7 @@ export interface AgentRuntimeResult { } export type AgentRuntimeResponseStreamStatus = - | 'streaming' - | 'ready' - | 'committed' - | 'discarded' - | 'failed'; + 'streaming' | 'ready' | 'committed' | 'discarded' | 'failed'; export interface AgentRuntimeResponseStream { schemaVersion: string; @@ -491,22 +487,13 @@ export interface GameCreatorAgentLlmConfigStatus { } export type GameCreatorLlmApiKind = - | 'openai_responses' - | 'openai_chat' - | 'anthropic'; + 'openai_responses' | 'openai_chat' | 'anthropic'; export type GameCreatorAgentMode = - | 'codex_app_server' - | 'codex_cli' - | 'provider'; + 'codex_app_server' | 'codex_cli' | 'provider'; export type RuntimeLlmProviderPresetId = - | 'custom' - | 'openai' - | 'deepseek' - | 'anthropic' - | 'ark'; + 'custom' | 'openai' | 'deepseek' | 'anthropic' | 'ark'; export type RuntimeAgentLlmProviderPresetId = - | 'inherit' - | RuntimeLlmProviderPresetId; + 'inherit' | RuntimeLlmProviderPresetId; export interface GameCreatorLlmConfig { apiKey: string; diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/FontImporterPreview.tsx b/apps/ai-game-creator-shell/src/components/AssetImporter/FontImporterPreview.tsx new file mode 100644 index 000000000..292166312 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/FontImporterPreview.tsx @@ -0,0 +1,82 @@ +import { invoke } from '@tauri-apps/api/core'; +import { useEffect, useRef, useState } from 'react'; + +import { FontSamplePreview } from '../../features/ui-editor/components/FontSamplePreview'; +import type { FontAsset } from '../../features/ui-editor/types/FontAsset'; +import { + type LoadedUiEditorFontFace, + loadUiEditorFontFace, +} from '../../features/ui-editor/useUiEditorFontFaces'; +import type { AssetImporterPreviewProps } from './utils'; + +export function FontImporterPreview({ + projectPath, + selected, +}: AssetImporterPreviewProps) { + const [fontFamily, setFontFamily] = useState(null); + const [font, setFont] = useState(null); + const [error, setError] = useState(null); + const loadedRef = useRef<{ face: FontFace; url: string } | null>(null); + + useEffect(() => { + let cancelled = false; + let loaded: LoadedUiEditorFontFace | null = null; + if (loadedRef.current) { + document.fonts?.delete(loadedRef.current.face); + URL.revokeObjectURL(loadedRef.current.url); + loadedRef.current = null; + } + setFontFamily(null); + setFont(null); + setError(null); + if (!selected?.asset || selected.isDirectory) return; + void (async () => { + try { + const [font] = await invoke( + 'prepare_ui_editor_project_fonts', + { projectPath, assets: [selected.asset] }, + ); + if (!font) throw new Error('未取得字体元数据'); + loaded = await loadUiEditorFontFace(projectPath, font); + if (cancelled) { + URL.revokeObjectURL(loaded.url); + return; + } + document.fonts.add(loaded.face); + loadedRef.current = loaded; + setFont(font); + setFontFamily(loaded.cssFamily); + } catch (cause) { + console.error('[ui-editor-font-preview] failed', { + assetId: selected.asset?.id, + relativePath: selected.asset?.localPath, + cause, + }); + if (!cancelled) setError('字体样张加载失败'); + } + })(); + return () => { + cancelled = true; + if (loadedRef.current === loaded && loaded) { + document.fonts?.delete(loaded.face); + URL.revokeObjectURL(loaded.url); + loadedRef.current = null; + } + }; + }, [projectPath, selected]); + + if (!fontFamily) { + return ( + + {error ?? selected?.name ?? '选择一个字体文件'} + + ); + } + return ( + + ); +} diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/ImageImporterPreview.tsx b/apps/ai-game-creator-shell/src/components/AssetImporter/ImageImporterPreview.tsx new file mode 100644 index 000000000..7b3e2150a --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/ImageImporterPreview.tsx @@ -0,0 +1,72 @@ +import { invoke } from '@tauri-apps/api/core'; +import { useEffect, useRef, useState } from 'react'; + +import { SpriteImagePreview } from '../../features/ui-editor/components/SpriteImagePreview'; +import { resolveClientAssetReadUrl } from '../../services/clientApi'; +import { + cancelLocalProjectResourcePreviewScope, + createProjectResourcePreviewRequestId, + createProjectResourcePreviewScopeId, +} from '../../services/projectResourcePreviewTransport'; +import type { AssetImporterPreviewProps } from './utils'; + +type ImagePreviewResponse = { dataUrl: string }; + +export function ImageImporterPreview({ + projectPath, + selected, +}: AssetImporterPreviewProps) { + const [previewUrl, setPreviewUrl] = useState(null); + const scopeIdRef = useRef(createProjectResourcePreviewScopeId()); + + useEffect(() => { + const previousScopeId = scopeIdRef.current; + scopeIdRef.current = createProjectResourcePreviewScopeId(); + cancelLocalProjectResourcePreviewScope(previousScopeId); + return () => cancelLocalProjectResourcePreviewScope(scopeIdRef.current); + }, [projectPath]); + + useEffect(() => { + if (!selected || selected.isDirectory) { + setPreviewUrl(null); + return; + } + if (selected.previewUrl) { + setPreviewUrl(selected.previewUrl); + return; + } + if (selected.source === 'remote' && selected.remoteObjectKey) { + void resolveClientAssetReadUrl(selected.remoteObjectKey) + .then(setPreviewUrl) + .catch(() => setPreviewUrl(null)); + return; + } + if ( + selected.source === 'remote' && + /^https?:\/\//iu.test(selected.asset?.localPath ?? '') + ) { + setPreviewUrl(selected.asset?.localPath ?? null); + return; + } + void invoke('read_local_project_image_preview', { + projectPath, + relativePath: selected.asset?.localPath, + scopeId: scopeIdRef.current, + requestId: createProjectResourcePreviewRequestId(), + }) + .then((value) => setPreviewUrl(value.dataUrl)) + .catch(() => setPreviewUrl(null)); + }, [projectPath, selected]); + + return previewUrl ? ( + + ) : ( + + {selected?.name ?? '选择一个文件'} + + ); +} diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx b/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx new file mode 100644 index 000000000..74de6842d --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx @@ -0,0 +1,442 @@ +import '@cubone/react-file-manager/dist/style.css'; + +import { FileManager } from '@cubone/react-file-manager'; +import { invoke } from '@tauri-apps/api/core'; +import { open as openNativeFileDialog } from '@tauri-apps/plugin-dialog'; +import { X } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { + loadEditorAssetLibrary, + resolveClientAssetReadUrl, +} from '../../services/clientApi'; +import { ThemedModal } from '../modal/ThemedModal'; +import { + type AssetImporterSettings, + buildProjectFiles, + buildRemoteFolderFiles, + buildRootFiles, + type ImportedAsset, + type ManagerFile, + type ManifestAsset, + PROJECT_ASSETS_PATH, + REMOTE_ASSETS_PATH, +} from './utils'; + +export type { ImportedAsset } from './utils'; +export type { + AssetImporterSettings, + ImportPolicy, + ImportRequirements, + LocalAssetCandidate, + RemoteAssetCandidate, +} from './utils'; + +// FileManager treats an initial `/` as its Home location, then resolves its +// visible root entries from the empty internal path. Keep this value aligned +// with the image importer behaviour that predates the shared component. +const ROOT_PATH = '/'; + +export type AssetImporterProps = { + open: boolean; + onClose: () => void; + onImport: (assets: ImportedAsset[]) => void; + projectPath: string; + settings: AssetImporterSettings; +}; + +type LocalProjectFile = { path: string }; + +type LocalManifestResponse = { + assets?: ManifestAsset[]; + manifest?: { assets?: ManifestAsset[] }; +}; + +type LocalProjectFilesResponse = { files?: LocalProjectFile[] }; + +type ImportAssetResponse = { + id: string; + localPath: string; + assetKind?: string | null; +}; + +type GenericImportResponse = { assets: ImportAssetResponse[] }; + +export function AssetImporter({ + open, + onClose, + onImport, + projectPath, + settings, +}: AssetImporterProps) { + const maxItems = settings.local.requirements.maxItems; + const Preview = settings.preview; + const maxFileSize = settings.local.requirements.maxFileSizeBytes ?? 0; + const [files, setFiles] = useState([]); + const [selected, setSelected] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [currentPath, setCurrentPath] = useState(ROOT_PATH); + const currentPathRef = useRef(ROOT_PATH); + const remoteLoadedRef = useRef(false); + + const loadLocal = useCallback(async () => { + setError(null); + await invoke('init_local_game_project', { + projectPath, + projectId: 'ui-editor-test', + name: 'UI Editor Test', + }); + const [manifest, listed] = await Promise.all([ + invoke('get_local_game_manifest', { + projectPath, + commandId: 'asset.list', + }), + invoke('list_local_project_files', { + projectPath, + }), + ]); + const registered = (manifest.assets ?? + manifest.manifest?.assets ?? + []) as ManifestAsset[]; + const registeredPaths = new Set(registered.map((asset) => asset.localPath)); + const filesFromDisk = (listed.files ?? []).filter((file) => + registeredPaths.has(file.path), + ); + const byPath = new Map(registered.map((asset) => [asset.localPath, asset])); + return buildProjectFiles( + filesFromDisk + .map((file) => byPath.get(file.path)) + .filter(Boolean) as ManifestAsset[], + settings.local.typeFilter, + ); + }, [projectPath, settings.local.typeFilter]); + + const replaceProjectFiles = useCallback( + (projectFiles: ManagerFile[]) => { + setFiles((current) => [ + ...buildRootFiles(settings), + ...projectFiles, + ...(settings.remote + ? current.filter((file) => file.source === 'remote') + : []), + ]); + }, + [settings], + ); + + const replaceRemoteFiles = useCallback( + (remoteFiles: ManagerFile[]) => { + setFiles((current) => [ + ...buildRootFiles(settings), + ...current.filter((file) => file.source === 'local'), + ...remoteFiles, + ]); + remoteLoadedRef.current = true; + }, + [settings], + ); + + const loadRemote = useCallback(async () => { + const library = await loadEditorAssetLibrary(); + if (!settings.remote) return []; + const remoteFiles = buildRemoteFolderFiles( + library, + settings.remote.typeFilter, + ); + replaceRemoteFiles(remoteFiles); + return remoteFiles; + }, [replaceRemoteFiles, settings.remote]); + + const refresh = useCallback( + async (path = currentPathRef.current) => { + setLoading(true); + setError(null); + try { + const shouldRefreshProject = + path === ROOT_PATH || path.startsWith(PROJECT_ASSETS_PATH); + const shouldRefreshRemote = + settings.remote && + (path === ROOT_PATH || path.startsWith(REMOTE_ASSETS_PATH)); + const projectResult = shouldRefreshProject + ? await Promise.allSettled([loadLocal()]).then(([result]) => result) + : null; + if (projectResult?.status === 'fulfilled' && projectResult.value) { + replaceProjectFiles(projectResult.value); + } + let remoteResult: PromiseSettledResult | null = null; + if ( + shouldRefreshRemote && + (path !== ROOT_PATH || remoteLoadedRef.current) + ) { + remoteResult = await Promise.allSettled([loadRemote()]).then( + ([result]) => result, + ); + } + if ( + projectResult?.status === 'rejected' || + remoteResult?.status === 'rejected' + ) { + const cause = + projectResult?.status === 'rejected' + ? projectResult.reason + : remoteResult?.status === 'rejected' + ? remoteResult.reason + : null; + setError( + cause instanceof Error + ? cause.message + : '部分素材来源加载失败,仍可继续使用其他来源。', + ); + } + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setLoading(false); + } + }, + [loadLocal, loadRemote, replaceProjectFiles, settings.remote], + ); + + useEffect(() => { + if (!open) return; + currentPathRef.current = ROOT_PATH; + remoteLoadedRef.current = false; + setCurrentPath(ROOT_PATH); + setFiles(buildRootFiles(settings)); + setSelected([]); + void refresh(ROOT_PATH); + }, [open, refresh, settings]); + + const refreshProjectAfterImportFailure = async (cause: unknown) => { + const importError = cause instanceof Error ? cause.message : String(cause); + await refresh(PROJECT_ASSETS_PATH); + setError(importError); + }; + + const pickLocal = async () => { + setError(null); + setLoading(true); + try { + const paths = await openNativeFileDialog({ + title: settings.local.fileDialog.title, + multiple: true, + directory: false, + filters: settings.local.fileDialog.filters.map((filter) => ({ + name: filter.name, + extensions: [...filter.extensions], + })), + }); + const sourcePaths = Array.isArray(paths) ? paths : paths ? [paths] : []; + if (!sourcePaths.length) return; + if (sourcePaths.length > maxItems) { + throw new Error(`最多选择 ${maxItems} 个文件`); + } + const result = await invoke( + 'import_ui_editor_assets', + { + request: { + projectPath, + source: 'local', + localSourcePaths: sourcePaths, + localPolicy: settings.local.localPolicy, + localRequirements: settings.local.requirements, + }, + }, + ); + onImport( + result.assets.map((asset) => ({ + id: asset.id, + localPath: asset.localPath, + assetKind: asset.assetKind ?? null, + })), + ); + onClose(); + } catch (cause) { + await refreshProjectAfterImportFailure(cause); + } finally { + setLoading(false); + } + }; + const confirm = async () => { + const chosen = selected.filter((item) => !item.isDirectory && item.asset); + if (!chosen.length || chosen.length > maxItems) return; + try { + setLoading(true); + const remoteChosen = chosen.filter((entry) => entry.source === 'remote'); + const localChosen = chosen.filter((entry) => entry.source !== 'remote'); + const imported: ImportedAsset[] = localChosen.map( + (entry) => entry.asset!, + ); + if (remoteChosen.length) { + const remoteResult = await invoke( + 'import_ui_editor_assets', + { + request: { + projectPath, + source: 'remote', + remoteAssets: await Promise.all( + remoteChosen.map(async (item) => { + const asset = item.asset!; + const downloadUrl = asset.localPath.startsWith('http') + ? asset.localPath + : await resolveClientAssetReadUrl(asset.localPath); + return { + assetId: asset.id, + objectKey: asset.localPath, + downloadUrl, + }; + }), + ), + remotePolicy: settings.remote!.remotePolicy, + remoteRequirements: settings.remote!.requirements, + }, + }, + ); + imported.push( + ...remoteResult.assets.map((asset, index) => ({ + id: asset.id, + localPath: asset.localPath, + assetKind: + asset.assetKind ?? remoteChosen[index]?.asset?.assetKind ?? null, + })), + ); + } + onImport(imported); + onClose(); + } catch (cause) { + await refreshProjectAfterImportFailure(cause); + } finally { + setLoading(false); + } + }; + const handleComputerImport = () => { + void pickLocal(); + }; + const handleFolderChange = (path: string) => { + const nextPath = path || ROOT_PATH; + currentPathRef.current = nextPath; + setCurrentPath(nextPath); + if ( + settings.remote && + nextPath.startsWith(REMOTE_ASSETS_PATH) && + !remoteLoadedRef.current + ) { + setLoading(true); + setError(null); + void loadRemote() + .catch((cause) => + setError(cause instanceof Error ? cause.message : String(cause)), + ) + .finally(() => setLoading(false)); + } + }; + + return ( + +
+

+ {settings.icon} + {settings.title} +

+ +
+
+
+ {error ? ( +
+ {error} +
+ ) : null} +
+ filter.extensions) + .map((value) => `.${value}`) + .join(',')} + onSelectionChange={(items: ManagerFile[]) => { + const assetItems = items + .filter((item) => !item.isDirectory) + .slice(-maxItems); + setSelected(assetItems); + }} + onFolderChange={handleFolderChange} + onRefresh={() => void refresh()} + /> +
+
+ +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/react-file-manager.d.ts b/apps/ai-game-creator-shell/src/components/AssetImporter/react-file-manager.d.ts new file mode 100644 index 000000000..0cbb9f777 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/react-file-manager.d.ts @@ -0,0 +1,35 @@ +declare module '@cubone/react-file-manager' { + import type { CSSProperties, ReactElement, ReactNode } from 'react'; + + export type FileManagerFile = { + name: string; + isDirectory: boolean; + path: string; + updatedAt?: string; + size?: number; + }; + + export type FileManagerProps< + TFile extends FileManagerFile = FileManagerFile, + > = { + files: TFile[]; + className?: string; + layout?: 'list' | 'grid'; + initialPath?: string; + enableFilePreview?: boolean; + acceptedFileTypes?: string; + permissions?: Record; + onSelectionChange?: (files: TFile[]) => void; + filePreviewComponent?: (file: TFile) => ReactNode; + onFileOpen?: (file: TFile) => void; + onFolderChange?: (path: string) => void; + onRefresh?: () => void; + height?: string | number; + width?: string | number; + style?: CSSProperties; + }; + + export function FileManager( + props: FileManagerProps, + ): ReactElement; +} diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/settings.ts b/apps/ai-game-creator-shell/src/components/AssetImporter/settings.ts new file mode 100644 index 000000000..122b5a02f --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/settings.ts @@ -0,0 +1,122 @@ +import { Image as ImageIcon, Type } from 'lucide-react'; +import { createElement } from 'react'; + +import { FontImporterPreview } from './FontImporterPreview'; +import { ImageImporterPreview } from './ImageImporterPreview'; +import type { + AssetImporterSettings, + ImportRequirements, + LocalAssetCandidate, + RemoteAssetCandidate, +} from './utils'; + +const IMAGE_MEDIA_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const; +const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'webp'] as const; +const FONT_MEDIA_TYPES = [ + 'font/ttf', + 'font/otf', + 'font/woff', + 'font/woff2', +] as const; +const FONT_EXTENSIONS = ['ttf', 'otf', 'woff', 'woff2'] as const; + +function imageLocalTypeFilter(asset: LocalAssetCandidate) { + return ( + IMAGE_MEDIA_TYPES.includes( + asset.mediaType.toLowerCase() as (typeof IMAGE_MEDIA_TYPES)[number], + ) || /\.(png|jpe?g|webp)$/iu.test(asset.localPath) + ); +} + +function imageRemoteTypeFilter(asset: RemoteAssetCandidate) { + return ( + asset.assetKind === null || + !/^(video|audio|sound-effect|background-music|character-animation)/iu.test( + asset.assetKind, + ) + ); +} + +function fontLocalTypeFilter(asset: LocalAssetCandidate) { + return ( + FONT_MEDIA_TYPES.includes( + asset.mediaType.toLowerCase() as (typeof FONT_MEDIA_TYPES)[number], + ) || /\.(ttf|otf|woff2?)$/iu.test(asset.localPath) + ); +} + +const imageRequirements: ImportRequirements = { + maxItems: 100, + maxFileSizeBytes: 20 * 1024 * 1024, +}; + +const fontRequirements: ImportRequirements = { + maxItems: 64, + maxFileSizeBytes: 8 * 1024 * 1024, + maxTotalSizeBytes: 32 * 1024 * 1024, +}; + +function imageImporterSettings( + requirements: ImportRequirements, +): AssetImporterSettings { + return { + title: '导入图片素材', + ariaLabel: '导入图片素材', + icon: createElement(ImageIcon, { size: 16 }), + preview: ImageImporterPreview, + local: { + label: '本地项目', + typeFilter: imageLocalTypeFilter, + fileDialog: { + title: '选择图片素材', + filters: [{ name: 'Images', extensions: [...IMAGE_EXTENSIONS] }], + }, + requirements, + localPolicy: { + destination: 'assets/uploads', + acceptedMediaTypes: IMAGE_MEDIA_TYPES, + acceptedExtensions: IMAGE_EXTENSIONS, + }, + }, + remote: { + label: '云端素材库', + typeFilter: imageRemoteTypeFilter, + requirements, + remotePolicy: { + destination: 'assets/uploads', + acceptedMediaTypes: IMAGE_MEDIA_TYPES, + acceptedExtensions: IMAGE_EXTENSIONS, + }, + }, + }; +} + +export const DESIGN_IMAGE_IMPORTER_SETTINGS: AssetImporterSettings = + imageImporterSettings({ + maxItems: 4, + maxFileSizeBytes: 5 * 1024 * 1024, + }); + +export const SPRITE_IMPORTER_SETTINGS: AssetImporterSettings = + imageImporterSettings(imageRequirements); + +export const FONT_IMPORTER_SETTINGS: AssetImporterSettings = { + title: '导入字体', + ariaLabel: '导入字体', + icon: createElement(Type, { size: 16 }), + preview: FontImporterPreview, + local: { + label: '本地项目', + typeFilter: fontLocalTypeFilter, + fileDialog: { + title: '选择字体文件', + filters: [{ name: 'Fonts', extensions: [...FONT_EXTENSIONS] }], + }, + requirements: fontRequirements, + localPolicy: { + destination: 'assets/fonts', + acceptedMediaTypes: FONT_MEDIA_TYPES, + acceptedExtensions: FONT_EXTENSIONS, + }, + }, +}; diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts b/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts new file mode 100644 index 000000000..b2f363179 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts @@ -0,0 +1,271 @@ +import type { FileManagerFile } from '@cubone/react-file-manager'; +import type { ComponentType, ReactNode } from 'react'; + +export type ImportedAsset = { + id: string; + localPath: string; + assetKind: string | null; +}; + +export type AssetSource = 'local' | 'remote'; + +export type LocalAssetCandidate = { + id: string; + name: string; + localPath: string; + mediaType: string; +}; + +export type RemoteAssetCandidate = { + assetId: string; + name: string; + objectKey: string; + assetKind: string | null; + sizeBytes: number; + previewUrl?: string; +}; + +export type ImportRequirements = { + maxItems: number; + maxFileSizeBytes?: number; + maxTotalSizeBytes?: number; +}; + +export type ImportPolicy = { + destination: string; + acceptedMediaTypes: readonly string[]; + acceptedExtensions: readonly string[]; +}; + +export type NativeDialogSettings = { + title: string; + filters: ReadonlyArray<{ + name: string; + extensions: readonly string[]; + }>; +}; + +export type LocalImporterSettings = { + label: string; + typeFilter: (asset: LocalAssetCandidate) => boolean; + fileDialog: NativeDialogSettings; + requirements: ImportRequirements; + localPolicy: ImportPolicy; +}; + +export type RemoteImporterSettings = { + label: string; + typeFilter: (asset: RemoteAssetCandidate) => boolean; + requirements: ImportRequirements; + remotePolicy: ImportPolicy; +}; + +export type AssetImporterSettings = { + title: string; + ariaLabel: string; + icon?: ReactNode; + local: LocalImporterSettings; + remote?: RemoteImporterSettings; + preview: ComponentType; +}; + +export type ManagerFile = FileManagerFile & { + previewUrl?: string; + source?: 'local' | 'remote'; + remoteObjectKey?: string; + asset?: ImportedAsset; +}; + +export type AssetImporterPreviewProps = { + projectPath: string; + selected: ManagerFile | undefined; +}; + +export type ManifestAsset = { + id: string; + localPath: string; + mediaType: string; + source?: { kind?: string }; +}; + +type RemoteFolder = { folderId?: string; label?: unknown }; +type RemoteAsset = { + folderId?: string; + assetKind?: string | null; + label?: unknown; + assetId?: string; + objectKey?: string; + imageSrc?: string; + size?: number; + previewUrl?: string; +}; +type RemoteLibrary = { folders?: RemoteFolder[]; assets?: RemoteAsset[] }; + +export const PROJECT_ASSETS_PATH = '/本地项目'; +export const REMOTE_ASSETS_PATH = '/云端素材库'; +function imagePath(path: string) { + return path.replace(/^\/+/, '').replaceAll('\\', '/'); +} + +function managerPathFromLocalPath(localPath: string) { + const path = imagePath(localPath); + return `${PROJECT_ASSETS_PATH}/${path.split('/').slice(1).join('/')}`; +} + +function safeManagerName(value: unknown, fallback: string) { + const normalized = typeof value === 'string' ? value.trim() : ''; + return (normalized || fallback).replaceAll('/', '/').replaceAll('\\', '\'); +} + +export function buildProjectFiles( + assets: ManifestAsset[], + typeFilter: (asset: LocalAssetCandidate) => boolean, +): ManagerFile[] { + const result: ManagerFile[] = []; + const seen = new Set(); + for (const asset of assets) { + const path = imagePath(asset.localPath); + if (!path.startsWith('assets/')) continue; + const candidate: LocalAssetCandidate = { + id: asset.id, + name: path.split('/').at(-1) ?? path, + localPath: asset.localPath, + mediaType: asset.mediaType, + }; + if (!typeFilter(candidate)) continue; + const parts = path.split('/'); + for (let index = 1; index < parts.length - 1; index += 1) { + const folderPath = `${PROJECT_ASSETS_PATH}/${parts.slice(1, index + 1).join('/')}`; + if (seen.has(folderPath)) continue; + seen.add(folderPath); + result.push({ + name: parts[index] ?? folderPath, + isDirectory: true, + path: folderPath, + source: 'local', + }); + } + result.push({ + name: parts.at(-1) ?? path, + isDirectory: false, + path: managerPathFromLocalPath(asset.localPath), + source: 'local', + asset: { id: asset.id, localPath: asset.localPath, assetKind: null }, + }); + } + return result; +} + +export function buildRemoteFolderFiles( + payload: unknown, + typeFilter: (asset: RemoteAssetCandidate) => boolean, +): ManagerFile[] { + const root = + payload && typeof payload === 'object' + ? (payload as Record) + : {}; + const candidate = root.library ?? root.data ?? payload; + const library: RemoteLibrary = + candidate && typeof candidate === 'object' + ? (candidate as RemoteLibrary) + : {}; + const folders = Array.isArray(library.folders) + ? library.folders.filter( + (folder): folder is RemoteFolder => + folder !== null && typeof folder === 'object', + ) + : []; + const assets = Array.isArray(library.assets) + ? library.assets.filter( + (asset): asset is RemoteAsset => + asset !== null && typeof asset === 'object', + ) + : []; + const result: ManagerFile[] = []; + const folderIds = new Set(folders.map((folder) => folder.folderId)); + const normalizedFolders = [...folders]; + if (assets.some((asset) => !folderIds.has(asset.folderId))) + normalizedFolders.push({ folderId: '__uncategorized__', label: '未分类' }); + const usedFolderNames = new Set(); + for (const folder of normalizedFolders) { + const baseFolderName = safeManagerName( + folder.label, + folder.folderId || '未分类', + ); + const folderName = usedFolderNames.has(baseFolderName) + ? `${baseFolderName} (${safeManagerName(folder.folderId, String(usedFolderNames.size + 1))})` + : baseFolderName; + usedFolderNames.add(folderName); + const folderPath = `${REMOTE_ASSETS_PATH}/${folderName}`; + result.push({ + name: folderName, + isDirectory: true, + path: folderPath, + source: 'remote', + }); + const folderAssets = assets.filter((item) => + folder.folderId === '__uncategorized__' + ? !folderIds.has(item.folderId) + : item.folderId === folder.folderId, + ); + const usedNames = new Set(); + for (const asset of folderAssets) { + const baseName = safeManagerName( + asset.label, + asset.assetId || '图片素材', + ); + const fileName = usedNames.has(baseName) + ? `${baseName} (${safeManagerName(asset.assetId, String(usedNames.size + 1))})` + : baseName; + usedNames.add(fileName); + const localPath = + asset.objectKey || asset.imageSrc || asset.assetId || fileName; + const candidate: RemoteAssetCandidate = { + assetId: asset.assetId || asset.objectKey || asset.imageSrc || fileName, + name: baseName, + objectKey: asset.objectKey || asset.imageSrc || fileName, + assetKind: asset.assetKind ?? null, + sizeBytes: asset.size ?? 0, + previewUrl: asset.previewUrl, + }; + if (!typeFilter(candidate)) continue; + result.push({ + name: fileName, + isDirectory: false, + path: `${folderPath}/${fileName}`, + size: asset.size, + previewUrl: asset.previewUrl, + source: 'remote', + remoteObjectKey: asset.objectKey, + asset: { + id: + asset.assetId || + asset.objectKey || + asset.imageSrc || + 'platform-image', + localPath, + assetKind: asset.assetKind ?? null, + }, + }); + } + } + return result; +} + +export function buildRootFiles(settings: AssetImporterSettings): ManagerFile[] { + const roots: ManagerFile[] = [ + { + name: settings.local.label, + isDirectory: true, + path: PROJECT_ASSETS_PATH, + }, + ]; + if (settings.remote) { + roots.push({ + name: settings.remote.label, + isDirectory: true, + path: REMOTE_ASSETS_PATH, + }); + } + return roots; +} diff --git a/apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx b/apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx new file mode 100644 index 000000000..67af6cfbc --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx @@ -0,0 +1,118 @@ +import { FocusTrap } from 'focus-trap-react'; +import { type CSSProperties, type ReactNode, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; + +type ThemedModalTheme = 'light' | 'dark'; + +export type ThemedModalProps = { + open: boolean; + ariaLabel: string; + children: ReactNode; + onClose: () => void; + theme?: ThemedModalTheme; + closeOnBackdrop?: boolean; + closeOnEscape?: boolean; + overlayClassName?: string; + panelClassName?: string; + panelStyle?: CSSProperties; +}; + +function joinClassNames( + ...classNames: Array +) { + return classNames.filter(Boolean).join(' '); +} + +export function ThemedModal({ + open, + ariaLabel, + children, + onClose, + theme = 'light', + closeOnBackdrop = true, + closeOnEscape = true, + overlayClassName, + panelClassName, + panelStyle, +}: ThemedModalProps) { + const backdropPointerSequenceRef = useRef(null); + const panelRef = useRef(null); + + useEffect(() => { + if (!open || !closeOnEscape) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !event.defaultPrevented) onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [closeOnEscape, onClose, open]); + + if (!open || typeof document === 'undefined') return null; + + return createPortal( + panelRef.current!, + returnFocusOnDeactivate: true, + }} + > +
{ + backdropPointerSequenceRef.current = + event.target === event.currentTarget; + }} + onPointerUpCapture={(event) => { + backdropPointerSequenceRef.current = + backdropPointerSequenceRef.current === true && + event.target === event.currentTarget; + }} + onPointerCancelCapture={() => { + backdropPointerSequenceRef.current = false; + }} + onClick={(event) => { + const stayedOnBackdrop = backdropPointerSequenceRef.current !== false; + backdropPointerSequenceRef.current = null; + if ( + closeOnBackdrop && + stayedOnBackdrop && + event.target === event.currentTarget + ) { + onClose(); + } + }} + > +
event.stopPropagation()} + onKeyDown={(event) => { + if (closeOnEscape && event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + onClose(); + } + }} + > + {children} +
+
+
, + document.body, + ); +} diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 9fbbb9059..f303db629 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -237,9 +237,9 @@ export function sameAgentRuntimeRun( ) { return Boolean( previous && - previous.agentId === state.agentId && - previous.sessionId === state.sessionId && - previous.runId === state.runId, + previous.agentId === state.agentId && + previous.sessionId === state.sessionId && + previous.runId === state.runId, ); } @@ -642,12 +642,12 @@ export function conversationContainsProjectSupervisorResponseStream( ) { return Boolean( stream?.accumulatedText.trim() && - messages.some( - (message) => - message.role === 'assistant' && - message.content === stream.accumulatedText && - message.updatedAt >= stream.startedAt, - ), + messages.some( + (message) => + message.role === 'assistant' && + message.content === stream.accumulatedText && + message.updatedAt >= stream.startedAt, + ), ); } @@ -833,8 +833,8 @@ export function agentRuntimeNeedsUserInput( ) { return Boolean( runtime?.userInputRequest || - runtime?.status === 'waiting-for-user-input' || - runtime?.phase === 'waiting-for-user-input', + runtime?.status === 'waiting-for-user-input' || + runtime?.phase === 'waiting-for-user-input', ); } @@ -1787,16 +1787,14 @@ function directPlatformFailureDetail(message: string) { return null; } const lower = trimmed.toLowerCase(); - if ( - !( - lower.includes('陶泥儿美术包生成失败') || - lower.includes('平台图片生成任务失败') || - lower.includes('external editor') || - lower.includes('game-chat 图集') || - lower.includes('透明美术图集') || - lower.includes('图集切片') - ) - ) { + if (!( + lower.includes('陶泥儿美术包生成失败') || + lower.includes('平台图片生成任务失败') || + lower.includes('external editor') || + lower.includes('game-chat 图集') || + lower.includes('透明美术图集') || + lower.includes('图集切片') + )) { return null; } if ( @@ -1847,14 +1845,12 @@ function directCodexFailureDetail(message: string) { function directRuntimeFailureDetail(message: string) { const trimmed = message.trim(); - if ( - !( - trimmed.startsWith('Codex 已返回,但客户端登记生成产物失败:') || - trimmed.includes('陶泥儿美术包不完整,已终止代码生成') || - trimmed.includes('陶泥儿规范图生成后未形成可用平台合同') || - trimmed.includes('陶泥儿美术包生成返回后未形成可用的已登记平台素材合同') - ) - ) { + if (!( + trimmed.startsWith('Codex 已返回,但客户端登记生成产物失败:') || + trimmed.includes('陶泥儿美术包不完整,已终止代码生成') || + trimmed.includes('陶泥儿规范图生成后未形成可用平台合同') || + trimmed.includes('陶泥儿美术包生成返回后未形成可用的已登记平台素材合同') + )) { return null; } if ( @@ -2132,9 +2128,9 @@ export function projectSupervisorPendingRepairMatchesProfessional( const summary = action?.inputSummary ?? ''; return Boolean( action?.tool === 'agent.delegate' && - runtime.delegationId && - summary.includes(`agentId=${runtime.agentId}`) && - summary.includes(`repairOf=${runtime.delegationId}`), + runtime.delegationId && + summary.includes(`agentId=${runtime.agentId}`) && + summary.includes(`repairOf=${runtime.delegationId}`), ); } diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx b/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx index 8d910e7f6..ab1a12292 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx @@ -770,8 +770,8 @@ export function ProjectSupervisorRuntimePanel({ const needsUserInput = agentRuntimeNeedsUserInput(runtime); const needsSupervisorReconciliation = Boolean( runtime && - (runtime.status === 'needs-reconciliation' || - runtime.phase === 'needs-reconciliation'), + (runtime.status === 'needs-reconciliation' || + runtime.phase === 'needs-reconciliation'), ); const cancelledSupervisorQueuePending = runtime && (runtime.status === 'cancelled' || runtime.phase === 'cancelled') @@ -780,12 +780,12 @@ export function ProjectSupervisorRuntimePanel({ const canRetrySupervisor = Boolean( !readOnly && runtime && - !pendingToolAction && - (runtime.status === 'failed' || - runtime.phase === 'failed' || - ((runtime.status === 'cancelled' || runtime.phase === 'cancelled') && - cancelledSupervisorQueuePending === 0)) && - agentRuntimeCanRetry(runtime.status), + !pendingToolAction && + (runtime.status === 'failed' || + runtime.phase === 'failed' || + ((runtime.status === 'cancelled' || runtime.phase === 'cancelled') && + cancelledSupervisorQueuePending === 0)) && + agentRuntimeCanRetry(runtime.status), ); const activeCollaboratingAgents = collaboratingRuntimes.filter( (professionalRuntime) => !isAgentRuntimeTerminalState(professionalRuntime), diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts b/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts index a18cf2355..09d6dc44a 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts @@ -1752,7 +1752,7 @@ export function useDeveloperAgentPanel(launcherView: LauncherView) { currentAgentChatSession?.archivedAt !== undefined; const currentAgentChatRuntimeMatchesGoal = Boolean( agentChatRuntime?.goalId && - (!agentChatGoal || agentChatRuntime.goalId === agentChatGoal.goalId), + (!agentChatGoal || agentChatRuntime.goalId === agentChatGoal.goalId), ); const currentAgentChatGoalStatus = (currentAgentChatRuntimeMatchesGoal @@ -1766,27 +1766,27 @@ export function useDeveloperAgentPanel(launcherView: LauncherView) { : 'edit'; const currentAgentChatSessionMutationBlocked = Boolean( agentChatActiveRuntime && - ([ - 'running', - 'pending', - 'waiting-for-confirmation', - 'waiting-for-user-input', - 'cancelling', - 'finalizing', - 'pausing', - 'paused', - ].includes(agentChatActiveRuntime.status) || - ['needs-reconciliation', 'pausing', 'paused'].includes( - agentChatActiveRuntime.phase, - ) || - ['pause-requested', 'paused', 'clearing'].includes( - agentChatActiveRuntime.goalStatus ?? '', - ) || - (agentChatActiveRuntime.taskQueue?.pending ?? 0) > 0 || - (agentChatActiveRuntime.taskQueue?.running ?? 0) > 0 || - (agentChatActiveRuntime.taskQueue?.waitingForConfirmation ?? 0) > 0 || - (agentChatActiveRuntime.taskQueue?.waitingForUserInput ?? 0) > 0 || - (agentChatActiveRuntime.taskQueue?.paused ?? 0) > 0), + ([ + 'running', + 'pending', + 'waiting-for-confirmation', + 'waiting-for-user-input', + 'cancelling', + 'finalizing', + 'pausing', + 'paused', + ].includes(agentChatActiveRuntime.status) || + ['needs-reconciliation', 'pausing', 'paused'].includes( + agentChatActiveRuntime.phase, + ) || + ['pause-requested', 'paused', 'clearing'].includes( + agentChatActiveRuntime.goalStatus ?? '', + ) || + (agentChatActiveRuntime.taskQueue?.pending ?? 0) > 0 || + (agentChatActiveRuntime.taskQueue?.running ?? 0) > 0 || + (agentChatActiveRuntime.taskQueue?.waitingForConfirmation ?? 0) > 0 || + (agentChatActiveRuntime.taskQueue?.waitingForUserInput ?? 0) > 0 || + (agentChatActiveRuntime.taskQueue?.paused ?? 0) > 0), ); const currentAgentChatActiveSessions = agentChatSessions.filter( (session) => session.archivedAt === null, diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/binding.ts b/apps/ai-game-creator-shell/src/features/ui-editor/binding.ts new file mode 100644 index 000000000..0c936fc1c --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/binding.ts @@ -0,0 +1,19 @@ +import type { BindingDTO } from './types/BindingDTO'; +import type { Node } from './types/Node'; +import type { State } from './types/State'; + +function applyChanges(node: Node, result: BindingDTO): void { + const change = result.changes.find((candidate) => candidate.node_id === node.id); + if (change) { + node.components = structuredClone(change.components); + node.metadata.components_status = structuredClone(change.components_status); + } + for (const child of node.children) applyChanges(child, result); +} + +/** Applies only explicit component changes; omitted nodes remain untouched. */ +export function applyBindingResult(state: State, result: BindingDTO): State { + const next = structuredClone(state); + for (const tree of next.ui_trees) applyChanges(tree.root, result); + return next; +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/bindingOverview.ts b/apps/ai-game-creator-shell/src/features/ui-editor/bindingOverview.ts new file mode 100644 index 000000000..84e6cc6ce --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/bindingOverview.ts @@ -0,0 +1,119 @@ +import { + collectUiTreeNodeTargets, + isBlocked, + isNeedReview, + type UiTreeNodeTarget, +} from './stageStatusOverview'; +import type { Component } from './types/Component'; +import type { SpriteAsset } from './types/SpriteAsset'; +import type { UITree } from './types/UITree'; + +export type ComponentBindingCounts = { + componentsNeedingAssets: number; + assetSlots: number; + boundSlots: number; + pendingSlots: number; +}; + +export type BindingOverview = ComponentBindingCounts & { + needsAttention: number; + blocked: number; + independentAssets: number; +}; + +const EMPTY_COMPONENT_BINDING_COUNTS: ComponentBindingCounts = { + componentsNeedingAssets: 0, + assetSlots: 0, + boundSlots: 0, + pendingSlots: 0, +}; + +function countRequiredAssetSlot(isBound: boolean): ComponentBindingCounts { + return { + componentsNeedingAssets: 1, + assetSlots: 1, + boundSlots: isBound ? 1 : 0, + pendingSlots: isBound ? 0 : 1, + }; +} + +export function getImageBindingCounts( + component: Extract['Image'], +): ComponentBindingCounts { + return countRequiredAssetSlot(component.target_graphic !== null); +} + +export function getTextBindingCounts( + component: Extract['Text'], +): ComponentBindingCounts { + if ( + typeof component.font !== 'object' || + component.font === null || + !('Bound' in component.font) + ) { + return EMPTY_COMPONENT_BINDING_COUNTS; + } + return countRequiredAssetSlot(true); +} + +export function getComponentBindingCounts( + component: Component, +): ComponentBindingCounts { + if ('Image' in component) { + return getImageBindingCounts(component.Image); + } + if ('Text' in component) { + return getTextBindingCounts(component.Text); + } + return EMPTY_COMPONENT_BINDING_COUNTS; +} + +function addBindingCounts( + overview: ComponentBindingCounts, + counts: ComponentBindingCounts, +) { + overview.componentsNeedingAssets += counts.componentsNeedingAssets; + overview.assetSlots += counts.assetSlots; + overview.boundSlots += counts.boundSlots; + overview.pendingSlots += counts.pendingSlots; +} + +export function getBindingOverview( + uiTrees: UITree[], + spriteAssets: Record, +): BindingOverview { + const overview: BindingOverview = { + ...EMPTY_COMPONENT_BINDING_COUNTS, + needsAttention: 0, + blocked: 0, + independentAssets: Object.keys(spriteAssets).length, + }; + + for (const { node } of collectUiTreeNodeTargets(uiTrees)) { + const status = node.metadata.components_status; + if (isBlocked(status)) overview.blocked += 1; + if (isBlocked(status) || isNeedReview(status)) { + overview.needsAttention += 1; + } + for (const component of node.components) { + addBindingCounts(overview, getComponentBindingCounts(component)); + } + } + + return overview; +} + +export function nodeHasPendingBinding(target: UiTreeNodeTarget): boolean { + return target.node.components.some( + (component) => getComponentBindingCounts(component).pendingSlots > 0, + ); +} + +export function nodeNeedsComponentReview(target: UiTreeNodeTarget): boolean { + const status = target.node.metadata.components_status; + return isBlocked(status) || isNeedReview(status); +} + +export function nodeHasBlockedComponents(target: UiTreeNodeTarget): boolean { + return isBlocked(target.node.metadata.components_status); +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/components/FontSamplePreview.tsx b/apps/ai-game-creator-shell/src/features/ui-editor/components/FontSamplePreview.tsx new file mode 100644 index 000000000..e2ee7c94f --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/components/FontSamplePreview.tsx @@ -0,0 +1,72 @@ +export function FontSamplePreview({ + fontFamily, + fontWeight, + italic, +}: { + fontFamily?: string; + fontWeight?: number; + italic?: boolean; +}) { + // 将字体样张的排版属性放到每一行上,而不是依赖 Inspector 容器的继承。 + // Inspector 同时承载节点文本样式编辑,样张必须始终只反映当前字体资源。 + const sampleStyle = fontFamily + ? { + color: '#000', + fontFamily: JSON.stringify(fontFamily), + fontWeight, + fontStyle: italic ? 'italic' : 'normal', + fontSynthesis: 'none', + } + : { color: '#000' }; + + const sampleLines = [ + { + text: '中文字体预览 · English Font Preview · 0123456789', + fontSize: 12, + lineHeight: '20px', + }, + { + text: '春夏秋冬,山川湖海;Aa Bb Cc,!? @#&*()', + fontSize: 14, + lineHeight: '24px', + }, + { + text: '陶泥儿 Genarrative Studio', + fontSize: 18, + lineHeight: '28px', + }, + { + text: '你好,Hello World! 字体设计', + fontSize: 24, + lineHeight: '32px', + }, + { + text: '字体样张 Aa 字母数字 123', + fontSize: 30, + lineHeight: '40px', + }, + { + text: '中文 English', + fontSize: 36, + lineHeight: '40px', + }, + ]; + + return ( +
+ {sampleLines.map((line, index) => ( +

+ {line.text} +

+ ))} +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/components/SpriteImagePreview.tsx b/apps/ai-game-creator-shell/src/features/ui-editor/components/SpriteImagePreview.tsx new file mode 100644 index 000000000..a724c4e42 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/components/SpriteImagePreview.tsx @@ -0,0 +1,12 @@ +export function SpriteImagePreview({ + src, + alt, + className, +}: { + src: string | undefined | null; + alt: string; + className?: string; +}) { + if (!src) return null; + return {alt}; +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/importAdapter.ts b/apps/ai-game-creator-shell/src/features/ui-editor/importAdapter.ts new file mode 100644 index 000000000..e2f238527 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/importAdapter.ts @@ -0,0 +1,120 @@ +import { invoke } from '@tauri-apps/api/core'; + +import type { ImportedAsset } from '../../components/AssetImporter'; +import { + createProjectResourcePreviewRequestId, + createProjectResourcePreviewScopeId, +} from '../../services/projectResourcePreviewTransport'; +import type { FontAsset } from './types/FontAsset'; +import type { SpriteAsset } from './types/SpriteAsset'; +import type { UIDesignImage } from './types/UIDesignImage'; +import type { UIDesignImageId } from './types/UIDesignImageId'; +import type { DesignImageInput } from './useUiEditorState'; + +type ImagePreviewResponse = { dataUrl: string }; + +export type PreparedImageAsset = { + resource: T; + previewUrl: string; +}; + +export function decodeImageSize(src: string): Promise<[number, number]> { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => { + if (image.naturalWidth <= 0 || image.naturalHeight <= 0) { + reject(new Error('图片尺寸无效')); + return; + } + resolve([image.naturalWidth, image.naturalHeight]); + }; + image.onerror = () => reject(new Error('图片无法解码')); + image.src = src; + }); +} + +async function readImportedImage( + projectPath: string, + asset: ImportedAsset, + scopeId: string, +) { + const preview = await invoke( + 'read_local_project_image_preview', + { + projectPath, + relativePath: asset.localPath, + scopeId, + requestId: createProjectResourcePreviewRequestId(), + }, + ); + return { + previewUrl: preview.dataUrl, + pixelSize: await decodeImageSize(preview.dataUrl), + }; +} + +export async function prepareDesignImageBatch( + projectPath: string, + assets: readonly ImportedAsset[], +): Promise>> { + const scopeId = createProjectResourcePreviewScopeId(); + return Promise.all( + assets.map(async (asset) => { + const { previewUrl, pixelSize } = await readImportedImage( + projectPath, + asset, + scopeId, + ); + const image: UIDesignImage = { + metadata: { + name: '', + description: '', + role: null, + slave_to: null, + }, + path: asset.localPath, + pixel_size: pixelSize, + pixels_per_unit: 1, + }; + return { + resource: { id: asset.id as UIDesignImageId, image }, + previewUrl, + }; + }), + ); +} + +export async function prepareSpriteAssetBatch( + projectPath: string, + assets: readonly ImportedAsset[], +): Promise>> { + const scopeId = createProjectResourcePreviewScopeId(); + return Promise.all( + assets.map(async (asset) => { + const { previewUrl, pixelSize } = await readImportedImage( + projectPath, + asset, + scopeId, + ); + const resource: SpriteAsset = { + asset_id: asset.id, + metadata: { name: '', asset_type: '' }, + path: asset.localPath, + pixel_size: pixelSize, + pixels_per_unit: 1, + border: { left: 0, right: 0, top: 0, bottom: 0 }, + }; + return { resource, previewUrl }; + }), + ); +} + +export function prepareFontAssetBatch( + projectPath: string, + assets: readonly ImportedAsset[], +) { + return invoke('prepare_ui_editor_project_fonts', { + projectPath, + assets, + }); +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/merge.ts b/apps/ai-game-creator-shell/src/features/ui-editor/merge.ts new file mode 100644 index 000000000..b90781bb4 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/merge.ts @@ -0,0 +1,10 @@ +import type { MergeDTO } from './types/MergeDTO'; +import type { State } from './types/State'; + +/** 合并阶段返回一棵新树,成功后替换当前全部逐图识别树。 */ +export function applyMergeResult(state: State, result: MergeDTO): State { + return { + ...structuredClone(state), + ui_trees: [structuredClone(result.ui_tree)], + }; +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/nodeTransformGeometry.ts b/apps/ai-game-creator-shell/src/features/ui-editor/nodeTransformGeometry.ts new file mode 100644 index 000000000..4a0b09382 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/nodeTransformGeometry.ts @@ -0,0 +1,203 @@ +import type { Node } from './types/Node'; +import type { NodeId } from './types/NodeId'; +import type { Transform } from './types/Transform'; + +export type ResizeAxis = 'horizontal' | 'vertical'; + +export type ResizeHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'; + +export type PageRect = { + min: [number, number]; + max: [number, number]; +}; + +export type NodePageContext = { + node: Node; + rect: PageRect; + parentRect: PageRect; +}; + +const MIN_NODE_SIZE = 1; + +export function pageRectFromSize(size: [number, number]): PageRect { + return { min: [0, 0], max: [...size] }; +} + +export function pageRectSize(rect: PageRect): [number, number] { + return [rect.max[0] - rect.min[0], rect.max[1] - rect.min[1]]; +} + +export function isValidPageRect(rect: PageRect): boolean { + return [...rect.min, ...rect.max].every(Number.isFinite); +} + +export function resolvePageRect( + transform: Transform, + parentRect: PageRect, +): PageRect { + const [parentWidth, parentHeight] = pageRectSize(parentRect); + return { + min: [ + parentRect.min[0] + + parentWidth * transform.anchor_min[0] + + transform.offset_min[0], + parentRect.min[1] + + parentHeight * transform.anchor_min[1] + + transform.offset_min[1], + ], + max: [ + parentRect.min[0] + + parentWidth * transform.anchor_max[0] + + transform.offset_max[0], + parentRect.min[1] + + parentHeight * transform.anchor_max[1] + + transform.offset_max[1], + ], + }; +} + +export function findNodePageContext( + node: Node, + nodeId: NodeId, + parentRect: PageRect, +): NodePageContext | null { + const rect = resolvePageRect(node.layout.transform, parentRect); + if (node.id === nodeId) return { node, rect, parentRect }; + for (const child of node.children) { + const found = findNodePageContext(child, nodeId, rect); + if (found) return found; + } + return null; +} + +export function setOffsetsForPageRect( + transform: Transform, + rect: PageRect, + parentRect: PageRect, +): Transform { + const [parentWidth, parentHeight] = pageRectSize(parentRect); + return { + ...structuredClone(transform), + offset_min: [ + rect.min[0] - parentRect.min[0] - parentWidth * transform.anchor_min[0], + rect.min[1] - parentRect.min[1] - parentHeight * transform.anchor_min[1], + ], + offset_max: [ + rect.max[0] - parentRect.min[0] - parentWidth * transform.anchor_max[0], + rect.max[1] - parentRect.min[1] - parentHeight * transform.anchor_max[1], + ], + }; +} + +export function resolveAnchorPresetTransform( + transform: Transform, + anchorMin: [number, number], + anchorMax: [number, number], + parentSize?: { width: number; height: number }, +): Transform { + const next: Transform = { + ...structuredClone(transform), + anchor_min: [...anchorMin], + anchor_max: [...anchorMax], + }; + if (!parentSize || parentSize.width <= 0 || parentSize.height <= 0) { + return next; + } + return setOffsetsForPageRect( + next, + resolvePageRect( + transform, + pageRectFromSize([parentSize.width, parentSize.height]), + ), + pageRectFromSize([parentSize.width, parentSize.height]), + ); +} + +/** Converts a same-page reparent operation into the target parent's local transform. */ +export function resolveReparentTransform( + transform: Transform, + nodeRect: PageRect, + targetParentRect: PageRect, +): Transform { + return setOffsetsForPageRect(transform, nodeRect, targetParentRect); +} + +/** Keeps each direct child's page rectangle stable while its parent changes rectangle. */ +export function resolveChildrenTransformsForParentRect( + children: readonly Node[], + oldParentRect: PageRect, + newParentRect: PageRect, +): Array<{ id: NodeId; transform: Transform }> { + return children.map((child) => ({ + id: child.id, + transform: setOffsetsForPageRect( + child.layout.transform, + resolvePageRect(child.layout.transform, oldParentRect), + newParentRect, + ), + })); +} + +export function resolveProportionalResizeAxis( + startSize: [number, number], + delta: [number, number], +): ResizeAxis { + const startWidth = Math.max(MIN_NODE_SIZE, startSize[0]); + const startHeight = Math.max(MIN_NODE_SIZE, startSize[1]); + return Math.abs(delta[0]) / startWidth >= Math.abs(delta[1]) / startHeight + ? 'horizontal' + : 'vertical'; +} + +export function resizePageRect( + startRect: PageRect, + handle: ResizeHandle, + delta: [number, number], + keepRatio: boolean, + ratioAxis?: ResizeAxis, +): PageRect { + const next: PageRect = { min: [...startRect.min], max: [...startRect.max] }; + if (handle.includes('w')) { + next.min[0] = Math.min( + startRect.max[0] - MIN_NODE_SIZE, + startRect.min[0] + delta[0], + ); + } else if (handle.includes('e')) { + next.max[0] = Math.max( + startRect.min[0] + MIN_NODE_SIZE, + startRect.max[0] + delta[0], + ); + } + if (handle.includes('n')) { + next.min[1] = Math.min( + startRect.max[1] - MIN_NODE_SIZE, + startRect.min[1] + delta[1], + ); + } else if (handle.includes('s')) { + next.max[1] = Math.max( + startRect.min[1] + MIN_NODE_SIZE, + startRect.max[1] + delta[1], + ); + } + + if (keepRatio && handle.length === 2) { + const [startWidth, startHeight] = pageRectSize(startRect); + const [nextWidth, nextHeight] = pageRectSize(next); + const axis = + ratioAxis ?? + resolveProportionalResizeAxis([startWidth, startHeight], delta); + const ratio = + Math.max(MIN_NODE_SIZE, startWidth) / + Math.max(MIN_NODE_SIZE, startHeight); + const width = + axis === 'horizontal' + ? Math.max(MIN_NODE_SIZE, nextWidth) + : Math.max(MIN_NODE_SIZE, nextHeight) * ratio; + const height = width / ratio; + if (handle.includes('w')) next.min[0] = startRect.max[0] - width; + else next.max[0] = startRect.min[0] + width; + if (handle.includes('n')) next.min[1] = startRect.max[1] - height; + else next.max[1] = startRect.min[1] + height; + } + return next; +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/recognition.ts b/apps/ai-game-creator-shell/src/features/ui-editor/recognition.ts new file mode 100644 index 000000000..2238c6b40 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/recognition.ts @@ -0,0 +1,16 @@ +import type { RecognitionDTO } from './types/RecognitionDTO'; +import type { State } from './types/State'; + +/** + * 识别结果是整棵结构草稿树的替换结果,不与旧树逐节点合并。 + * 识别阶段有意不携带视觉组件;组件绑定由后续 visual-binding 阶段完成。 + */ +export function applyRecognitionResult( + state: State, + result: RecognitionDTO, +): State { + return { + ...structuredClone(state), + ui_trees: structuredClone(result.ui_trees), + }; +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/requisites.ts b/apps/ai-game-creator-shell/src/features/ui-editor/requisites.ts new file mode 100644 index 000000000..af3e0c929 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/requisites.ts @@ -0,0 +1,230 @@ +import type { StageStatus } from './types/StageStatus'; +import type { State } from './types/State'; +import type { UIDesignImageId } from './types/UIDesignImageId'; + +export type UiEditorPrerequisiteIssue = { + code: string; + message: string; + resourceId?: string; +}; + +function imageResourceIssues(state: State): UiEditorPrerequisiteIssue[] { + const issues: UiEditorPrerequisiteIssue[] = []; + for (const [id, image] of Object.entries(state.ui_design_images)) { + if ( + !image.path.trim() || + image.pixel_size.some((value) => !Number.isFinite(value) || value <= 0) || + !Number.isFinite(image.pixels_per_unit) || + image.pixels_per_unit <= 0 + ) { + issues.push({ + code: 'invalid-design-image', + message: '界面图无法读取或尺寸无效', + resourceId: id, + }); + } + } + return issues; +} + +export function validateComponentRecognitionPrerequisites( + state: State, +): UiEditorPrerequisiteIssue[] { + const ids = Object.keys(state.ui_design_images) as UIDesignImageId[]; + const issues = imageResourceIssues(state); + if (ids.length === 0) { + issues.push({ code: 'missing-design-image', message: '请先导入界面图' }); + } + if (ids.length > 4) { + issues.push({ code: 'design-image-limit', message: '界面图最多 4 张' }); + } + for (const id of ids) { + const image = state.ui_design_images[id]!; + const { role, slave_to: slaveTo } = image.metadata; + if (role === 'Page' && slaveTo !== null) { + issues.push({ + code: 'page-has-slave-to', + message: '主页面不能设置归属页面', + resourceId: id, + }); + } else if (role !== null && role !== 'Page') { + if (slaveTo === null) { + issues.push({ + code: 'missing-slave-to', + message: '该界面角色需要选择归属主页面', + resourceId: id, + }); + } else if (slaveTo === id) { + issues.push({ + code: 'self-slave-to', + message: '界面不能归属于自身', + resourceId: id, + }); + } else if (state.ui_design_images[slaveTo]?.metadata.role !== 'Page') { + issues.push({ + code: 'invalid-slave-to', + message: '归属页面必须是有效主页面', + resourceId: id, + }); + } + } + } + return issues; +} + +export function validateAssetRecognitionPrerequisites( + state: State, +): UiEditorPrerequisiteIssue[] { + const issues = validateComponentRecognitionPrerequisites(state); + if (state.ui_trees.length === 0) { + issues.push({ + code: 'missing-ui-tree', + message: '请先完成组件识别', + }); + } + for (const tree of state.ui_trees) { + if (!(tree.src_ui_design in state.ui_design_images)) { + issues.push({ + code: 'missing-tree-design-image', + message: '组件树引用的界面图不存在', + resourceId: tree.src_ui_design, + }); + } + } + if (Object.keys(state.sprite_assets).length === 0) { + issues.push({ + code: 'missing-sprite-assets', + message: '请先导入独立素材', + }); + } + return issues; +} + +export function validateLayoutGenerationPrerequisites( + state: State, +): UiEditorPrerequisiteIssue[] { + const issues = validateAssetRecognitionPrerequisites(state); + const visit = (nodes: State['ui_trees'][number]['root']['children']) => { + for (const node of nodes) { + for (const component of node.components) { + if ( + 'Image' in component && + component.Image.target_graphic !== null && + !(component.Image.target_graphic in state.sprite_assets) + ) { + issues.push({ + code: 'missing-target-graphic', + message: '图片组件引用的独立素材不存在', + resourceId: component.Image.target_graphic, + }); + } + if ('Text' in component) { + const font = component.Text.font; + if (typeof font !== 'string' && !(font.Bound in state.font_assets)) { + issues.push({ + code: 'missing-font', + message: '文本组件引用的字体不存在', + resourceId: font.Bound, + }); + } + } + } + visit(node.children); + } + }; + for (const tree of state.ui_trees) visit([tree.root]); + return issues; +} + +export function validateLayoutReviewPrerequisites( + state: State, +): UiEditorPrerequisiteIssue[] { + return validateLayoutGenerationPrerequisites(state); +} + +function layoutStatusIssue( + status: StageStatus, +): UiEditorPrerequisiteIssue | null { + if (status === 'NoProblem') return null; + if ('Blocked' in status) { + return { code: 'layout-blocked', message: '存在被阻塞的节点结果' }; + } + return { + code: 'layout-needs-review', + message: status.NeedReview || '存在需要人工审阅的节点结果', + }; +} + +function componentStatusIssue( + status: StageStatus, +): UiEditorPrerequisiteIssue | null { + if (status === 'NoProblem') return null; + if ('Blocked' in status) { + return { code: 'components-blocked', message: '存在被阻塞的节点结果' }; + } + return { + code: 'components-needs-review', + message: status.NeedReview || '存在需要人工审阅的节点结果', + }; +} + +function visitNodes( + nodes: State['ui_trees'][number]['root']['children'], + visit: (node: State['ui_trees'][number]['root']) => void, +) { + for (const node of nodes) { + visit(node); + visitNodes(node.children, visit); + } +} + +// 结果检查与进入步骤的前置条件保持分离;调用方只把结果作为非阻塞提示。 +export function validateReferenceAnalysisResult( + state: State, +): UiEditorPrerequisiteIssue[] { + const images = Object.values(state.ui_design_images); + if (images.length === 0) { + return [{ code: 'missing-analysis-input', message: '尚未导入参考图' }]; + } + if (images.every((image) => image.metadata.role === null)) { + return [ + { + code: 'missing-reference-analysis', + message: '参考图尚未设置界面用途', + }, + ]; + } + return []; +} + +export function validateStructureRecognitionResult( + state: State, +): UiEditorPrerequisiteIssue[] { + if (state.ui_trees.length === 0) { + return [{ code: 'missing-ui-tree', message: '尚未生成界面结构' }]; + } + const issues: UiEditorPrerequisiteIssue[] = []; + for (const tree of state.ui_trees) { + visitNodes([tree.root], (node) => { + const issue = layoutStatusIssue(node.metadata.layout_status); + if (issue) issues.push(issue); + }); + } + return issues; +} + +export function validateVisualBindingResult( + state: State, +): UiEditorPrerequisiteIssue[] { + const issues: UiEditorPrerequisiteIssue[] = []; + for (const tree of state.ui_trees) { + visitNodes([tree.root], (node) => { + if (node.components.length == 0) { + return; + } + const issue = componentStatusIssue(node.metadata.components_status); + if (issue) issues.push(issue); + }); + } + return issues; +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/spriteBorder.ts b/apps/ai-game-creator-shell/src/features/ui-editor/spriteBorder.ts new file mode 100644 index 000000000..f02283280 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/spriteBorder.ts @@ -0,0 +1,73 @@ +import type { SpriteBorder } from './types/SpriteBorder'; + +const MAX_U32 = 4_294_967_295; + +export type SpriteBorderValidation = + | { ok: true } + | { ok: false; message: string; axis?: 'horizontal' | 'vertical' }; + +export function spriteBorderIsClear(border: SpriteBorder) { + return ( + border.left === 0 && + border.right === 0 && + border.top === 0 && + border.bottom === 0 + ); +} + +export function validateSpriteBorder( + pixelSize: [number, number], + border: SpriteBorder, +): SpriteBorderValidation { + const values = [border.left, border.right, border.top, border.bottom]; + if ( + values.some( + (value) => !Number.isInteger(value) || value < 0 || value > MAX_U32, + ) + ) { + return { ok: false, message: '边距必须是非负整数像素' }; + } + if (spriteBorderIsClear(border)) return { ok: true }; + if (!pixelSize.every((value) => Number.isFinite(value) && value > 0)) { + return { ok: false, message: '素材尺寸无效' }; + } + + const width = Math.floor(pixelSize[0]); + const height = Math.floor(pixelSize[1]); + if (border.left + border.right + 1 > width) { + return { + ok: false, + axis: 'horizontal', + message: '水平中心至少保留 1 px', + }; + } + if (border.top + border.bottom + 1 > height) { + return { + ok: false, + axis: 'vertical', + message: '垂直中心至少保留 1 px', + }; + } + return { ok: true }; +} + +export function spriteBorderRatioPreset( + pixelSize: [number, number], + centerRatio: 1 | 2, +): SpriteBorder | null { + if (!pixelSize.every((value) => Number.isFinite(value) && value > 0)) { + return null; + } + const width = Math.floor(pixelSize[0]); + const height = Math.floor(pixelSize[1]); + const divisor = centerRatio + 2; + const horizontal = Math.floor(width / divisor); + const vertical = Math.floor(height / divisor); + if (horizontal === 0 || vertical === 0) return null; + return { + left: horizontal, + right: horizontal, + top: vertical, + bottom: vertical, + }; +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/stageStatusOverview.ts b/apps/ai-game-creator-shell/src/features/ui-editor/stageStatusOverview.ts new file mode 100644 index 000000000..c2f9e9c00 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/stageStatusOverview.ts @@ -0,0 +1,100 @@ +import type { Node as UiNode } from './types/Node'; +import type { NodeMetadata } from './types/NodeMetadata'; +import type { StageStatus } from './types/StageStatus'; +import type { UIDesignImageId } from './types/UIDesignImageId'; +import type { UITree } from './types/UITree'; + +export type StageStatusField = Extract< + keyof NodeMetadata, + 'layout_status' | 'components_status' +>; + +export type UiTreeNodeTarget = { + treeId: UIDesignImageId; + node: UiNode; +}; + +export type StageStatusOverview = { + total: number; + needsAttention: number; + noProblem: number; + blocked: number; +}; + +export function isNeedReview(status: StageStatus): boolean { + return typeof status === 'object' && 'NeedReview' in status; +} + +export function isBlocked(status: StageStatus): boolean { + return typeof status === 'object' && 'Blocked' in status; +} + +export function collectUiTreeNodeTargets( + uiTrees: UITree[], +): UiTreeNodeTarget[] { + const targets: UiTreeNodeTarget[] = []; + + const visit = (treeId: UIDesignImageId, node: UiNode) => { + targets.push({ treeId, node }); + for (const child of node.children) visit(treeId, child); + }; + + for (const tree of uiTrees) visit(tree.src_ui_design, tree.root); + return targets; +} + +export function getStageStatusOverview( + uiTrees: UITree[], + field: StageStatusField, +): StageStatusOverview { + const overview: StageStatusOverview = { + total: 0, + needsAttention: 0, + noProblem: 0, + blocked: 0, + }; + + for (const { node } of collectUiTreeNodeTargets(uiTrees)) { + const status = node.metadata[field]; + overview.total += 1; + if (status === 'NoProblem') overview.noProblem += 1; + if (isBlocked(status)) overview.blocked += 1; + if (isBlocked(status) || isNeedReview(status)) { + overview.needsAttention += 1; + } + } + + return overview; +} + +export function getStageStatusTargets( + uiTrees: UITree[], + field: StageStatusField, + matches: (status: StageStatus) => boolean, +): UiTreeNodeTarget[] { + return collectUiTreeNodeTargets(uiTrees).filter(({ node }) => + matches(node.metadata[field]), + ); +} + +export function getNextUiTreeNodeTarget( + targets: UiTreeNodeTarget[], + previousNodeId: string | null, +): UiTreeNodeTarget | null { + if (targets.length === 0) return null; + const previousIndex = targets.findIndex( + ({ node }) => node.id === previousNodeId, + ); + return targets[(previousIndex + 1) % targets.length] ?? null; +} + +export function getNextMatchingUiTreeNodeTarget( + uiTrees: UITree[], + previousNodeId: string | null, + matches: (target: UiTreeNodeTarget) => boolean, +): UiTreeNodeTarget | null { + return getNextUiTreeNodeTarget( + collectUiTreeNodeTargets(uiTrees).filter(matches), + previousNodeId, + ); +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/treeUtils.ts b/apps/ai-game-creator-shell/src/features/ui-editor/treeUtils.ts new file mode 100644 index 000000000..9e044efb8 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/treeUtils.ts @@ -0,0 +1,16 @@ +import type { Node as UiNode } from './types/Node'; +import type { NodeId } from './types/NodeId'; + +export function visitUiNodes( + root: UiNode, + visit: (node: UiNode) => void, +): void { + visit(root); + for (const child of root.children) visitUiNodes(child, visit); +} + +export function collectUiNodeIds(root: UiNode): Set { + const ids = new Set(); + visitUiNodes(root, (node) => ids.add(node.id)); + return ids; +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/BestFitFontSize.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/BestFitFontSize.ts new file mode 100644 index 000000000..c93fd9fee --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/BestFitFontSize.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type BestFitFontSize = { min: number, max: number, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingChange.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingChange.ts new file mode 100644 index 000000000..ec71edbfd --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingChange.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Component } from "./Component"; +import type { NodeId } from "./NodeId"; +import type { StageStatus } from "./StageStatus"; + +export type BindingChange = { node_id: NodeId, components: Array, components_status: StageStatus, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingDTO.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingDTO.ts new file mode 100644 index 000000000..beb340894 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingDTO.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BindingChange } from "./BindingChange"; + +export type BindingDTO = { changes: Array, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/ChildrenDisplayMode.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/ChildrenDisplayMode.ts new file mode 100644 index 000000000..fcedb923b --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/ChildrenDisplayMode.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ChildrenDisplayMode = "Stack" | "Exclusive"; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/Component.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/Component.ts new file mode 100644 index 000000000..8698056d8 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/Component.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ImageComponent } from "./ImageComponent"; +import type { TextComponent } from "./TextComponent"; + +export type Component = { "Image": ImageComponent } | { "Text": TextComponent }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/Container.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/Container.ts new file mode 100644 index 000000000..d435f9d0b --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/Container.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ContainerAlignment } from "./ContainerAlignment"; + +/** + * 互斥 Container 定义。没有 Container 即普通 Control。 + */ +export type Container = "None" | { "HBox": { alignment: ContainerAlignment, separation: number, } } | { "VBox": { alignment: ContainerAlignment, separation: number, } } | { "Grid": { columns: number, h_separation: number, v_separation: number, } } | { "Margin": { margin_left: number, margin_top: number, margin_right: number, margin_bottom: number, } } | { "Center": { use_top_left: boolean, } }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/ContainerAlignment.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/ContainerAlignment.ts new file mode 100644 index 000000000..054f0b15e --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/ContainerAlignment.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ContainerAlignment = "Begin" | "Center" | "End"; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/ControlLayout.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/ControlLayout.ts new file mode 100644 index 000000000..1dfc28ff5 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/ControlLayout.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Container } from "./Container"; +import type { Transform } from "./Transform"; + +/** + * Godot Control 的 Container 相关布局属性。 + * + * `Transform` 始终被保留。只有直接父节点为 Container 时,父节点才会 + * 忽略 child 的 anchors / offsets,改读 minimum size 与 size flags。 + */ +export type ControlLayout = { transform: Transform, custom_minimum_size: [number, number], size_flags_horizontal: number, size_flags_vertical: number, size_flags_stretch_ratio: number, container: Container, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/FillMethod.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/FillMethod.ts new file mode 100644 index 000000000..1651dbbee --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/FillMethod.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { HorizontalFillOrigin } from "./HorizontalFillOrigin"; +import type { Radial180Origin } from "./Radial180Origin"; +import type { Radial360Origin } from "./Radial360Origin"; +import type { Radial90Origin } from "./Radial90Origin"; +import type { VerticalFillOrigin } from "./VerticalFillOrigin"; + +export type FillMethod = { "Horizontal": HorizontalFillOrigin } | { "Vertical": VerticalFillOrigin } | { "Radial90": { origin: Radial90Origin, clockwise: boolean, } } | { "Radial180": { origin: Radial180Origin, clockwise: boolean, } } | { "Radial360": { origin: Radial360Origin, clockwise: boolean, } }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/FontAsset.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/FontAsset.ts new file mode 100644 index 000000000..3e6df8f8e --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/FontAsset.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FontAssetId } from "./FontAssetId"; +import type { FontAssetMetadata } from "./FontAssetMetadata"; + +export type FontAsset = { asset_id: FontAssetId, metadata: FontAssetMetadata, path: string, content_sha256: string, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/FontAssetId.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/FontAssetId.ts new file mode 100644 index 000000000..a6c33af60 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/FontAssetId.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type FontAssetId = string; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/FontAssetMetadata.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/FontAssetMetadata.ts new file mode 100644 index 000000000..39ee02a91 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/FontAssetMetadata.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FontFormat } from "./FontFormat"; + +export type FontAssetMetadata = { family_name: string, face_name: string, weight: number, italic: boolean, format: FontFormat, source_file_name: string, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/FontFormat.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/FontFormat.ts new file mode 100644 index 000000000..4b9ff9391 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/FontFormat.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type FontFormat = "TrueType" | "OpenType" | "Woff" | "Woff2"; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/FontSizing.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/FontSizing.ts new file mode 100644 index 000000000..38e9a2b2a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/FontSizing.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BestFitFontSize } from "./BestFitFontSize"; + +export type FontSizing = { "Fixed": number } | { "BestFit": BestFitFontSize }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/FontSource.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/FontSource.ts new file mode 100644 index 000000000..dc80dd4fb --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/FontSource.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FontAssetId } from "./FontAssetId"; + +/** + * 文本的字体来源。系统字体是一个明确的有效选择,而不是缺失的字体绑定。 + */ +export type FontSource = "SystemFont" | { "Bound": FontAssetId }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/FontStyle.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/FontStyle.ts new file mode 100644 index 000000000..441a8c28c --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/FontStyle.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type FontStyle = "Normal" | "Bold" | "Italic" | "BoldItalic"; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/HorizontalFillOrigin.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/HorizontalFillOrigin.ts new file mode 100644 index 000000000..d99431951 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/HorizontalFillOrigin.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HorizontalFillOrigin = "Left" | "Right"; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/HorizontalTextOverflow.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/HorizontalTextOverflow.ts new file mode 100644 index 000000000..19617d809 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/HorizontalTextOverflow.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HorizontalTextOverflow = "Wrap" | "Overflow"; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/ImageComponent.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/ImageComponent.ts new file mode 100644 index 000000000..61e4de858 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/ImageComponent.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ImageType } from "./ImageType"; +import type { SpriteAssetId } from "./SpriteAssetId"; + +/** + * Image 渲染组件;布局由同一 Entity 上独立的 `Control` 提供。 + */ +export type ImageComponent = { target_graphic: SpriteAssetId | null, image_type: ImageType, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/ImageType.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/ImageType.ts new file mode 100644 index 000000000..88d0452af --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/ImageType.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FillMethod } from "./FillMethod"; + +export type ImageType = { "Simple": { preserve_aspect: boolean, } } | { "Sliced": { fill_center: boolean, pixels_per_unit_multiplier: number, } } | { "Tiled": { fill_center: boolean, pixels_per_unit_multiplier: number, } } | { "Filled": { preserve_aspect: boolean, method: FillMethod, amount: number, } }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/MergeDTO.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/MergeDTO.ts new file mode 100644 index 000000000..366a191e3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/MergeDTO.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { UITree } from "./UITree"; + +export type MergeDTO = { ui_tree: UITree, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts new file mode 100644 index 000000000..c05d8743a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChildrenDisplayMode } from "./ChildrenDisplayMode"; +import type { Component } from "./Component"; +import type { ControlLayout } from "./ControlLayout"; +import type { NodeId } from "./NodeId"; +import type { NodeMetadata } from "./NodeMetadata"; + +export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, components: Array, children_display_mode: ChildrenDisplayMode, children: Array, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeId.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeId.ts new file mode 100644 index 000000000..b83e79464 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeId.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NodeId = string; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeMetadata.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeMetadata.ts new file mode 100644 index 000000000..43aee349e --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeMetadata.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { NodeSource } from "./NodeSource"; +import type { StageStatus } from "./StageStatus"; + +export type NodeMetadata = { name: string, description: string, layout_status: StageStatus, components_status: StageStatus, allow_llm_edit_layout: boolean, allow_llm_edit_component: boolean, source: NodeSource, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeSource.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeSource.ts new file mode 100644 index 000000000..acfd1a84f --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeSource.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NodeSource = "System" | "Human" | "Llm"; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/Radial180Origin.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/Radial180Origin.ts new file mode 100644 index 000000000..bc15a5f67 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/Radial180Origin.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Radial180Origin = "Bottom" | "Left" | "Top" | "Right"; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/Radial360Origin.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/Radial360Origin.ts new file mode 100644 index 000000000..6010e8fa2 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/Radial360Origin.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Radial360Origin = "Bottom" | "Right" | "Top" | "Left"; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/Radial90Origin.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/Radial90Origin.ts new file mode 100644 index 000000000..fcbffe0e6 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/Radial90Origin.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Radial90Origin = "BottomLeft" | "TopLeft" | "TopRight" | "BottomRight"; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/RecognitionDTO.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/RecognitionDTO.ts new file mode 100644 index 000000000..57f9f1ea9 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/RecognitionDTO.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { UITree } from "./UITree"; + +export type RecognitionDTO = { ui_trees: Array, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/SpriteAsset.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/SpriteAsset.ts new file mode 100644 index 000000000..2f89253cc --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/SpriteAsset.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SpriteAssetId } from "./SpriteAssetId"; +import type { SpriteAssetMetadata } from "./SpriteAssetMetadata"; +import type { SpriteBorder } from "./SpriteBorder"; + +export type SpriteAsset = { asset_id: SpriteAssetId, metadata: SpriteAssetMetadata, path: string, pixel_size: [number, number], pixels_per_unit: number, border: SpriteBorder, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/SpriteAssetId.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/SpriteAssetId.ts new file mode 100644 index 000000000..444bec042 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/SpriteAssetId.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SpriteAssetId = string; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/SpriteAssetMetadata.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/SpriteAssetMetadata.ts new file mode 100644 index 000000000..abaa0968a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/SpriteAssetMetadata.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SpriteAssetMetadata = { name: string, asset_type: string, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/SpriteBorder.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/SpriteBorder.ts new file mode 100644 index 000000000..cd1b63e38 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/SpriteBorder.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SpriteBorder = { left: number, right: number, top: number, bottom: number, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/StageStatus.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/StageStatus.ts new file mode 100644 index 000000000..ebb7d0541 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/StageStatus.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type StageStatus = "NoProblem" | { "NeedReview": string } | { "Blocked": string }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/State.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/State.ts new file mode 100644 index 000000000..c13126399 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/State.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FontAsset } from "./FontAsset"; +import type { FontAssetId } from "./FontAssetId"; +import type { SpriteAsset } from "./SpriteAsset"; +import type { SpriteAssetId } from "./SpriteAssetId"; +import type { UIDesignImage } from "./UIDesignImage"; +import type { UIDesignImageId } from "./UIDesignImageId"; +import type { UITree } from "./UITree"; + +export type State = { ui_trees: Array, ui_design_images: { [key in UIDesignImageId]: UIDesignImage }, sprite_assets: { [key in SpriteAssetId]: SpriteAsset }, font_assets: { [key in FontAssetId]: FontAsset }, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/TextAlignment.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/TextAlignment.ts new file mode 100644 index 000000000..0bb35743b --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/TextAlignment.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TextAlignment = "UpperLeft" | "UpperCenter" | "UpperRight" | "MiddleLeft" | "MiddleCenter" | "MiddleRight" | "LowerLeft" | "LowerCenter" | "LowerRight"; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/TextComponent.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/TextComponent.ts new file mode 100644 index 000000000..1f22b7b87 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/TextComponent.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FontSizing } from "./FontSizing"; +import type { FontSource } from "./FontSource"; +import type { FontStyle } from "./FontStyle"; +import type { HorizontalTextOverflow } from "./HorizontalTextOverflow"; +import type { TextAlignment } from "./TextAlignment"; +import type { VerticalTextOverflow } from "./VerticalTextOverflow"; + +/** + * Text / Label 渲染组件;布局由同一 Entity 上独立的 `Control` 提供。 + */ +export type TextComponent = { content: string, font: FontSource, font_style: FontStyle, font_sizing: FontSizing, color: [number, number, number, number], alignment: TextAlignment, horizontal_overflow: HorizontalTextOverflow, vertical_overflow: VerticalTextOverflow, line_spacing: number, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/Transform.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/Transform.ts new file mode 100644 index 000000000..45ced8465 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/Transform.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Transform = { anchor_min: [number, number], anchor_max: [number, number], offset_min: [number, number], offset_max: [number, number], }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignImage.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignImage.ts new file mode 100644 index 000000000..b1f383426 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignImage.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { UIDesignImageMetadata } from "./UIDesignImageMetadata"; + +export type UIDesignImage = { metadata: UIDesignImageMetadata, path: string, pixel_size: [number, number], pixels_per_unit: number, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignImageId.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignImageId.ts new file mode 100644 index 000000000..9826cd650 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignImageId.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type UIDesignImageId = string; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignImageMetadata.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignImageMetadata.ts new file mode 100644 index 000000000..2e9df7fd3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignImageMetadata.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { UIDesignImageId } from "./UIDesignImageId"; +import type { UIDesignImageRole } from "./UIDesignImageRole"; + +export type UIDesignImageMetadata = { name: string, description: string, role: UIDesignImageRole | null, slave_to: UIDesignImageId | null, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignImageRole.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignImageRole.ts new file mode 100644 index 000000000..131b0860b --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignImageRole.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type UIDesignImageRole = "Page" | "Section" | "Modal" | "Drawer" | "Popover" | "State" | "Scrolled" | "Detail"; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignSuggestionTreeNode.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignSuggestionTreeNode.ts new file mode 100644 index 000000000..fe0b35bc1 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignSuggestionTreeNode.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { UIDesignImageId } from "./UIDesignImageId"; +import type { UIDesignImageRole } from "./UIDesignImageRole"; + +export type UIDesignSuggestionTreeNode = { id: UIDesignImageId, name: string, description: string, role: UIDesignImageRole, children: Array, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/UIRect.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/UIRect.ts new file mode 100644 index 000000000..d1db67fd7 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/UIRect.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * UI 布局在父级坐标系中的轴对齐矩形。 + * + * `min` 是矩形的最小角,`size` 是沿两个坐标轴的尺寸。这里不规定 Y 轴方向, + * 因而既能用于 Y 轴向上的游戏坐标,也能用于 Y 轴向下的画布坐标。 + */ +export type UIRect = { min: [number, number], size: [number, number], }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/UITree.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/UITree.ts new file mode 100644 index 000000000..fa0cdaf61 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/UITree.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Node } from "./Node"; +import type { UIDesignImageId } from "./UIDesignImageId"; + +export type UITree = { src_ui_design: UIDesignImageId, root: Node, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/UiNodeMoveRequest.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/UiNodeMoveRequest.ts new file mode 100644 index 000000000..11d021c1c --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/UiNodeMoveRequest.ts @@ -0,0 +1,11 @@ +import type { NodeId } from './NodeId'; +import type { UIDesignImageId } from './UIDesignImageId'; + +/** UI 节点移动的完整上下文,避免在树视图与状态层之间散落五个并行参数。 */ +export type UiNodeMoveRequest = { + sourceTreeId: UIDesignImageId; + targetTreeId: UIDesignImageId; + nodeId: NodeId; + targetParentId: NodeId; + targetIndex: number; +}; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/VerticalFillOrigin.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/VerticalFillOrigin.ts new file mode 100644 index 000000000..06f94fc6c --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/VerticalFillOrigin.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type VerticalFillOrigin = "Bottom" | "Top"; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/VerticalTextOverflow.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/VerticalTextOverflow.ts new file mode 100644 index 000000000..8fe6a77dc --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/VerticalTextOverflow.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type VerticalTextOverflow = "Truncate" | "Overflow"; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignStateStore.ts b/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignStateStore.ts new file mode 100644 index 000000000..951d0911c --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignStateStore.ts @@ -0,0 +1,81 @@ +import { invoke } from '@tauri-apps/api/core'; + +import type { State } from './types/State'; +import { EMPTY_UI_EDITOR_STATE } from './useUiEditorState'; + +export type UiDesignStateSnapshot = { revision: number; state: State }; + +export type UiDesignStateSaveResult = + | { + status: 'saved' | 'unchanged'; + state: State; + revision: number; + committedProjectRevision: number; + } + | { status: 'conflict'; current: UiDesignStateSnapshot }; + +export type IUiDesignStateStore = { + load(assetId: string): Promise; + save( + assetId: string, + expectedRevision: number, + state: State, + ): Promise; +}; + +export function createTauriUiDesignStateStore( + projectPath: string, + expectedProjectId: string, +): IUiDesignStateStore { + return { + load(assetId) { + return invoke('load_ui_design_state', { + input: { projectPath, expectedProjectId, assetId }, + }); + }, + save(assetId, expectedRevision, state) { + return invoke('save_ui_design_state', { + input: { + projectPath, + expectedProjectId, + assetId, + expectedRevision, + state, + }, + }); + }, + }; +} + +const snapshots = new Map(); + +export const uiDesignStateStore: IUiDesignStateStore = { + async load(assetId) { + const snapshot = snapshots.get(assetId); + return snapshot + ? structuredClone(snapshot) + : { revision: 0, state: structuredClone(EMPTY_UI_EDITOR_STATE) }; + }, + async save(assetId, expectedRevision, state) { + const current = snapshots.get(assetId) ?? { + revision: 0, + state: structuredClone(EMPTY_UI_EDITOR_STATE), + }; + if (current.revision !== expectedRevision) { + return { status: 'conflict', current: structuredClone(current) }; + } + const next = { + revision: current.revision + 1, + state: structuredClone(state), + }; + snapshots.set(assetId, next); + return { + status: 'saved', + state: structuredClone(next.state), + revision: next.revision, + committedProjectRevision: 0, + }; + }, +}; + +export const EMPTY_UI_DESIGN_STATE = EMPTY_UI_EDITOR_STATE; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignSuggestions.ts b/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignSuggestions.ts new file mode 100644 index 000000000..4727c7cdf --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignSuggestions.ts @@ -0,0 +1,52 @@ +import type { State } from './types/State'; +import type { UIDesignImageId } from './types/UIDesignImageId'; +import type { UIDesignSuggestionTreeNode } from './types/UIDesignSuggestionTreeNode'; + +/** + * Applies semantic suggestions conservatively. The tree carries relationships; + * existing metadata remains authoritative whenever it is already populated. + * Persisted `slave_to` remains the owning Page ID, so nested descendants inherit + * their root Page rather than pointing at an intermediate Section. + */ +export function applyUiDesignSuggestions( + state: State, + suggestions: readonly UIDesignSuggestionTreeNode[], +): State { + const next = structuredClone(state); + + function applyNode( + node: UIDesignSuggestionTreeNode, + pageId: UIDesignImageId | null, + ): void { + const image = next.ui_design_images[node.id]; + if (!image) return; + + if (image.metadata.name.trim().length === 0 && node.name.trim()) { + image.metadata.name = node.name.trim(); + } + if (image.metadata.role === null) { + image.metadata.role = node.role; + } + if (image.metadata.slave_to === null && pageId !== null) { + image.metadata.slave_to = pageId; + } + if ( + image.metadata.description.trim().length === 0 && + node.description.trim() + ) { + image.metadata.description = node.description.trim(); + } + + const effectiveRole = image.metadata.role ?? node.role; + const childPageId = effectiveRole === 'Page' ? node.id : pageId; + for (const child of node.children) { + applyNode(child, childPageId); + } + } + + for (const root of suggestions) { + applyNode(root, null); + } + + return next; +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorFontFaces.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorFontFaces.ts new file mode 100644 index 000000000..5cd8fdae4 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorFontFaces.ts @@ -0,0 +1,172 @@ +import { invoke } from '@tauri-apps/api/core'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import type { FontAsset } from './types/FontAsset'; + +export type UiEditorFontFaceState = { + cssFamily: string; + status: 'error' | 'loaded' | 'loading'; +}; + +export type LoadedUiEditorFontFace = { + cssFamily: string; + face: FontFace; + url: string; +}; + +export function uiEditorPrivateFontFamily(assetId: string) { + const encoded = Array.from(new TextEncoder().encode(assetId), (byte) => + byte.toString(16).padStart(2, '0'), + ).join(''); + return `ui-editor-font-${encoded}`; +} + +function fontMimeType(font: FontAsset) { + switch (font.metadata.format) { + case 'OpenType': + return 'font/otf'; + case 'Woff': + return 'font/woff'; + case 'Woff2': + return 'font/woff2'; + case 'TrueType': + return 'font/ttf'; + } +} + +export async function loadUiEditorFontFace( + projectPath: string, + font: FontAsset, +): Promise { + if (typeof FontFace === 'undefined' || !document.fonts) { + throw new Error('FontFace unavailable'); + } + const cssFamily = uiEditorPrivateFontFamily(font.asset_id); + // eslint-disable-next-line no-console -- temporary font loading diagnostics. + console.info('[ui-editor-font-face] reading font bytes', { + assetId: font.asset_id, + relativePath: font.path, + }); + const bytes = await invoke('read_ui_editor_font_bytes', { + projectPath, + assetId: font.asset_id, + relativePath: font.path, + expectedSha256: font.content_sha256, + }); + // eslint-disable-next-line no-console -- temporary font loading diagnostics. + console.info('[ui-editor-font-face] received font bytes', { + assetId: font.asset_id, + bytes: bytes.byteLength, + }); + const url = URL.createObjectURL( + new Blob([new Uint8Array(bytes)], { type: fontMimeType(font) }), + ); + const face = new FontFace(cssFamily, `url(${JSON.stringify(url)})`, { + style: font.metadata.italic ? 'italic' : 'normal', + weight: String(font.metadata.weight), + }); + try { + // eslint-disable-next-line no-console -- temporary font loading diagnostics. + console.info('[ui-editor-font-face] loading FontFace', { + assetId: font.asset_id, + cssFamily, + }); + await face.load(); + return { cssFamily, face, url }; + } catch (cause) { + document.fonts.delete(face); + URL.revokeObjectURL(url); + // eslint-disable-next-line no-console -- temporary font loading diagnostics. + console.error('[ui-editor-font-face] FontFace load failed', { + assetId: font.asset_id, + relativePath: font.path, + cause, + }); + throw cause; + } +} + +export function useUiEditorFontFaces( + projectPath: string, + fonts: Record, +) { + const fontsRef = useRef(fonts); + fontsRef.current = fonts; + const signature = useMemo( + () => + JSON.stringify( + Object.values(fonts) + .map((font) => [ + font.asset_id, + font.path, + font.content_sha256, + font.metadata.weight, + font.metadata.italic, + font.metadata.format, + ]) + .sort(([left], [right]) => String(left).localeCompare(String(right))), + ), + [fonts], + ); + const [states, setStates] = useState>( + {}, + ); + + useEffect(() => { + const fontAssets = Object.values(fontsRef.current); + let cancelled = false; + const registered: Array<{ face: FontFace; url: string }> = []; + setStates( + Object.fromEntries( + fontAssets.map((font) => [ + font.asset_id, + { + cssFamily: uiEditorPrivateFontFamily(font.asset_id), + status: 'loading' as const, + }, + ]), + ), + ); + + for (const font of fontAssets) { + void (async () => { + const cssFamily = uiEditorPrivateFontFamily(font.asset_id); + let loaded: LoadedUiEditorFontFace | undefined; + try { + loaded = await loadUiEditorFontFace(projectPath, font); + if (cancelled) { + URL.revokeObjectURL(loaded.url); + return; + } + document.fonts.add(loaded.face); + registered.push({ face: loaded.face, url: loaded.url }); + setStates((current) => ({ + ...current, + [font.asset_id]: { cssFamily, status: 'loaded' }, + })); + } catch { + if (loaded) { + document.fonts?.delete(loaded.face); + URL.revokeObjectURL(loaded.url); + } + if (!cancelled) { + setStates((current) => ({ + ...current, + [font.asset_id]: { cssFamily, status: 'error' }, + })); + } + } + })(); + } + + return () => { + cancelled = true; + for (const item of registered) { + document.fonts?.delete(item.face); + URL.revokeObjectURL(item.url); + } + }; + }, [projectPath, signature]); + + return states; +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts new file mode 100644 index 000000000..5bfe37adf --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts @@ -0,0 +1,1446 @@ +import { useCallback, useRef, useState } from 'react'; + +import { + findNodePageContext, + isValidPageRect, + pageRectFromSize, + pageRectSize, + resolveChildrenTransformsForParentRect, + resolvePageRect, + resolveReparentTransform, +} from './nodeTransformGeometry'; +import { validateSpriteBorder } from './spriteBorder'; +import type { ChildrenDisplayMode } from './types/ChildrenDisplayMode'; +import type { Component } from './types/Component'; +import type { FontAsset } from './types/FontAsset'; +import type { FontAssetId } from './types/FontAssetId'; +import type { Node } from './types/Node'; +import type { NodeId } from './types/NodeId'; +import type { NodeMetadata } from './types/NodeMetadata'; +import type { SpriteAsset } from './types/SpriteAsset'; +import type { SpriteAssetId } from './types/SpriteAssetId'; +import type { SpriteBorder } from './types/SpriteBorder'; +import type { State } from './types/State'; +import type { UIDesignImage } from './types/UIDesignImage'; +import type { UIDesignImageId } from './types/UIDesignImageId'; +import type { UIDesignImageRole } from './types/UIDesignImageRole'; +import type { UiNodeMoveRequest } from './types/UiNodeMoveRequest'; + +export const EMPTY_UI_EDITOR_STATE: State = { + ui_trees: [], + ui_design_images: {}, + sprite_assets: {}, + font_assets: {}, +}; + +export type UiEditorOperationFailureReason = + | 'locked' + | 'duplicate' + | 'limit' + | 'missing' + | 'invalid' + | `invalid:${string}`; + +export type UiEditorOperationResult = + | { ok: true; value: T } + | { ok: false; reason: UiEditorOperationFailureReason }; + +type UiEditorOperationFailure = Extract; + +export type NodeMetadataPatch = Partial< + Pick< + NodeMetadata, + | 'name' + | 'description' + | 'layout_status' + | 'components_status' + | 'allow_llm_edit_layout' + | 'allow_llm_edit_component' + > +>; + +export type ComponentIndex = number; + +export type NodeTransformOptions = { + keepChildrenUnchanged?: boolean; +}; + +export type NodeLayoutPatch = Partial; + +type NodeLocation = { + node: Node; + parent: Node | null; + index: number; +}; + +function wouldCreateSlaveToCycle( + images: State['ui_design_images'], + id: UIDesignImageId, + slaveTo: UIDesignImageId, +): boolean { + let current: UIDesignImageId | null = slaveTo; + const visited = new Set(); + while (current !== null) { + if (current === id || visited.has(current)) return true; + visited.add(current); + const image: UIDesignImage | undefined = images[current]; + if (!image) return true; + current = image.metadata.slave_to; + } + return false; +} + +function visitNodes(node: Node, visit: (node: Node) => void): void { + visit(node); + for (const child of node.children) visitNodes(child, visit); +} + +function existingNodeIds(state: State): Set { + const ids = new Set(); + for (const tree of state.ui_trees) { + visitNodes(tree.root, (node) => ids.add(node.id)); + } + return ids; +} + +function createUniqueNodeId(state: State): NodeId { + const ids = existingNodeIds(state); + const randomId = () => + globalThis.crypto?.randomUUID?.() ?? + `node-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + let id = randomId(); + while (ids.has(id)) id = randomId(); + return id; +} + +function createPageRoot(state: State): Node { + return { + id: createUniqueNodeId(state), + layout: { + transform: { + anchor_min: [0, 0], + anchor_max: [1, 1], + offset_min: [0, 0], + offset_max: [0, 0], + }, + custom_minimum_size: [0, 0], + size_flags_horizontal: 1, + size_flags_vertical: 1, + size_flags_stretch_ratio: 1, + container: 'None', + }, + metadata: { + name: '页面根节点', + description: '', + layout_status: 'NoProblem', + components_status: 'NoProblem', + allow_llm_edit_layout: true, + allow_llm_edit_component: true, + source: 'System', + }, + components: [], + children_display_mode: 'Stack', + children: [], + }; +} + +function createHumanNode(state: State): Node { + return { + id: createUniqueNodeId(state), + layout: { + transform: { + anchor_min: [0.5, 0.5], + anchor_max: [0.5, 0.5], + offset_min: [-50, -50], + offset_max: [50, 50], + }, + custom_minimum_size: [0, 0], + size_flags_horizontal: 1, + size_flags_vertical: 1, + size_flags_stretch_ratio: 1, + container: 'None', + }, + metadata: { + name: '新节点', + description: '', + layout_status: 'NoProblem', + components_status: 'NoProblem', + allow_llm_edit_layout: true, + allow_llm_edit_component: true, + source: 'Human', + }, + components: [], + children_display_mode: 'Stack', + children: [], + }; +} + +function synchronizeDesignImageTrees(state: State): void { + const imageIds = new Set(Object.keys(state.ui_design_images)); + state.ui_trees = state.ui_trees.filter((tree) => + imageIds.has(tree.src_ui_design), + ); + for (const [id] of Object.entries(state.ui_design_images)) { + if (!state.ui_trees.some((tree) => tree.src_ui_design === id)) { + state.ui_trees.push({ src_ui_design: id, root: createPageRoot(state) }); + } + } +} + +function findNodeLocation( + node: Node, + id: NodeId, + parent: Node | null = null, + index = 0, +): NodeLocation | null { + if (node.id === id) return { node, parent, index }; + for (let childIndex = 0; childIndex < node.children.length; childIndex += 1) { + const found = findNodeLocation( + node.children[childIndex]!, + id, + node, + childIndex, + ); + if (found) return found; + } + return null; +} + +function containsNode(root: Node, id: NodeId): boolean { + return findNodeLocation(root, id) !== null; +} + +export type DesignImageInput = { + id: UIDesignImageId; + image: UIDesignImage; +}; + +export type RemovalImpact = { + removedResourceCount: number; + removedTreeCount: number; + clearedSlaveToCount: number; + clearedTargetGraphicCount: number; + clearedFontCount: number; +}; + +function cloneState(state: State): State { + return structuredClone(state); +} + +function sameResource(left: T, right: T): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function visitComponents(nodes: Node[], visit: (component: Component) => void) { + for (const node of nodes) { + for (const component of node.components) { + visit(component); + } + visitComponents(node.children, visit); + } +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function isFinitePositive(value: unknown): value is number { + return isFiniteNumber(value) && value > 0; +} + +function isEnumValue( + value: unknown, + values: readonly T[], +): value is T { + return typeof value === 'string' && values.includes(value as T); +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' + ? (value as Record) + : null; +} + +function isBoundFontSource(value: unknown): value is { Bound: FontAssetId } { + const record = asRecord(value); + return ( + record !== null && + Object.keys(record).length === 1 && + typeof record.Bound === 'string' && + record.Bound.length > 0 && + record.Bound.trim() === record.Bound + ); +} + +function isValidFontSource(value: unknown): boolean { + return value === 'SystemFont' || isBoundFontSource(value); +} + +function isBoundToFont( + value: unknown, + id: FontAssetId, +): value is { Bound: FontAssetId } { + return isBoundFontSource(value) && value.Bound === id; +} + +function isValidFillMethod(method: unknown): boolean { + const record = asRecord(method); + if (!record) return false; + if ('Horizontal' in record) { + return isEnumValue(record.Horizontal, ['Left', 'Right'] as const); + } + if ('Vertical' in record) { + return isEnumValue(record.Vertical, ['Bottom', 'Top'] as const); + } + for (const key of ['Radial90', 'Radial180', 'Radial360'] as const) { + if (key in record) { + const radial = asRecord(record[key]); + if (!radial) return false; + return ( + typeof radial.clockwise === 'boolean' && + isEnumValue( + radial.origin, + key === 'Radial90' + ? (['BottomLeft', 'TopLeft', 'TopRight', 'BottomRight'] as const) + : key === 'Radial180' + ? (['Bottom', 'Left', 'Top', 'Right'] as const) + : (['Bottom', 'Right', 'Top', 'Left'] as const), + ) + ); + } + } + return false; +} + +function isValidImageType(imageType: unknown): boolean { + const record = asRecord(imageType); + if (!record) return false; + if ('Simple' in record) { + const value = asRecord(record.Simple); + if (!value) return false; + return typeof value.preserve_aspect === 'boolean'; + } + if ('Sliced' in record || 'Tiled' in record) { + const value = asRecord('Sliced' in record ? record.Sliced : record.Tiled); + return ( + value !== null && + typeof value.fill_center === 'boolean' && + isFinitePositive(value.pixels_per_unit_multiplier) + ); + } + if ('Filled' in record) { + const value = asRecord(record.Filled); + return ( + value !== null && + typeof value.preserve_aspect === 'boolean' && + isFiniteNumber(value.amount) && + value.amount >= 0 && + value.amount <= 1 && + isValidFillMethod(value.method) + ); + } + return false; +} + +function isValidComponent(component: Component): boolean { + if ('Image' in component) { + return ( + (component.Image.target_graphic === null || + typeof component.Image.target_graphic === 'string') && + isValidImageType(component.Image.image_type) + ); + } + if ('Text' in component) { + const text = component.Text; + const colorValid = + Array.isArray(text.color) && + text.color.length === 4 && + text.color.every( + (channel) => + Number.isInteger(channel) && channel >= 0 && channel <= 255, + ); + const sizingValid = + ('Fixed' in text.font_sizing && + isFinitePositive(text.font_sizing.Fixed)) || + ('BestFit' in text.font_sizing && + isFinitePositive(text.font_sizing.BestFit.min) && + isFinitePositive(text.font_sizing.BestFit.max) && + text.font_sizing.BestFit.min <= text.font_sizing.BestFit.max); + return ( + typeof text.content === 'string' && + isValidFontSource(text.font) && + sizingValid && + colorValid && + isEnumValue(text.alignment, [ + 'UpperLeft', + 'UpperCenter', + 'UpperRight', + 'MiddleLeft', + 'MiddleCenter', + 'MiddleRight', + 'LowerLeft', + 'LowerCenter', + 'LowerRight', + ] as const) && + isEnumValue(text.horizontal_overflow, ['Wrap', 'Overflow'] as const) && + isEnumValue(text.vertical_overflow, ['Truncate', 'Overflow'] as const) && + isFinitePositive(text.line_spacing) + ); + } + return false; +} + +export function designImageRemovalImpact( + state: State, + id: UIDesignImageId, +): RemovalImpact { + return { + removedResourceCount: id in state.ui_design_images ? 1 : 0, + removedTreeCount: state.ui_trees.filter((tree) => tree.src_ui_design === id) + .length, + clearedSlaveToCount: Object.values(state.ui_design_images).filter( + (image) => image.metadata.slave_to === id, + ).length, + clearedTargetGraphicCount: 0, + clearedFontCount: 0, + }; +} + +export function spriteAssetRemovalImpact( + state: State, + id: SpriteAssetId, +): RemovalImpact { + let clearedTargetGraphicCount = 0; + for (const tree of state.ui_trees) { + visitComponents([tree.root], (component) => { + if ('Image' in component && component.Image.target_graphic === id) { + clearedTargetGraphicCount += 1; + } + }); + } + return { + removedResourceCount: id in state.sprite_assets ? 1 : 0, + removedTreeCount: 0, + clearedSlaveToCount: 0, + clearedTargetGraphicCount, + clearedFontCount: 0, + }; +} + +export function fontAssetRemovalImpact( + state: State, + id: FontAssetId, +): RemovalImpact { + let clearedFontCount = 0; + for (const tree of state.ui_trees) { + visitComponents([tree.root], (component) => { + if ('Text' in component && isBoundToFont(component.Text.font, id)) { + clearedFontCount += 1; + } + }); + } + return { + removedResourceCount: id in state.font_assets ? 1 : 0, + removedTreeCount: 0, + clearedSlaveToCount: 0, + clearedTargetGraphicCount: 0, + clearedFontCount, + }; +} + +function imageResourceValidationError(image: UIDesignImage) { + if (image.path.trim().length === 0) return '缺少图片路径'; + if (!image.pixel_size.every((value) => Number.isFinite(value) && value > 0)) { + return `图片尺寸无效:${JSON.stringify(image.pixel_size)}`; + } + if (!Number.isFinite(image.pixels_per_unit) || image.pixels_per_unit <= 0) { + return `像素单位无效:${image.pixels_per_unit}`; + } + return null; +} + +function spriteResourceValidationError(sprite: SpriteAsset) { + if (sprite.asset_id.trim().length === 0) return '缺少素材 ID'; + if (sprite.path.trim().length === 0) return '缺少素材路径'; + if ( + !sprite.pixel_size.every((value) => Number.isFinite(value) && value > 0) + ) { + return `素材尺寸无效:${JSON.stringify(sprite.pixel_size)}`; + } + if (!Number.isFinite(sprite.pixels_per_unit) || sprite.pixels_per_unit <= 0) { + return `像素单位无效:${sprite.pixels_per_unit}`; + } + const border = validateSpriteBorder(sprite.pixel_size, sprite.border); + return border.ok ? null : border.message; +} + +function fontResourceValidationError(font: FontAsset) { + if (font.asset_id.trim().length === 0) return '缺少字体 ID'; + if (font.path.trim().length === 0) return '缺少字体路径'; + if (!/^[a-f0-9]{64}$/u.test(font.content_sha256)) return '字体摘要无效'; + if (font.metadata.family_name.trim().length === 0) return '缺少字体家族名'; + if (font.metadata.face_name.trim().length === 0) return '缺少字体面名称'; + if (font.metadata.source_file_name.trim().length === 0) { + return '缺少字体源文件名'; + } + if ( + !Number.isInteger(font.metadata.weight) || + font.metadata.weight < 1 || + font.metadata.weight > 1000 + ) { + return '字体 weight 无效'; + } + if ( + !['TrueType', 'OpenType', 'Woff', 'Woff2'].includes(font.metadata.format) + ) { + return '字体格式无效'; + } + return null; +} + +export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { + const [state, setState] = useState(() => { + const next = cloneState(initialState); + synchronizeDesignImageTrees(next); + return next; + }); + const [isLocked, setIsLocked] = useState(false); + const stateRef = useRef(state); + const isLockedRef = useRef(false); + stateRef.current = state; + + const commit = useCallback((nextState: State) => { + stateRef.current = nextState; + setState(nextState); + }, []); + + const guard = useCallback((): UiEditorOperationFailure | null => { + // Every semantic write exits before reading or committing State while locked. + return isLockedRef.current ? { ok: false, reason: 'locked' } : null; + }, []); + + const runWithStateLocked = useCallback( + async (operation: (snapshot: State) => Promise): Promise => { + if (isLockedRef.current) { + throw new Error('UI editor State is already locked'); + } + + // Acquire synchronously so another operation cannot enter before React + // renders isLocked=true. The callback receives an isolated, stable + // snapshot; all editor writes remain blocked until it settles. + isLockedRef.current = true; + setIsLocked(true); + const snapshot = cloneState(stateRef.current); + try { + return await operation(snapshot); + } finally { + isLockedRef.current = false; + setIsLocked(false); + } + }, + [], + ); + + const setImageName = useCallback( + (id: UIDesignImageId, name: string): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + if (!(id in current.ui_design_images)) { + return { ok: false, reason: 'missing' }; + } + const next = cloneState(current); + next.ui_design_images[id]!.metadata.name = name; + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const setImageDescription = useCallback( + (id: UIDesignImageId, description: string): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + if (!(id in current.ui_design_images)) { + return { ok: false, reason: 'missing' }; + } + const next = cloneState(current); + next.ui_design_images[id]!.metadata.description = description; + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const setImageRole = useCallback( + ( + id: UIDesignImageId, + role: UIDesignImageRole | null, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + if (!(id in current.ui_design_images)) { + return { ok: false, reason: 'missing' }; + } + const next = cloneState(current); + next.ui_design_images[id]!.metadata.role = role; + synchronizeDesignImageTrees(next); + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const setImageSlaveTo = useCallback( + ( + id: UIDesignImageId, + slaveTo: UIDesignImageId | null, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + if (!(id in current.ui_design_images)) { + return { ok: false, reason: 'missing' }; + } + if (slaveTo !== null && !(slaveTo in current.ui_design_images)) { + return { ok: false, reason: 'missing' }; + } + if ( + slaveTo !== null && + wouldCreateSlaveToCycle(current.ui_design_images, id, slaveTo) + ) { + return { ok: false, reason: 'invalid:slave_to 不能形成循环' }; + } + const next = cloneState(current); + next.ui_design_images[id]!.metadata.slave_to = slaveTo; + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const addDesignImages = useCallback( + (entries: readonly DesignImageInput[]): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + const ids = entries.map((entry) => entry.id); + if (new Set(ids).size !== ids.length) { + return { ok: false, reason: 'duplicate' }; + } + if (ids.some((id) => id in current.ui_design_images)) { + return { ok: false, reason: 'duplicate' }; + } + if (Object.keys(current.ui_design_images).length + entries.length > 4) { + return { ok: false, reason: 'limit' }; + } + const invalidImage = entries + .map((entry) => ({ + entry, + error: imageResourceValidationError(entry.image), + })) + .find((item) => item.error); + if (invalidImage?.error) { + return { + ok: false, + reason: `invalid:${invalidImage.entry.id}:${invalidImage.error}`, + }; + } + const next = cloneState(current); + for (const entry of entries) { + next.ui_design_images[entry.id] = structuredClone(entry.image); + } + synchronizeDesignImageTrees(next); + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const addSpriteAssets = useCallback( + (assets: readonly SpriteAsset[]): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + const unique = new Map(); + for (const asset of assets) { + const candidate = + unique.get(asset.asset_id) ?? current.sprite_assets[asset.asset_id]; + if (candidate && !sameResource(candidate, asset)) { + return { ok: false, reason: 'duplicate' }; + } + unique.set(asset.asset_id, asset); + } + const invalidSprite = [...unique.values()] + .map((asset) => ({ + asset, + error: spriteResourceValidationError(asset), + })) + .find((item) => item.error); + if (invalidSprite?.error) { + return { + ok: false, + reason: `invalid:${invalidSprite.asset.asset_id}:${invalidSprite.error}`, + }; + } + const next = cloneState(current); + for (const asset of unique.values()) { + next.sprite_assets[asset.asset_id] = structuredClone(asset); + } + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const addFontAssets = useCallback( + (assets: readonly FontAsset[]): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + const unique = new Map(); + for (const asset of assets) { + const candidate = + unique.get(asset.asset_id) ?? current.font_assets[asset.asset_id]; + if (candidate && !sameResource(candidate, asset)) { + return { ok: false, reason: 'duplicate' }; + } + unique.set(asset.asset_id, asset); + } + const newCount = [...unique.keys()].filter( + (id) => !(id in current.font_assets), + ).length; + if (Object.keys(current.font_assets).length + newCount > 64) { + return { ok: false, reason: 'limit' }; + } + const invalidFont = [...unique.values()] + .map((asset) => ({ asset, error: fontResourceValidationError(asset) })) + .find((item) => item.error); + if (invalidFont?.error) { + return { + ok: false, + reason: `invalid:${invalidFont.asset.asset_id}:${invalidFont.error}`, + }; + } + const next = cloneState(current); + for (const asset of unique.values()) { + next.font_assets[asset.asset_id] = structuredClone(asset); + } + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const setSpriteName = useCallback( + (id: SpriteAssetId, name: string): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + if (!(id in current.sprite_assets)) { + return { ok: false, reason: 'missing' }; + } + const next = cloneState(current); + next.sprite_assets[id]!.metadata.name = name; + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const setSpriteAssetType = useCallback( + (id: SpriteAssetId, assetType: string): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + if (!(id in current.sprite_assets)) { + return { ok: false, reason: 'missing' }; + } + const next = cloneState(current); + next.sprite_assets[id]!.metadata.asset_type = assetType; + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const setSpriteBorder = useCallback( + (id: SpriteAssetId, border: SpriteBorder): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + const sprite = current.sprite_assets[id]; + if (!sprite) return { ok: false, reason: 'missing' }; + if (!validateSpriteBorder(sprite.pixel_size, border).ok) { + return { ok: false, reason: 'invalid' }; + } + const next = cloneState(current); + next.sprite_assets[id]!.border = structuredClone(border); + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const insertNode = useCallback( + ( + treeId: UIDesignImageId, + parentId: NodeId, + targetIndex?: number, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + const tree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + if (!tree) return { ok: false, reason: 'missing' }; + const parent = findNodeLocation(tree.root, parentId); + if (!parent) return { ok: false, reason: 'missing' }; + const index = targetIndex ?? parent.node.children.length; + if ( + !Number.isInteger(index) || + index < 0 || + index > parent.node.children.length + ) { + return { ok: false, reason: 'invalid' }; + } + const next = cloneState(current); + const nextTree = next.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + )!; + const nextParent = findNodeLocation(nextTree.root, parentId)!.node; + const node = createHumanNode(next); + nextParent.children.splice(index, 0, node); + commit(next); + return { ok: true, value: node.id }; + }, + [commit, guard], + ); + + const insertNodeAfter = useCallback( + ( + treeId: UIDesignImageId, + nodeId: NodeId, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + const tree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + if (!tree) return { ok: false, reason: 'missing' }; + const location = findNodeLocation(tree.root, nodeId); + if (!location || !location.parent) { + return { ok: false, reason: 'invalid' }; + } + return insertNode(treeId, location.parent.id, location.index + 1); + }, + [guard, insertNode], + ); + + const deleteNode = useCallback( + (treeId: UIDesignImageId, nodeId: NodeId): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + const tree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + if (!tree) return { ok: false, reason: 'missing' }; + const location = findNodeLocation(tree.root, nodeId); + if (!location || !location.parent) { + return { ok: false, reason: 'invalid' }; + } + const next = cloneState(current); + const nextTree = next.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + )!; + const nextLocation = findNodeLocation(nextTree.root, nodeId)!; + nextLocation.parent!.children.splice(nextLocation.index, 1); + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const setNodeTransform = useCallback( + ( + treeId: UIDesignImageId, + nodeId: NodeId, + transform: Node['layout']['transform'], + options: NodeTransformOptions = {}, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + if ( + !Object.values(transform).every((values) => + values.every(Number.isFinite), + ) + ) { + return { ok: false, reason: 'invalid' }; + } + const current = stateRef.current; + const tree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + if (!tree) return { ok: false, reason: 'missing' }; + const location = findNodeLocation(tree.root, nodeId); + if (!location) return { ok: false, reason: 'missing' }; + // The page root is a structural ordinary Node, but its containing page + // rectangle is fixed. Moving or resizing it would invalidate every + // descendant's coordinate space. + if (location.node.id === tree.root.id) { + return { ok: false, reason: 'invalid' }; + } + const next = cloneState(current); + const nextTree = next.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + )!; + const nextNode = findNodeLocation(nextTree.root, nodeId)!.node; + nextNode.layout.transform = structuredClone(transform); + if (options.keepChildrenUnchanged && location.parent) { + const image = current.ui_design_images[treeId]; + if ( + !image || + !Number.isFinite(image.pixels_per_unit) || + image.pixels_per_unit <= 0 + ) { + return { ok: false, reason: 'invalid' }; + } + const pageRect = pageRectFromSize([ + image.pixel_size[0] / image.pixels_per_unit, + image.pixel_size[1] / image.pixels_per_unit, + ]); + const oldParentContext = findNodePageContext( + tree.root, + location.parent.id, + pageRect, + ); + const oldNodeContext = findNodePageContext(tree.root, nodeId, pageRect); + if ( + !oldParentContext || + !isValidPageRect(oldParentContext.rect) || + !oldNodeContext || + !isValidPageRect(oldNodeContext.rect) + ) { + return { ok: false, reason: 'invalid' }; + } + const newNodeRect = resolvePageRect(transform, oldParentContext.rect); + if (!isValidPageRect(newNodeRect)) { + return { ok: false, reason: 'invalid' }; + } + const childTransforms = resolveChildrenTransformsForParentRect( + location.node.children, + oldNodeContext.rect, + newNodeRect, + ); + for (const child of childTransforms) { + const nextChild = nextNode.children.find( + (candidate) => candidate.id === child.id, + ); + if (!nextChild) { + return { ok: false, reason: 'invalid' }; + } + nextChild.layout.transform = child.transform; + } + } + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const setNodeLayout = useCallback( + ( + treeId: UIDesignImageId, + nodeId: NodeId, + patch: NodeLayoutPatch, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + const tree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + if (!tree || !findNodeLocation(tree.root, nodeId)) + return { ok: false, reason: 'missing' }; + const next = cloneState(current); + const node = findNodeLocation( + next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)! + .root, + nodeId, + )!.node; + const layout = { ...node.layout, ...structuredClone(patch) }; + if ( + !layout.custom_minimum_size.every( + (value) => Number.isFinite(value) && value >= 0, + ) || + !Number.isFinite(layout.size_flags_stretch_ratio) || + layout.size_flags_stretch_ratio <= 0 || + ![0, 1, 2, 3, 4, 8].includes(layout.size_flags_horizontal) || + ![0, 1, 2, 3, 4, 8].includes(layout.size_flags_vertical) + ) { + return { ok: false, reason: 'invalid' }; + } + node.layout = layout; + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const setNodeComponents = useCallback( + ( + treeId: UIDesignImageId, + nodeId: NodeId, + components: Component[], + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + if (!components.every(isValidComponent)) { + return { ok: false, reason: 'invalid' }; + } + const current = stateRef.current; + const tree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + if (!tree) return { ok: false, reason: 'missing' }; + const location = findNodeLocation(tree.root, nodeId); + if (!location) return { ok: false, reason: 'missing' }; + const next = cloneState(current); + const nextTree = next.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + )!; + findNodeLocation(nextTree.root, nodeId)!.node.components = + structuredClone(components); + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const insertComponent = useCallback( + ( + treeId: UIDesignImageId, + nodeId: NodeId, + index: ComponentIndex, + component: Component, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + if ( + !isValidComponent(component) || + !Number.isInteger(index) || + index < 0 + ) { + return { ok: false, reason: 'invalid' }; + } + const current = stateRef.current; + const tree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + if (!tree) return { ok: false, reason: 'missing' }; + const location = findNodeLocation(tree.root, nodeId); + if (!location) return { ok: false, reason: 'missing' }; + if (index > location.node.components.length) { + return { ok: false, reason: 'invalid' }; + } + const next = cloneState(current); + const nextNode = findNodeLocation( + next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)! + .root, + nodeId, + )!.node; + nextNode.components.splice(index, 0, structuredClone(component)); + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const deleteComponent = useCallback( + ( + treeId: UIDesignImageId, + nodeId: NodeId, + index: ComponentIndex, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + if (!Number.isInteger(index) || index < 0) + return { ok: false, reason: 'invalid' }; + const current = stateRef.current; + const tree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + if (!tree) return { ok: false, reason: 'missing' }; + const location = findNodeLocation(tree.root, nodeId); + if (!location) return { ok: false, reason: 'missing' }; + if (index >= location.node.components.length) { + return { ok: false, reason: 'invalid' }; + } + const next = cloneState(current); + const nextNode = findNodeLocation( + next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)! + .root, + nodeId, + )!.node; + nextNode.components.splice(index, 1); + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const moveComponent = useCallback( + ( + treeId: UIDesignImageId, + nodeId: NodeId, + fromIndex: ComponentIndex, + toIndex: ComponentIndex, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + if ( + !Number.isInteger(fromIndex) || + !Number.isInteger(toIndex) || + fromIndex < 0 || + toIndex < 0 + ) { + return { ok: false, reason: 'invalid' }; + } + const current = stateRef.current; + const tree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + if (!tree) return { ok: false, reason: 'missing' }; + const location = findNodeLocation(tree.root, nodeId); + if (!location) return { ok: false, reason: 'missing' }; + if ( + fromIndex >= location.node.components.length || + toIndex >= location.node.components.length + ) { + return { ok: false, reason: 'invalid' }; + } + if (fromIndex === toIndex) return { ok: true, value: undefined }; + const next = cloneState(current); + const nextNode = findNodeLocation( + next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)! + .root, + nodeId, + )!.node; + const [component] = nextNode.components.splice(fromIndex, 1); + if (!component) return { ok: false, reason: 'invalid' }; + nextNode.components.splice(toIndex, 0, component); + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const setNodeMetadata = useCallback( + ( + treeId: UIDesignImageId, + nodeId: NodeId, + patch: NodeMetadataPatch, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + const tree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + if (!tree) return { ok: false, reason: 'missing' }; + if (!findNodeLocation(tree.root, nodeId)) + return { ok: false, reason: 'missing' }; + const next = cloneState(current); + const node = findNodeLocation( + next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)! + .root, + nodeId, + )!.node; + if (patch.name !== undefined) node.metadata.name = patch.name; + if (patch.description !== undefined) + node.metadata.description = patch.description; + if (patch.layout_status !== undefined) + node.metadata.layout_status = patch.layout_status; + if (patch.components_status !== undefined) + node.metadata.components_status = patch.components_status; + if (patch.allow_llm_edit_layout !== undefined) + node.metadata.allow_llm_edit_layout = patch.allow_llm_edit_layout; + if (patch.allow_llm_edit_component !== undefined) + node.metadata.allow_llm_edit_component = patch.allow_llm_edit_component; + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const setNodeChildrenDisplayMode = useCallback( + ( + treeId: UIDesignImageId, + nodeId: NodeId, + childrenDisplayMode: ChildrenDisplayMode, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + const tree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + if (!tree) return { ok: false, reason: 'missing' }; + if (!findNodeLocation(tree.root, nodeId)) { + return { ok: false, reason: 'missing' }; + } + const next = cloneState(current); + const node = findNodeLocation( + next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)! + .root, + nodeId, + )!.node; + node.children_display_mode = childrenDisplayMode; + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const moveNode = useCallback( + ({ + sourceTreeId, + targetTreeId, + nodeId, + targetParentId, + targetIndex, + }: UiNodeMoveRequest): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + if (!Number.isInteger(targetIndex) || targetIndex < 0) { + return { ok: false, reason: 'invalid' }; + } + const current = stateRef.current; + const sourceTree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === sourceTreeId, + ); + const targetTree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === targetTreeId, + ); + if (!sourceTree || !targetTree) return { ok: false, reason: 'missing' }; + const source = findNodeLocation(sourceTree.root, nodeId); + const target = findNodeLocation(targetTree.root, targetParentId); + if (!source || !target || source.node.id === sourceTree.root.id) { + return { ok: false, reason: 'missing' }; + } + if ( + sourceTreeId !== targetTreeId && + containsNode(targetTree.root, nodeId) + ) { + return { ok: false, reason: 'duplicate' }; + } + if ( + sourceTreeId === targetTreeId && + (source.node.id === target.node.id || + containsNode(source.node, targetParentId)) + ) { + return { ok: false, reason: 'invalid' }; + } + let nextTransform: Node['layout']['transform'] | null = null; + if (sourceTreeId === targetTreeId) { + const image = current.ui_design_images[sourceTreeId]; + if (!image) return { ok: false, reason: 'missing' }; + const logicalSize = pageRectFromSize([ + image.pixel_size[0] / image.pixels_per_unit, + image.pixel_size[1] / image.pixels_per_unit, + ]); + if ( + !isValidPageRect(logicalSize) || + pageRectSize(logicalSize).some((value) => value <= 0) + ) { + return { ok: false, reason: 'invalid' }; + } + const pageContext = findNodePageContext( + sourceTree.root, + nodeId, + logicalSize, + ); + const targetContext = findNodePageContext( + targetTree.root, + targetParentId, + logicalSize, + ); + if ( + !pageContext || + pageRectSize(pageContext.rect).some((value) => value <= 0) || + !targetContext || + pageRectSize(targetContext.rect).some((value) => value <= 0) + ) { + return { ok: false, reason: 'invalid' }; + } + nextTransform = resolveReparentTransform( + source.node.layout.transform, + pageContext.rect, + targetContext.rect, + ); + } + const next = cloneState(current); + const nextSourceTree = next.ui_trees.find( + (candidate) => candidate.src_ui_design === sourceTreeId, + )!; + const nextTargetTree = next.ui_trees.find( + (candidate) => candidate.src_ui_design === targetTreeId, + )!; + const nextSource = findNodeLocation(nextSourceTree.root, nodeId)!; + const nextTarget = findNodeLocation( + nextTargetTree.root, + targetParentId, + )!.node; + if (targetIndex > nextTarget.children.length) { + return { ok: false, reason: 'invalid' }; + } + const [moved] = nextSource.parent + ? nextSource.parent.children.splice(nextSource.index, 1) + : []; + if (!moved) return { ok: false, reason: 'invalid' }; + // 跨树时只保留节点原本的局部 transform;源、目标界面图的画布和父级坐标空间独立, + // “保留 transform”不等于承诺页面位置稳定。只有同树移动才在上面做页面矩形换算。 + if (nextTransform) moved.layout.transform = nextTransform; + nextTarget.children.splice(targetIndex, 0, moved); + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + + const removeDesignImage = useCallback( + ( + id: UIDesignImageId, + options: { dryRun: boolean }, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + if (!(id in current.ui_design_images)) { + return { ok: false, reason: 'missing' }; + } + const impact = designImageRemovalImpact(current, id); + if (options.dryRun) return { ok: true, value: impact }; + const next = cloneState(current); + delete next.ui_design_images[id]; + next.ui_trees = next.ui_trees.filter((tree) => tree.src_ui_design !== id); + for (const image of Object.values(next.ui_design_images)) { + if (image.metadata.slave_to === id) image.metadata.slave_to = null; + } + commit(next); + return { ok: true, value: impact }; + }, + [commit, guard], + ); + + const removeSpriteAsset = useCallback( + ( + id: SpriteAssetId, + options: { dryRun: boolean }, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + if (!(id in current.sprite_assets)) { + return { ok: false, reason: 'missing' }; + } + const impact = spriteAssetRemovalImpact(current, id); + if (options.dryRun) return { ok: true, value: impact }; + const next = cloneState(current); + delete next.sprite_assets[id]; + for (const tree of next.ui_trees) { + visitComponents([tree.root], (component) => { + if ('Image' in component && component.Image.target_graphic === id) { + component.Image.target_graphic = null; + } + }); + } + commit(next); + return { ok: true, value: impact }; + }, + [commit, guard], + ); + + const removeFontAsset = useCallback( + ( + id: FontAssetId, + options: { dryRun: boolean }, + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + if (!(id in current.font_assets)) { + return { ok: false, reason: 'missing' }; + } + const impact = fontAssetRemovalImpact(current, id); + if (options.dryRun) return { ok: true, value: impact }; + const next = cloneState(current); + delete next.font_assets[id]; + for (const tree of next.ui_trees) { + visitComponents([tree.root], (component) => { + if ('Text' in component && isBoundToFont(component.Text.font, id)) { + component.Text.font = 'SystemFont'; + } + }); + } + commit(next); + return { ok: true, value: impact }; + }, + [commit, guard], + ); + + const clearState = useCallback((): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + commit(cloneState(EMPTY_UI_EDITOR_STATE)); + return { ok: true, value: undefined }; + }, [commit, guard]); + + const replaceState = useCallback( + (nextState: State) => { + const next = cloneState(nextState); + synchronizeDesignImageTrees(next); + commit(next); + }, + [commit], + ); + + return { + state, + + isLocked, + runWithStateLocked, + setImageName, + setImageDescription, + setImageRole, + setImageSlaveTo, + addDesignImages, + addSpriteAssets, + addFontAssets, + setSpriteName, + setSpriteAssetType, + setSpriteBorder, + insertNode, + insertNodeAfter, + deleteNode, + setNodeTransform, + setNodeLayout, + setNodeComponents, + insertComponent, + deleteComponent, + moveComponent, + setNodeMetadata, + setNodeChildrenDisplayMode, + moveNode, + removeDesignImage, + removeSpriteAsset, + removeFontAsset, + clearState, + replaceState, + }; +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts b/apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts new file mode 100644 index 000000000..92ac8d202 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts @@ -0,0 +1,260 @@ +import type { CSSProperties } from 'react'; + +import type { FillMethod } from '../types/FillMethod'; +import type { HorizontalTextOverflow } from '../types/HorizontalTextOverflow'; +import type { ImageComponent } from '../types/ImageComponent'; +import type { ImageType } from '../types/ImageType'; +import type { SpriteAsset } from '../types/SpriteAsset'; +import type { TextAlignment } from '../types/TextAlignment'; +import type { TextComponent } from '../types/TextComponent'; +import type { VerticalTextOverflow } from '../types/VerticalTextOverflow'; +import { + fontStyleToCss, + initialFontSize, + SYSTEM_UI_FONT_STACK, +} from './textStyleToCss'; + +export { SYSTEM_UI_FONT_STACK } from './textStyleToCss'; + +export type ImageRenderModel = { + kind: 'image' | 'background' | 'filled'; + src: string; + style: CSSProperties; + imageStyle?: CSSProperties; + diagnostic?: string; +}; + +function finite(value: number, field: string): number { + if (!Number.isFinite(value)) { + throw new RangeError(`${field} must be finite`); + } + return value; +} + +function positive(value: number, field: string): number { + finite(value, field); + if (value <= 0) throw new RangeError(`${field} must be positive`); + return value; +} + +function clamp01(value: number): number { + return Math.min(1, Math.max(0, finite(value, 'amount'))); +} + +function logicalSize( + sprite: SpriteAsset, + multiplier = 1, +): { width: number; height: number } { + const pixelsPerUnit = positive(sprite.pixels_per_unit, 'pixels_per_unit'); + const scale = positive(multiplier, 'pixels_per_unit_multiplier'); + return { + width: + positive(sprite.pixel_size[0], 'pixel_size[0]') / (pixelsPerUnit * scale), + height: + positive(sprite.pixel_size[1], 'pixel_size[1]') / (pixelsPerUnit * scale), + }; +} + +function fillClipPath(method: FillMethod, amount: number): string | undefined { + if (amount >= 1) return undefined; + if (amount <= 0) return 'inset(0 0 0 100%)'; + + if ('Horizontal' in method) { + return method.Horizontal === 'Left' + ? `inset(0 ${(1 - amount) * 100}% 0 0)` + : `inset(0 0 0 ${(1 - amount) * 100}%)`; + } + if ('Vertical' in method) { + return method.Vertical === 'Top' + ? `inset(0 0 ${(1 - amount) * 100}% 0)` + : `inset(${(1 - amount) * 100}% 0 0 0)`; + } + + const [kind, config] = + 'Radial90' in method + ? (['Radial90', method.Radial90] as const) + : 'Radial180' in method + ? (['Radial180', method.Radial180] as const) + : (['Radial360', method.Radial360] as const); + const maxSweep = kind === 'Radial90' ? 90 : kind === 'Radial180' ? 180 : 360; + const sweep = amount * maxSweep; + const origin = config.origin; + const originAngle = + origin === 'Top' || origin === 'TopLeft' || origin === 'TopRight' + ? 0 + : origin === 'Right' || origin === 'BottomRight' + ? 90 + : origin === 'Bottom' || origin === 'BottomLeft' + ? 180 + : 270; + const start = config.clockwise ? originAngle : originAngle - sweep; + // CSS conic gradients provide a deterministic browser preview for radial + // fills. Exact engine parity is intentionally deferred. + return `conic-gradient(from ${start}deg, #000 0deg ${sweep}deg, transparent ${sweep}deg 360deg)`; +} + +function imageFitStyle(preserveAspect: boolean): CSSProperties { + return { + width: '100%', + height: '100%', + objectFit: preserveAspect ? 'contain' : 'fill', + }; +} + +function imageTypeModel( + imageType: ImageType, + sprite: SpriteAsset, + src: string, +): ImageRenderModel { + if ('Simple' in imageType) { + return { + kind: 'image', + src, + style: { overflow: 'hidden' }, + imageStyle: imageFitStyle(imageType.Simple.preserve_aspect), + }; + } + + if ('Filled' in imageType) { + const amount = clamp01(imageType.Filled.amount); + const clip = fillClipPath(imageType.Filled.method, amount); + const imageStyle = imageFitStyle(imageType.Filled.preserve_aspect); + if (clip?.startsWith('conic-gradient')) { + imageStyle.maskImage = clip; + imageStyle.WebkitMaskImage = clip; + imageStyle.clipPath = undefined; + } else { + imageStyle.clipPath = clip; + } + return { + kind: 'filled', + src, + style: { overflow: 'hidden' }, + imageStyle, + }; + } + + const multiplier = + 'Sliced' in imageType + ? imageType.Sliced.pixels_per_unit_multiplier + : imageType.Tiled.pixels_per_unit_multiplier; + const size = logicalSize(sprite, multiplier); + + if ('Tiled' in imageType) { + return { + kind: 'background', + src, + style: { + backgroundImage: `url("${src}")`, + backgroundRepeat: 'repeat', + backgroundPosition: 'top left', + backgroundSize: `${size.width}px ${size.height}px`, + overflow: 'hidden', + }, + diagnostic: imageType.Tiled.fill_center + ? undefined + : 'Tiled fill_center=false uses the browser repeat fallback.', + }; + } + + const border = sprite.border; + const ppu = positive(sprite.pixels_per_unit, 'pixels_per_unit'); + const logicalBorderScale = + ppu * + positive( + imageType.Sliced.pixels_per_unit_multiplier, + 'pixels_per_unit_multiplier', + ); + const borderWidths = { + borderTopWidth: `${border.top / logicalBorderScale}px`, + borderRightWidth: `${border.right / logicalBorderScale}px`, + borderBottomWidth: `${border.bottom / logicalBorderScale}px`, + borderLeftWidth: `${border.left / logicalBorderScale}px`, + }; + return { + kind: 'background', + src, + style: { + ...borderWidths, + borderStyle: 'solid', + borderImageSource: `url("${src}")`, + borderImageSlice: `${border.top} ${border.right} ${border.bottom} ${border.left}${imageType.Sliced.fill_center ? ' fill' : ''}`, + borderImageWidth: `${border.top / logicalBorderScale}px ${border.right / logicalBorderScale}px ${border.bottom / logicalBorderScale}px ${border.left / logicalBorderScale}px`, + borderImageRepeat: 'stretch', + overflow: 'hidden', + }, + diagnostic: + border.left + border.right >= sprite.pixel_size[0] || + border.top + border.bottom >= sprite.pixel_size[1] + ? 'Sprite border leaves no center pixel; using best-effort CSS slicing.' + : undefined, + }; +} + +export function imageComponentToRenderModel( + component: ImageComponent, + sprite: SpriteAsset | null, + src: string | null, +): ImageRenderModel | null { + if (!src) return null; + if (!sprite) return null; + try { + return imageTypeModel(component.image_type, sprite, src); + } catch { + return { + kind: 'image', + src, + style: { overflow: 'hidden' }, + imageStyle: imageFitStyle(true), + diagnostic: '图片组件参数无效,已回退为等比预览。', + }; + } +} + +function alignment( + alignmentValue: TextAlignment, +): Pick { + const vertical = alignmentValue.startsWith('Upper') + ? 'flex-start' + : alignmentValue.startsWith('Middle') + ? 'center' + : 'flex-end'; + const horizontal = alignmentValue.endsWith('Left') + ? 'flex-start' + : alignmentValue.endsWith('Center') + ? 'center' + : 'flex-end'; + return { + alignItems: vertical, + justifyContent: horizontal, + textAlign: + horizontal === 'flex-start' + ? 'left' + : horizontal === 'center' + ? 'center' + : 'right', + }; +} + +export function textComponentToCss(component: TextComponent): CSSProperties { + const [red, green, blue, alpha] = component.color; + const horizontalOverflow: HorizontalTextOverflow = + component.horizontal_overflow; + const verticalOverflow: VerticalTextOverflow = component.vertical_overflow; + return { + display: 'flex', + width: '100%', + height: '100%', + boxSizing: 'border-box', + padding: 0, + color: `rgba(${red}, ${green}, ${blue}, ${Math.min(255, Math.max(0, alpha)) / 255})`, + fontFamily: SYSTEM_UI_FONT_STACK, + fontSize: `${finite(initialFontSize(component.font_sizing), 'font size')}px`, + ...fontStyleToCss(component.font_style), + ...alignment(component.alignment), + lineHeight: finite(component.line_spacing, 'line_spacing'), + whiteSpace: horizontalOverflow === 'Wrap' ? 'normal' : 'nowrap', + overflow: verticalOverflow === 'Truncate' ? 'hidden' : 'visible', + overflowWrap: horizontalOverflow === 'Wrap' ? 'anywhere' : 'normal', + }; +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/utils/layout/controlLayoutToCss.ts b/apps/ai-game-creator-shell/src/features/ui-editor/utils/layout/controlLayoutToCss.ts new file mode 100644 index 000000000..37ef4e568 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/utils/layout/controlLayoutToCss.ts @@ -0,0 +1,147 @@ +import type { CSSProperties } from 'react'; + +import type { Container } from '../../types/Container'; +import type { ControlLayout } from '../../types/ControlLayout'; +import { tf2css } from '../transform/tf2css'; + +type Axis = 'horizontal' | 'vertical'; + +function itemAxisStyle( + layout: ControlLayout, + axis: Axis, +): Pick { + const minimum = layout.custom_minimum_size[axis === 'horizontal' ? 0 : 1]; + const flags = + axis === 'horizontal' + ? layout.size_flags_horizontal + : layout.size_flags_vertical; + const style: Pick< + CSSProperties, + 'minWidth' | 'minHeight' | 'flexGrow' | 'alignSelf' + > = axis === 'horizontal' ? { minWidth: minimum } : { minHeight: minimum }; + if ((flags & 2) !== 0) style.flexGrow = layout.size_flags_stretch_ratio; + return style; +} + +function crossAxisAlignment(flags: number): CSSProperties['alignSelf'] { + if ((flags & 1) !== 0) return 'stretch'; + if (flags === 4) return 'center'; + if (flags === 8) return 'flex-end'; + return 'flex-start'; +} + +function alignment( + value: 'Begin' | 'Center' | 'End', +): CSSProperties['justifyContent'] { + return value === 'Center' + ? 'center' + : value === 'End' + ? 'flex-end' + : 'flex-start'; +} + +/** + * 将持久化的 Godot Control/Container 数据单向翻译为 Preview CSS。 + * CSS 只是 Preview 后端,不反向定义或写回布局数据。 + */ +export function controlLayoutToPreviewCss( + layout: ControlLayout, + parentIsContainer: boolean, +): CSSProperties { + if (!parentIsContainer) return tf2css(layout.transform); + + const horizontal = itemAxisStyle(layout, 'horizontal'); + const vertical = itemAxisStyle(layout, 'vertical'); + return { + position: 'relative', + ...horizontal, + ...vertical, + }; +} + +export function containerToPreviewCss(container: Container): CSSProperties { + if (container === 'None') return {}; + if ( + typeof container === 'object' && + ('HBox' in container || 'VBox' in container) + ) { + const isHBox = 'HBox' in container; + const props = isHBox ? container.HBox : container.VBox; + return { + display: 'flex', + flexDirection: isHBox ? 'row' : 'column', + justifyContent: alignment(props.alignment), + gap: props.separation, + minWidth: 0, + minHeight: 0, + }; + } + if (typeof container === 'object' && 'Grid' in container) { + const props = container.Grid; + return { + display: 'grid', + gridTemplateColumns: `repeat(${props.columns}, minmax(0, 1fr))`, + columnGap: props.h_separation, + rowGap: props.v_separation, + minWidth: 0, + minHeight: 0, + }; + } + if (typeof container === 'object' && 'Margin' in container) { + const props = container.Margin; + return { + display: 'grid', + paddingLeft: props.margin_left, + paddingTop: props.margin_top, + paddingRight: props.margin_right, + paddingBottom: props.margin_bottom, + minWidth: 0, + minHeight: 0, + }; + } + return { + display: 'grid', + placeItems: 'center', + minWidth: 0, + minHeight: 0, + }; +} + +export function childInContainerToPreviewCss( + layout: ControlLayout, + parent: Container, +): CSSProperties { + const horizontal = itemAxisStyle(layout, 'horizontal'); + const vertical = itemAxisStyle(layout, 'vertical'); + if (typeof parent === 'object' && 'HBox' in parent) { + return { + ...horizontal, + ...vertical, + alignSelf: crossAxisAlignment(layout.size_flags_vertical), + }; + } + if (typeof parent === 'object' && 'VBox' in parent) { + return { + ...horizontal, + ...vertical, + alignSelf: crossAxisAlignment(layout.size_flags_horizontal), + }; + } + if (typeof parent === 'object' && 'Margin' in parent) + return { gridArea: '1 / 1', alignSelf: 'stretch', justifySelf: 'stretch' }; + if (typeof parent === 'object' && 'Center' in parent) { + const useTopLeft = parent.Center.use_top_left; + return useTopLeft + ? { + justifySelf: 'center', + alignSelf: 'center', + transform: 'translate(50%, 50%)', + } + : { justifySelf: 'center', alignSelf: 'center' }; + } + return { ...horizontal, ...vertical }; +} + +export function isContainer(container: Container): boolean { + return container !== 'None'; +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/utils/textStyleToCss.ts b/apps/ai-game-creator-shell/src/features/ui-editor/utils/textStyleToCss.ts new file mode 100644 index 000000000..769fabfdf --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/utils/textStyleToCss.ts @@ -0,0 +1,59 @@ +import type { CSSProperties } from 'react'; + +import type { FontSizing } from '../types/FontSizing'; +import type { FontSource } from '../types/FontSource'; +import type { FontStyle } from '../types/FontStyle'; +import type { UiEditorFontFaceState } from '../useUiEditorFontFaces'; + +export const SYSTEM_UI_FONT_STACK = + 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; + +export function fontStyleToCss( + fontStyle: FontStyle, +): Pick { + switch (fontStyle) { + case 'Bold': + return { fontStyle: 'normal', fontWeight: 700 }; + case 'Italic': + return { fontStyle: 'italic', fontWeight: 400 }; + case 'BoldItalic': + return { fontStyle: 'italic', fontWeight: 700 }; + case 'Normal': + return { fontStyle: 'normal', fontWeight: 400 }; + } +} + +export function fontFamilyToCss( + source: FontSource, + fontFaces: Record, +): string { + if (typeof source === 'string') return SYSTEM_UI_FONT_STACK; + const fontFace = fontFaces[source.Bound]; + return fontFace?.status === 'loaded' + ? fontFace.cssFamily + : SYSTEM_UI_FONT_STACK; +} + +export function initialFontSize(sizing: FontSizing): number { + return 'Fixed' in sizing ? sizing.Fixed : sizing.BestFit.min; +} + +export function findLargestFittingFontSize( + min: number, + max: number, + fits: (size: number) => boolean, +): number { + let lower = min; + let upper = max; + let best = min; + while (lower <= upper) { + const candidate = Math.floor((lower + upper) / 2); + if (fits(candidate)) { + best = candidate; + lower = candidate + 1; + } else { + upper = candidate - 1; + } + } + return best; +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/utils/transform/tf2css.test.tsx b/apps/ai-game-creator-shell/src/features/ui-editor/utils/transform/tf2css.test.tsx new file mode 100644 index 000000000..0870bfece --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/utils/transform/tf2css.test.tsx @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest'; + +import type { Transform } from '../../types/Transform'; +import { tf2css } from './tf2css'; + +const STRETCH_TRANSFORM: Transform = { + anchor_min: [0, 0], + anchor_max: [1, 1], + offset_min: [0, 0], + offset_max: [0, 0], +}; + +const TRANSFORM_SLOTS = [ + ['anchor_min', 0], + ['anchor_min', 1], + ['anchor_max', 0], + ['anchor_max', 1], + ['offset_min', 0], + ['offset_min', 1], + ['offset_max', 0], + ['offset_max', 1], +] as const satisfies ReadonlyArray; + +function withSlot( + field: keyof Transform, + index: 0 | 1, + value: number, +): Transform { + const transform: Transform = { + anchor_min: [...STRETCH_TRANSFORM.anchor_min], + anchor_max: [...STRETCH_TRANSFORM.anchor_max], + offset_min: [...STRETCH_TRANSFORM.offset_min], + offset_max: [...STRETCH_TRANSFORM.offset_max], + }; + transform[field][index] = value; + return transform; +} + +describe('tf2css', () => { + it('maps a fixed center anchor to four responsive CSS edges', () => { + expect( + tf2css({ + anchor_min: [0.5, 0.5], + anchor_max: [0.5, 0.5], + offset_min: [-50, -20], + offset_max: [50, 20], + }), + ).toEqual({ + position: 'absolute', + left: 'calc(50% - 50px)', + top: 'calc(50% - 20px)', + right: 'calc(50% - 50px)', + bottom: 'calc(50% - 20px)', + }); + }); + + it('maps stretch offsets to CSS inset margins', () => { + expect( + tf2css({ + anchor_min: [0, 0], + anchor_max: [1, 1], + offset_min: [12, 8], + offset_max: [-16, -24], + }), + ).toEqual({ + position: 'absolute', + left: '12px', + top: '8px', + right: '16px', + bottom: '24px', + }); + }); + + it('maps split anchors without requiring the parent pixel size', () => { + expect( + tf2css({ + anchor_min: [0.1, 0.2], + anchor_max: [0.9, 0.8], + offset_min: [10, 25], + offset_max: [-10, -15], + }), + ).toEqual({ + position: 'absolute', + left: 'calc(10% + 10px)', + top: 'calc(20% + 25px)', + right: 'calc(10% + 10px)', + bottom: 'calc(20% + 15px)', + }); + }); + + it('preserves full JavaScript precision for fractional anchors', () => { + expect( + tf2css({ + anchor_min: [1 / 3, 1 / 3], + anchor_max: [2 / 3, 2 / 3], + offset_min: [0, 0], + offset_max: [0, 0], + }), + ).toEqual({ + position: 'absolute', + left: '33.33333333333333%', + top: '33.33333333333333%', + right: '33.33333333333334%', + bottom: '33.33333333333334%', + }); + }); + + it('preserves out-of-range ordered anchors and normalizes negative zero', () => { + expect( + tf2css({ + anchor_min: [-0.25, -0.5], + anchor_max: [1.5, 1.25], + offset_min: [-0, -0], + offset_max: [-0, -0], + }), + ).toEqual({ + position: 'absolute', + left: '-25%', + top: '-50%', + right: '-50%', + bottom: '-25%', + }); + + expect(tf2css(STRETCH_TRANSFORM)).toEqual({ + position: 'absolute', + left: '0px', + top: '0px', + right: '0px', + bottom: '0px', + }); + }); + + it.each(TRANSFORM_SLOTS)( + 'identifies a non-finite %s[%i] value', + (field, index) => { + expect(() => tf2css(withSlot(field, index, Number.NaN))).toThrowError( + new RangeError(`Transform.${field}[${index}] must be finite`), + ); + }, + ); + + it.each([Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( + 'rejects the non-finite value %s', + (value) => { + expect(() => tf2css(withSlot('offset_max', 1, value))).toThrowError( + new RangeError('Transform.offset_max[1] must be finite'), + ); + }, + ); + + it('rejects finite anchors whose CSS percentage would overflow', () => { + expect(() => + tf2css(withSlot('anchor_min', 0, Number.MAX_VALUE)), + ).toThrowError( + new RangeError( + 'Transform.anchor_min[0] cannot be represented as a CSS percentage', + ), + ); + }); + + it('rejects inverted anchors', () => { + expect(() => + tf2css({ + anchor_min: [0.8, 0], + anchor_max: [0.2, 1], + offset_min: [0, 0], + offset_max: [0, 0], + }), + ).toThrowError('Transform anchors must be ordered'); + }); +}); diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/utils/transform/tf2css.ts b/apps/ai-game-creator-shell/src/features/ui-editor/utils/transform/tf2css.ts new file mode 100644 index 000000000..82b51c221 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/utils/transform/tf2css.ts @@ -0,0 +1,98 @@ +import type { CSSProperties } from 'react'; + +import type { Transform } from '../../types/Transform'; + +type TransformVectorField = keyof Transform; + +const TRANSFORM_VECTOR_FIELDS = [ + 'anchor_min', + 'anchor_max', + 'offset_min', + 'offset_max', +] as const satisfies ReadonlyArray; + +function normalizeZero(value: number): number { + return Object.is(value, -0) ? 0 : value; +} + +function assertFiniteTransform(transform: Transform): void { + for (const field of TRANSFORM_VECTOR_FIELDS) { + for (const index of [0, 1] as const) { + if (!Number.isFinite(transform[field][index])) { + throw new RangeError(`Transform.${field}[${index}] must be finite`); + } + } + } +} + +function toPercentage(value: number, fieldPath: string): number { + const percentage = normalizeZero(value * 100); + if (!Number.isFinite(percentage)) { + throw new RangeError( + `${fieldPath} cannot be represented as a CSS percentage`, + ); + } + return percentage; +} + +function toRemainingPercentage(value: number, fieldPath: string): number { + const percentage = normalizeZero(100 - value * 100); + if (!Number.isFinite(percentage)) { + throw new RangeError( + `${fieldPath} cannot be represented as a CSS percentage`, + ); + } + return percentage; +} + +function toCssLength(percentage: number, pixelOffset: number): string { + const normalizedPercentage = normalizeZero(percentage); + const normalizedOffset = normalizeZero(pixelOffset); + + if (normalizedPercentage === 0) { + return `${normalizedOffset}px`; + } + if (normalizedOffset === 0) { + return `${normalizedPercentage}%`; + } + + const operator = normalizedOffset < 0 ? '-' : '+'; + return `calc(${normalizedPercentage}% ${operator} ${Math.abs(normalizedOffset)}px)`; +} + +/** + * Converts the UI editor's Control-like anchors and edge offsets to CSS. + * + * The containing element must establish the absolute-positioning containing + * block. The resulting geometry matches `Transform::resolve` while the + * resolved width and height are non-negative for that containing block. + */ +export function tf2css(transform: Transform): CSSProperties { + assertFiniteTransform(transform); + // Keep conversion errors specific even when an extreme anchor also happens + // to violate the min/max ordering constraint. + const left = toPercentage(transform.anchor_min[0], 'Transform.anchor_min[0]'); + const top = toPercentage(transform.anchor_min[1], 'Transform.anchor_min[1]'); + const right = toRemainingPercentage( + transform.anchor_max[0], + 'Transform.anchor_max[0]', + ); + const bottom = toRemainingPercentage( + transform.anchor_max[1], + 'Transform.anchor_max[1]', + ); + if ( + transform.anchor_min[0] > transform.anchor_max[0] || + transform.anchor_min[1] > transform.anchor_max[1] + ) { + throw new RangeError('Transform anchors must be ordered'); + } + + return { + position: 'absolute', + left: toCssLength(left, transform.offset_min[0]), + top: toCssLength(top, transform.offset_min[1]), + right: toCssLength(right, -transform.offset_max[0]), + bottom: toCssLength(bottom, -transform.offset_max[1]), + }; +} diff --git a/apps/ai-game-creator-shell/src/services/clientApi.ts b/apps/ai-game-creator-shell/src/services/clientApi.ts index 5b16aa4f6..2ca39bb4e 100644 --- a/apps/ai-game-creator-shell/src/services/clientApi.ts +++ b/apps/ai-game-creator-shell/src/services/clientApi.ts @@ -93,6 +93,108 @@ export async function requestClientApi( return text ? unwrapApiResponse(JSON.parse(text) as T) : (null as T); } +/** + * Authenticated binary request for internal APIs (for example asset bytes). + * This intentionally shares the same token and URL resolution as requestClientApi + * instead of using the external editor API key flow. + */ +export async function requestClientApiBytes( + url: string, + fallbackMessage: string, + init: RequestInit = {}, +) { + const headers = new Headers(init.headers); + headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION); + const token = getStoredAuthAccessToken(); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + let response: Response; + try { + response = await fetchClientHttp(url, { + ...init, + credentials: 'same-origin', + headers, + }); + } catch { + throw new ClientAuthRequestError( + '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + { networkError: true }, + ); + } + if (!response.ok) { + throw new ClientAuthRequestError( + await readApiErrorMessage(response, fallbackMessage), + { status: response.status }, + ); + } + return response; +} + +export type ClientAssetReadUrlResponse = { + read?: { signedUrl?: string; objectKey?: string; expiresAt?: string }; + signedUrl?: string; + objectKey?: string; + expiresAt?: string; +}; + +export type ClientEditorAssetLibrary = { + folders: Array<{ + folderId: string; + label: string; + sortOrder?: number; + collapsed?: boolean; + systemDefault?: boolean; + }>; + assets: Array<{ + assetId: string; + folderId: string; + label: string; + imageSrc?: string; + thumbnailSrc?: string | null; + objectKey?: string | null; + assetObjectId?: string | null; + width?: number; + height?: number; + assetKind?: string | null; + [key: string]: unknown; + }>; +}; + +export function loadEditorAssetLibrary() { + return requestClientApi<{ library: ClientEditorAssetLibrary }>( + '/api/editor/assets/library', + { method: 'GET' }, + '读取平台素材库失败', + ).then((response) => response.library); +} + +function buildClientAssetObjectKeyQuery(objectKey: string) { + return new URLSearchParams({ objectKey: objectKey.replace(/^\/+/, '') }); +} + +export async function resolveClientAssetReadUrl(objectKey: string) { + const params = buildClientAssetObjectKeyQuery(objectKey); + const payload = await requestClientApi( + `/api/assets/read-url?${params.toString()}`, + { method: 'GET' }, + '读取平台素材预览地址失败', + ); + const signedUrl = payload?.read?.signedUrl ?? payload?.signedUrl; + if (!signedUrl?.trim()) { + throw new Error('平台素材预览地址缺失'); + } + return signedUrl; +} + +export function readClientAssetBytes(objectKey: string) { + const params = buildClientAssetObjectKeyQuery(objectKey); + return requestClientApiBytes( + `/api/assets/read-bytes?${params.toString()}`, + '读取平台素材内容失败', + ); +} + export function getClientProfileDashboard() { return requestClientApi( '/api/profile/dashboard', diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index b87d1f44c..646b360ea 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -23,6 +23,102 @@ textarea { font: inherit; } +@keyframes ui-editor-status-attention-arrive { + 0%, + 100% { + transform: scale(1); + } + + 16%, + 50% { + transform: scale(1.012); + } + + 32%, + 68% { + transform: scale(0.996); + } +} + +@keyframes ui-editor-status-attention-ring { + 0%, + 100% { + opacity: 0; + transform: scale(0.96); + } + + 16%, + 48% { + opacity: 0.78; + transform: scale(1.01); + } + + 32%, + 68% { + opacity: 0.2; + transform: scale(1.025); + } +} + +@keyframes ui-editor-status-attention-breathe { + 0%, + 100% { + opacity: 0.32; + transform: scale(0.995); + } + + 50% { + opacity: 0.7; + transform: scale(1.018); + } +} + +.ui-editor-status-attention { + position: relative; + z-index: 0; + isolation: isolate; + border: 2px solid rgb(var(--ui-editor-status-attention-rgb) / 88%); + background: rgb(var(--ui-editor-status-attention-rgb) / 8%); + box-shadow: + 0 0 0 4px rgb(var(--ui-editor-status-attention-rgb) / 16%), + 0 8px 20px rgb(var(--ui-editor-status-attention-rgb) / 12%); + animation: ui-editor-status-attention-arrive 820ms + cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.ui-editor-status-attention::after { + position: absolute; + z-index: -1; + inset: -6px; + border: 2px solid rgb(var(--ui-editor-status-attention-rgb) / 85%); + border-radius: 12px; + content: ''; + pointer-events: none; + animation: + ui-editor-status-attention-ring 820ms ease-out, + ui-editor-status-attention-breathe 1.8s ease-in-out 820ms infinite; +} + +.ui-editor-status-attention--review { + --ui-editor-status-attention-rgb: 245 158 11; +} + +.ui-editor-status-attention--blocked { + --ui-editor-status-attention-rgb: 239 68 68; +} + +@media (prefers-reduced-motion: reduce) { + .ui-editor-status-attention, + .ui-editor-status-attention::after { + animation: none; + } + + .ui-editor-status-attention::after { + opacity: 0.7; + transform: scale(1.01); + } +} + .client-auth-shell { display: grid; min-height: 100vh; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index ef0c5e017..4184da97e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -61,6 +61,7 @@ import { resolveEmbeddedPreviewUrl, } from '../../features/project-workspace/LocalGamePreviewFrame'; import { requestPlatformSessionRefresh } from '../../services/platformSession'; +import UiEditorPage from '../ui-editor'; import { type ProjectManifestSnapshotMetadata, resolveResourceFocusIntent, @@ -178,6 +179,17 @@ type ResourceEditorRoute = { capability: Extract; }; +type UiEditorRoute = { + resourceId: string; + resourceLabel: string; +}; + +type CreateUiDesignResourceResult = { + asset: { id: string }; + manifest: GameCreationAppManifest; + committedProjectRevision: number; +}; + type DeriveLocalProjectResourceResult = { operationId: string; sourceResourceId: string; @@ -727,6 +739,9 @@ export default function ProjectDevelopmentView({ useState(null); const [resourceEditorRoute, setResourceEditorRoute] = useState(null); + const [uiEditorRoute, setUiEditorRoute] = useState( + null, + ); const [assetCanvasNotice, setAssetCanvasNotice] = useState(''); const [pendingResourceEdits, setPendingResourceEdits] = useState< PendingLocalProjectResourceEdit[] @@ -1428,8 +1443,7 @@ export default function ProjectDevelopmentView({ } const viewport = resolveViewport(event); const category = viewport?.dataset.resourceSectionScroll as - | ResourceCategory - | undefined; + ResourceCategory | undefined; if (!viewport || !category) { return; } @@ -1448,8 +1462,7 @@ export default function ProjectDevelopmentView({ const event = rawEvent as WebKitGestureEvent; const viewport = resolveViewport(event); const category = viewport?.dataset.resourceSectionScroll as - | ResourceCategory - | undefined; + ResourceCategory | undefined; if (!viewport || !category) { return; } @@ -1537,8 +1550,7 @@ export default function ProjectDevelopmentView({ .querySelectorAll('[data-resource-section-scroll]') .forEach((viewport) => { const category = viewport.dataset.resourceSectionScroll as - | ResourceCategory - | undefined; + ResourceCategory | undefined; if (!category) { return; } @@ -1558,8 +1570,7 @@ export default function ProjectDevelopmentView({ ?.querySelectorAll('[data-resource-section-scroll]') .forEach((viewport) => { const category = viewport.dataset.resourceSectionScroll as - | ResourceCategory - | undefined; + ResourceCategory | undefined; if (!category) { return; } @@ -1623,6 +1634,7 @@ export default function ProjectDevelopmentView({ pendingResourceFocusRef.current = null; setAssetCanvasRoute(null); setResourceEditorRoute(null); + setUiEditorRoute(null); resourceEditorRevisionRef.current.clear(); setHiddenCommittedResourceId(null); setPendingResourceEdits([]); @@ -2010,6 +2022,14 @@ export default function ProjectDevelopmentView({ const handleResourceSelect = useCallback( (resourceId: string) => { + const resource = resources.find((entry) => entry.id === resourceId); + if (resource?.subtype === 'UI' && resource.manifestAssetId !== null) { + setUiEditorRoute({ + resourceId: resource.manifestAssetId, + resourceLabel: resource.label, + }); + return; + } advanceFocusGeneration(); stopActiveCardMedia(); captureResourceSectionScrollPositions(); @@ -2024,10 +2044,39 @@ export default function ProjectDevelopmentView({ advanceFocusGeneration, captureResourceListScrollPosition, captureResourceSectionScrollPositions, + resources, stopActiveCardMedia, ], ); + const createUiDesignResource = useCallback(async () => { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setAssetCanvasNotice('UI 设计资源需要在客户端内创建'); + return; + } + setAssetCanvasNotice('正在创建 UI 设计…'); + try { + const result = await invoke( + 'create_ui_design_resource', + { projectPath, expectedProjectId: manifest.projectId }, + ); + if (result.manifest.projectId !== manifest.projectId) { + throw new Error('UI 设计资源登记结果与当前项目不一致'); + } + onManifestChange?.(projectPath, result.manifest, { + projectId: result.manifest.projectId, + revision: result.committedProjectRevision, + source: 'asset-command', + }); + setAssetCanvasNotice('UI 设计已创建,正在同步资源与布局…'); + } catch (error) { + setAssetCanvasNotice( + error instanceof Error ? error.message : String(error), + ); + } + }, [manifest.projectId, onManifestChange, projectPath]); + function showResourceSortMode(nextMode: ResourceSortMode) { if (nextMode === sortMode) { return; @@ -2857,16 +2906,16 @@ export default function ProjectDevelopmentView({ dependencyLayoutSettled: dependencyLayout.settled, dependencyPositioned: Boolean( intent.resourceId && - dependencyLayout.layout.positions.some( - (position) => position.resourceId === intent.resourceId, - ), + dependencyLayout.layout.positions.some( + (position) => position.resourceId === intent.resourceId, + ), ), typeLayoutSettled: typeLayout.settled, typePositioned: Boolean( intent.resourceId && - typeLayout.layout.positions.some( - (position) => position.resourceId === intent.resourceId, - ), + typeLayout.layout.positions.some( + (position) => position.resourceId === intent.resourceId, + ), ), visible: targetVisible, domRendered: Boolean(card), @@ -2947,900 +2996,944 @@ export default function ProjectDevelopmentView({ setMode('run'); } + const resourceViewState = (() => { + switch (mode) { + case 'resources': + if (assetCanvasRoute) { + return `resources.asset-canvas.${assetCanvasRoute.scope.intent}`; + } + if (resourceEditorRoute) { + return `resources.editor.${resourceEditorRoute.capability.editKind}`; + } + if (uiEditorRoute) { + return 'resources.ui-editor'; + } + if (focusedResource) { + return `resources.focused.${focusedResource.category}`; + } + return 'resources.list'; + case 'run': + return undefined; + } + })(); + return (
-
-
-
-
- - -
-
- - {mode === 'resources' && - !focusedResource && - !assetCanvasRoute && - !resourceEditorRoute ? ( - <> - {pendingResourceEdits.length > 0 || - pendingResourceEditsLoadState === 'failed' ? ( + setUiEditorRoute(null)} + /> +
+ ) : ( +
+
+
+
+ + +
+
+ + {mode === 'resources' && + !focusedResource && + !assetCanvasRoute && + !resourceEditorRoute && + !uiEditorRoute ? ( + <> + {pendingResourceEdits.length > 0 || + pendingResourceEditsLoadState === 'failed' ? ( + + ) : null} - ) : null} - - - - {resourceLayoutSaving ? '保存中' : resourceLayoutNotice} - - - - ) : null} -
-
- - {!runAvailable && - !focusedResource && - !assetCanvasRoute && - !resourceEditorRoute ? ( -

- 首个可运行原型尚未完成,运行视图暂不可用 -

- ) : null} - - {mode === 'resources' && assetCanvasRoute ? ( - - ) : mode === 'resources' && resourceEditorRoute ? ( - - ) : mode === 'resources' && focusedResource ? ( -
-
- - - - - {categoryLabels[focusedResource.category]} - - {focusedResource.label} - - - - - - - -
-
-
-
-
资源路径
-
{focusedResource.path}
-
-
-
资源类型
-
{focusedPreviewMediaType}
-
-
-
资源来源
-
{focusedResource.sourceLabel}
-
- {focusedResource.taskTitle ? ( -
-
来源任务
-
{focusedResource.taskTitle}
-
- ) : null} -
-
依赖层级
-
{focusedResource.dependencyDepth}
-
-
-
依赖状态
-
- {focusedResourceDependencyDetails.cyclic - ? '同类型依赖环' - : '正常'} -
-
-
-
引用上游
-
- {focusedResourceDependencyDetails.upstreamLabels.length > - 0 - ? focusedResourceDependencyDetails.upstreamLabels.join( - '、', - ) - : '暂无同类型引用'} -
-
-
-
引用下游
-
- {focusedResourceDependencyDetails.downstreamLabels - .length > 0 - ? focusedResourceDependencyDetails.downstreamLabels.join( - '、', - ) - : '暂无同类型引用'} -
-
-
-
任务流
-
- {focusedResourceDependencyDetails.taskFlowLabels.length > - 0 - ? focusedResourceDependencyDetails.taskFlowLabels.join( - ';', - ) - : '暂无同类型任务流'} -
-
- {focusedResourceIsAudio ? ( -
-
音频时长
-
{formatMediaDuration(mediaDuration)}
-
- ) : null} - {focusedResource.version ? ( - <> -
-
版本 ID
-
{focusedResource.version.versionId}
-
-
-
项目修订
-
{focusedResource.version.projectRevision}
-
-
-
父版本
-
- {focusedResource.version.parentVersionId ?? - '首个版本'} -
-
-
-
直接子版本
-
- {focusedResource.version.childVersionIds.length > 0 - ? focusedResource.version.childVersionIds.join('、') - : '暂无'} -
-
-
-
创建原因
-
- { - { - initial: '初始版本', - 'resource-replacement': '资源替换', - 'agent-revision': 'Agent 修订', - }[focusedResource.version.createdReason] - } -
-
-
-
创建时间
-
- {formatVersionCreatedAt( - focusedResource.version.createdAt, - )} -
-
-
-
资源绑定
-
- {focusedResource.version.resourceBindings.length > 0 - ? focusedResource.version.resourceBindings - .map( - (binding) => - `${binding.slotId} → ${binding.resourceId}`, - ) - .join(';') - : '暂无'} -
-
- - ) : null} -
- {focusedResourceIsAudio ? ( -
- {focusedResourceCardPreview.status === 'loaded' && - focusedResourceCardPreview.preview.sourceUrl ? ( -
- ) : null} - {focusedResource.category === 'document' ? ( -
- {focusedResourceCardPreview.status === 'loaded' && - focusedResourceCardPreview.preview.content !== undefined ? ( - focusedResourceCardPreview.preview.content.trim() ? ( - - ) : ( -

文档为空

- ) - ) : focusedResourceCardPreview.status === 'failed' ? ( -

{focusedResourceCardPreview.error}

- ) : ( -

正在载入文档…

- )} -
- ) : null} - {focusedResource.category === 'code' ? ( -
- {focusedResourceCardPreview.status === 'loaded' && - focusedResourceCardPreview.preview.content !== undefined ? ( -
-                        
-                          {focusedResourceCardPreview.preview.content}
-                        
-                      
- ) : focusedResourceCardPreview.status === 'failed' ? ( -

{focusedResourceCardPreview.error}

- ) : ( -

正在载入游戏代码…

- )} -
- ) : null} -
-
- ) : mode === 'resources' ? ( -
- - {assetCanvasNotice ? ( -
- {assetCanvasNotice} - {hiddenCommittedResourceId ? ( + + + {resourceLayoutSaving ? '保存中' : resourceLayoutNotice} + + + + ) : null} +
+
+ + {!runAvailable && + !focusedResource && + !assetCanvasRoute && + !resourceEditorRoute && + !uiEditorRoute ? ( +

+ 首个可运行原型尚未完成,运行视图暂不可用 +

+ ) : null} + + {mode === 'resources' && assetCanvasRoute ? ( + + ) : mode === 'resources' && resourceEditorRoute ? ( + + ) : mode === 'resources' && focusedResource ? ( +
+
+ + + + + {categoryLabels[focusedResource.category]} + + {focusedResource.label} + + + + + + + +
+
+
+
+
资源路径
+
{focusedResource.path}
+
+
+
资源类型
+
{focusedPreviewMediaType}
+
+
+
资源来源
+
{focusedResource.sourceLabel}
+
+ {focusedResource.taskTitle ? ( +
+
来源任务
+
{focusedResource.taskTitle}
+
+ ) : null} +
+
依赖层级
+
{focusedResource.dependencyDepth}
+
+
+
依赖状态
+
+ {focusedResourceDependencyDetails.cyclic + ? '同类型依赖环' + : '正常'} +
+
+
+
引用上游
+
+ {focusedResourceDependencyDetails.upstreamLabels + .length > 0 + ? focusedResourceDependencyDetails.upstreamLabels.join( + '、', + ) + : '暂无同类型引用'} +
+
+
+
引用下游
+
+ {focusedResourceDependencyDetails.downstreamLabels + .length > 0 + ? focusedResourceDependencyDetails.downstreamLabels.join( + '、', + ) + : '暂无同类型引用'} +
+
+
+
任务流
+
+ {focusedResourceDependencyDetails.taskFlowLabels + .length > 0 + ? focusedResourceDependencyDetails.taskFlowLabels.join( + ';', + ) + : '暂无同类型任务流'} +
+
+ {focusedResourceIsAudio ? ( +
+
音频时长
+
{formatMediaDuration(mediaDuration)}
+
+ ) : null} + {focusedResource.version ? ( + <> +
+
版本 ID
+
{focusedResource.version.versionId}
+
+
+
项目修订
+
{focusedResource.version.projectRevision}
+
+
+
父版本
+
+ {focusedResource.version.parentVersionId ?? + '首个版本'} +
+
+
+
直接子版本
+
+ {focusedResource.version.childVersionIds.length > 0 + ? focusedResource.version.childVersionIds.join( + '、', + ) + : '暂无'} +
+
+
+
创建原因
+
+ { + { + initial: '初始版本', + 'resource-replacement': '资源替换', + 'agent-revision': 'Agent 修订', + }[focusedResource.version.createdReason] + } +
+
+
+
创建时间
+
+ {formatVersionCreatedAt( + focusedResource.version.createdAt, + )} +
+
+
+
资源绑定
+
+ {focusedResource.version.resourceBindings.length > 0 + ? focusedResource.version.resourceBindings + .map( + (binding) => + `${binding.slotId} → ${binding.resourceId}`, + ) + .join(';') + : '暂无'} +
+
+ + ) : null} +
+ {focusedResourceIsAudio ? ( +
+ {focusedResourceCardPreview.status === 'loaded' && + focusedResourceCardPreview.preview.sourceUrl ? ( +
+ ) : null} + {focusedResource.category === 'document' ? ( +
+ {focusedResourceCardPreview.status === 'loaded' && + focusedResourceCardPreview.preview.content !== + undefined ? ( + focusedResourceCardPreview.preview.content.trim() ? ( + + ) : ( +

文档为空

+ ) + ) : focusedResourceCardPreview.status === 'failed' ? ( +

{focusedResourceCardPreview.error}

+ ) : ( +

正在载入文档…

+ )} +
+ ) : null} + {focusedResource.category === 'code' ? ( +
+ {focusedResourceCardPreview.status === 'loaded' && + focusedResourceCardPreview.preview.content !== + undefined ? ( +
+                          
+                            {focusedResourceCardPreview.preview.content}
+                          
+                        
+ ) : focusedResourceCardPreview.status === 'failed' ? ( +

{focusedResourceCardPreview.error}

+ ) : ( +

正在载入游戏代码…

+ )} +
) : null}
- ) : null} - {attachments.some( - (attachment) => attachment.status === 'failed', - ) ? ( -
- {attachments - .filter((attachment) => attachment.status === 'failed') - .map((attachment) => ( -

- {attachment.fileName} - {attachment.error || '附件导入失败'} -

- ))} -
- ) : null} -
0 - ? dependencyDescriptionId - : undefined - } - aria-busy={resourceLayoutSaving} - onScroll={(event) => { - resourceListScrollPositionsRef.current.set( - resourceListScrollKey, - { - left: event.currentTarget.scrollLeft, - top: event.currentTarget.scrollTop, - }, - ); - }} - > - {sortMode === 'dependency' && - dependencyRelationshipDescriptions.length > 0 ? ( -
-

当前资源关系:

-
    - {dependencyRelationshipDescriptions.map((description) => ( -
  • {description}
  • - ))} -
+
+ ) : mode === 'resources' ? ( +
+ + {assetCanvasNotice ? ( +
+ {assetCanvasNotice} + {hiddenCommittedResourceId ? ( + + ) : null}
) : null} -
-
attachment.status === 'failed', + ) ? ( +
- {categoryOrder.map((category) => { - const categoryResources = - visibleResourcesByCategory.get(category) ?? []; - const baseExtent = resourceBaseExtentByCategory.get( - category, - ) ?? { width: 0, height: 0 }; - const extent = { - width: - baseExtent.width + - (sortMode === 'dependency' - ? RESOURCE_DEPENDENCY_VISUAL_GUTTER - : 0), - height: baseExtent.height, - }; - const Icon = categoryIcons[category]; - const sectionHeight = - resourceSectionHeights.sectionStates.get(category); - if (!sectionHeight) { - return null; + {attachments + .filter((attachment) => attachment.status === 'failed') + .map((attachment) => ( +

+ {attachment.fileName} + {attachment.error || '附件导入失败'} +

+ ))} +
+ ) : null} +
0 + ? dependencyDescriptionId + : undefined + } + aria-busy={resourceLayoutSaving} + onScroll={(event) => { + resourceListScrollPositionsRef.current.set( + resourceListScrollKey, + { + left: event.currentTarget.scrollLeft, + top: event.currentTarget.scrollTop, + }, + ); + }} + > + {sortMode === 'dependency' && + dependencyRelationshipDescriptions.length > 0 ? ( +
+

当前资源关系:

+
    + {dependencyRelationshipDescriptions.map( + (description) => ( +
  • {description}
  • + ), + )} +
+
+ ) : null} +
+
-
- - -
- - {categoryResources.length} 项 - -
- - - -
-
- - - -
-
-
-
{ - resourceSectionScrollPositionsRef.current.set( - resourceSectionScrollKey(category), - { - left: event.currentTarget.scrollLeft, - top: event.currentTarget.scrollTop, - }, - ); - }} + > + {categoryOrder.map((category) => { + const categoryResources = + visibleResourcesByCategory.get(category) ?? []; + const baseExtent = resourceBaseExtentByCategory.get( + category, + ) ?? { width: 0, height: 0 }; + const extent = { + width: + baseExtent.width + + (sortMode === 'dependency' + ? RESOURCE_DEPENDENCY_VISUAL_GUTTER + : 0), + height: baseExtent.height, + }; + const Icon = categoryIcons[category]; + const sectionHeight = + resourceSectionHeights.sectionStates.get(category); + if (!sectionHeight) { + return null; + } + return ( +
- {categoryResources.length > 0 ? ( -
-
+ + +
+ - {sortMode === 'dependency' ? ( - - ) : null} - {categoryResources.map((resource) => { - const position = resourcePositionById.get( - resource.id, - ); - if (!position) { - return null; - } - const relationState = - selectedVersionBindingResourceIds.has( - resource.id, + {categoryResources.length} 项 + +
+ + + +
+
+ + +
- ) : ( -

暂无已登记资源

- )} -
-
- ); - })} + +
{ + resourceSectionScrollPositionsRef.current.set( + resourceSectionScrollKey(category), + { + left: event.currentTarget.scrollLeft, + top: event.currentTarget.scrollTop, + }, + ); + }} + > + {categoryResources.length > 0 ? ( +
+
+ {sortMode === 'dependency' ? ( + + ) : null} + {categoryResources.map((resource) => { + const position = resourcePositionById.get( + resource.id, + ); + if (!position) { + return null; + } + const relationState = + selectedVersionBindingResourceIds.has( + resource.id, + ) + ? 'version-binding' + : null; + const previewIdentity = + resourceCardPreviews.identityByResourceId.get( + resource.id, + ); + if (!previewIdentity) { + return null; + } + return ( + + ); + })} +
+
+ ) : ( +

暂无已登记资源

+ )} +
+
+ ); + })} +
- - ) : ( -
-
- {embeddedPreviewUrl ? ( - - ) : ( -
-
- )} -
-
-
-
-
- {selectedResource ? ( -
-
-
名称
-
{selectedResource.label}
-
-
-
路径
-
{selectedResource.path}
-
-
-
类型
-
{selectedResource.mediaType}
-
-
- ) : ( -

暂停后选择资源可查看已登记信息

- )} -
-
-
-
- - - -
-
-
- )} -
- - - + - {professionalDagVisible ? ( + + + )} + + {professionalDagVisible && !uiEditorRoute ? (