diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index da7eddb68..c08cb6ac9 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -1,7 +1,7 @@ { "name": "@genarrative/ai-game-creator-shell", "private": true, - "version": "0.1.27", + "version": "0.1.29", "type": "module", "scripts": { "dev": "node scripts/start-tauri-dev.mjs", diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index efa6f62be..118c7f9e2 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -108,6 +108,8 @@ const rustSharedContractSource = fs.readFileSync( ); const allowedUncalledTauriCommands = [ 'append_direct_project_conversation_message', + // TODO: Remove the retired binding command after the legacy runtime path is removed. + 'bind_components', 'chat_with_game_creator_agent', 'check_ui_editor_font_glyph_coverage', 'create_ui_design_resource', diff --git a/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs b/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs index d660c2a70..5f89ad3ae 100644 --- a/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs +++ b/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs @@ -8,6 +8,7 @@ export const SKILL_PACK_SCHEMA_VERSION = 'agc-skill-pack.v1'; export const EXPECTED_SKILL_NAMES = Object.freeze([ 'agc-browser-playtest', 'agc-client-projection', + 'agc-game-production-workflow', 'agc-project-structure', 'agc-web-game-development', 'taonier-art-assets', diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 60032820b..7e07a3c9c 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "genarrative-ai-game-creator-shell" -version = "0.1.27" +version = "0.1.29" dependencies = [ "agent-runtime-core", "axum", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 370777cf3..4fe37d25a 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "genarrative-ai-game-creator-shell" -version = "0.1.27" +version = "0.1.29" edition = "2021" publish = false diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/SKILL.md new file mode 100644 index 000000000..f02b45374 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/SKILL.md @@ -0,0 +1,26 @@ +--- +name: agc-game-production-workflow +description: Orchestrate a complete AGC game delivery from an approved brief to a playable, art-integrated, browser-validated product. Use when creating a new game, implementing a substantial game brief, or turning a planning document into a finished game. +--- + +# AGC Game Production Workflow + +Use this Skill as the top-level SOP for a new game or a substantial game brief. The tools are stages in one delivery chain, not independent suggestions. Do not stop after producing a plan, after writing code, or after generating an image. + +## Stage flow + +1. **Brief and scope** — Read the current planning output and project instructions. Extract the game loop, player actions, entities, visual requirements, target viewports, and the completion evidence. If the brief is incomplete, ask focused questions before side effects. +2. **Project and asset inventory** — Inspect the existing project structure and call `agc_list_registered_assets` (and `agc_list_project_files` when needed). Record which requested visuals already have usable registered identities and which are missing. Do not invent asset identities from filenames. +3. **Visual production** — For missing or unsuitable visuals, call the reviewed `agc_tools` workflow: use `taonier_prepare_game_art` for a complete package, or `agc_generate_image` / `agc_edit_image` for focused assets. Read returned paths, identities, and warnings. A warning or partial package requires a narrower retry or independent assets before continuing. +4. **Game implementation** — Implement the complete playable loop and wire the returned project-relative asset paths into the actual runtime. Every required character, object, background, effect, and UI visual must have a real source or an explicit brief-level decision to remain code-native. Generated assets that are unused, documentation-only, or replaced by emoji/CSS placeholders do not satisfy this stage. +5. **Build and local verification** — Run the project’s bootstrap/install and verify/build commands. Confirm the actual playable entry under `dist` (or the editor runtime for a supported editor project) and fix build or asset-loading failures before preview. +6. **Browser playtest** — Call `agc_browser_playtest` for desktop and mobile evidence after meaningful changes. Check the game loop, input, layout, asset loading, and visible use of the generated art. Fix findings and repeat stages 4–6 until the evidence is clean. +7. **Delivery** — Report the implemented behavior, real asset paths and identities used, build result, playtest evidence, warnings, and any explicit remaining gap. Do not claim complete while a required stage is failed, skipped without the brief’s justification, or missing evidence. + +## Stage transitions + +Advance only when the current stage has its output: brief → inventory; inventory → art decision; art decision → usable registered assets or an explicit no-art decision; implementation → source references to those assets; build → playable entry; playtest → evidence; delivery → truthful report. If a tool fails, preserve its error and stop or repair at that stage instead of silently substituting a later-stage placeholder. + +For a small edit to an existing game where the brief and suitable assets are unchanged, use the focused edit path and do not regenerate art. This exception does not apply to a new game or a substantial planning brief. + +Read the referenced specialist Skills for their detailed contracts: `agc-project-structure`, `taonier-art-assets`, `agc-web-game-development`, `agc-client-projection`, and `agc-browser-playtest`. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/agents/openai.yaml b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/agents/openai.yaml new file mode 100644 index 000000000..f90955027 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "完整游戏生产流程" + short_description: "从策划案到真实美术接入和试玩验收的连续交付" + default_prompt: "Use $agc-game-production-workflow to take the current game brief through inventory, art, implementation, build, playtest, and delivery." diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/references/workflow-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/references/workflow-contract.md new file mode 100644 index 000000000..322b59323 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/references/workflow-contract.md @@ -0,0 +1,5 @@ +# Workflow contract + +The production Skill owns sequencing and transition evidence. Specialist Skills own the detailed safety and data rules for each tool family. A specialist tool result is never a delivery result by itself: image generation must be followed by registered identity inspection and runtime integration; code writing must be followed by build verification; a successful preview launch must be followed by desktop and mobile playtest evidence when the brief targets both. + +The no-art exception is valid only when the brief explicitly requests a code-native visual treatment or the inventory proves that all required visuals are already registered and suitable. Emoji, CSS primitives, random local files, and generated files that are not referenced by the runtime are not evidence of an integrated art package. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md index 1cd480a1e..c9de909a6 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md @@ -14,7 +14,7 @@ Implement the user's actual game request in the current project as an npm-manage 3. Build with the project's npm script before previewing. The playable entry is the package directory's `dist/index.html`; never report an unbuilt bare-module page as playable. Import assets or configure public assets so all runtime media is included in dist; preview and exports cannot read outside it. 4. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one. 5. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content. -6. Reuse registered Taonier art when available through `agc_tools`. Load media defensively and keep gameplay usable when an optional derivative is absent; never relabel a local placeholder as platform art. +6. Invoke `taonier-art-assets` for every new game brief that needs visual assets. First reuse suitable registered Taonier art; when the brief's required visual elements are missing or unsuitable, call the reviewed `agc_tools` generation/edit workflow in the same task. After the tool returns, wire its relative paths into the game and verify the rendered result. A game with unused generated assets or placeholder emoji/CSS where requested art should appear is not complete. Load media defensively only for genuinely optional effects, and never relabel a local placeholder as platform art. 7. Let Phaser own the render loop and input dispatch. Avoid duplicate scenes, stale event listeners, and state that survives restart unintentionally. 8. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index a4c42d7b5..72dbdf5fb 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,7 +1,29 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.12", + "version": "2026-08-26.13", "skills": [ + { + "name": "agc-game-production-workflow", + "purpose": "把完整游戏从策划案按阶段推进到真实素材接入、构建、试玩和交付", + "triggers": [ + "从策划案创建完整游戏", + "实现完整游戏交付", + "需要衔接策划、素材、代码、构建和试玩" + ], + "requiredTools": [ + "agc_tools.agc_list_registered_assets", + "agc_tools.agc_generate_image", + "agc_tools.agc_edit_image", + "agc_tools.taonier_prepare_game_art", + "agc_tools.agc_browser_playtest" + ], + "files": [ + "SKILL.md", + "agents/openai.yaml", + "references/workflow-contract.md" + ], + "sha256": "91082fdff4123f1e1fcf930af433cbea51a8c9d26991678b19028b344ea49f39" + }, { "name": "agc-project-structure", "purpose": "约束当前项目根、游戏代码、美术素材与客户端状态的职责边界", @@ -31,6 +53,7 @@ "已有陶泥儿素材需要接入玩法" ], "requiredTools": [ + "agc_tools.agc_list_registered_assets", "agc_tools.agc_generate_image", "agc_tools.agc_edit_image", "agc_tools.taonier_prepare_game_art" @@ -40,7 +63,7 @@ "agents/openai.yaml", "references/platform-art-contract.md" ], - "sha256": "82e4b2ee8ca8147b51ca206b0565b3cc244dc5d3cddb8343875001c0beb4711f" + "sha256": "bd1e415aac0cd0f97090296f34c67898dd731d1e177ec91a56027f9b68a88b37" }, { "name": "agc-web-game-development", @@ -57,7 +80,7 @@ "agents/openai.yaml", "references/game-quality-checklist.md" ], - "sha256": "0649c72dd53e05ad7c87b28def1397c2badf61b0c308091196c40f7c48a8b36a" + "sha256": "05b5cfbf7a40fd303717491f5cea84ff339a73359c9678b283fd54d2b5c45efd" }, { "name": "agc-browser-playtest", diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md index d069fe9d8..8d63beb92 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md @@ -5,7 +5,15 @@ description: Prepare, recover, inspect, and integrate real Taonier platform game # Taonier Art Assets -Use real platform assets only through the reviewed `agc_tools` MCP server. Use +Use real platform assets only through the reviewed `agc_tools` MCP server. When +building a new game from a brief that names characters, objects, backgrounds, +effects, or other visual elements, this Skill is an execution step: inspect +existing assets, generate or reuse suitable art, process it when needed, and +integrate the returned paths into the playable game before reporting the game +complete. Do not treat the art step as optional merely because the user did +not repeat “生图” in the latest message. + +Use `agc_generate_image` for a single ordinary image, character image, visual-spec image, UI design image, or publication material; use `agc_edit_image` for an edit of an existing registered image; use `taonier_prepare_game_art` only for @@ -17,11 +25,11 @@ the complete game-art package and its canonical slices. ## Workflow -1. Inspect existing `assets/` and registered project evidence before requesting new art. Reuse suitable assets when the user did not ask to regenerate them. +1. Inspect existing `assets/` and registered project evidence before requesting new art. Reuse suitable assets when they satisfy the current brief. If the brief requires visual elements that are absent or unsuitable, call the appropriate generation tool during the same game implementation task; do not continue with placeholder art and silently defer generation. 2. For one new image, call `agc_generate_image` with `kind="image"` (or `character`, `icon-spec`, `ui-prototype`, or `publication-material` when that is the explicit intent). For changes to an existing registered image, call `agc_edit_image` with its `sourceLocalAssetId`; do not fake an edit with a new-image request. For a complete game-art package, call `taonier_prepare_game_art` only when the current intent requires new or recoverable platform art. Use `mode="regenerate"` only after the latest User message is a standalone reviewed immediate-confirmation command such as `请重新生成美术`; punctuation may end it, but no brief, condition, negation, alternative, cost qualifier, deferral, or other text may accompany it. Describe the desired style and gameplay constraints in an earlier non-billable turn, then obtain the standalone confirmation turn; otherwise use `mode="reuse-or-create"`. Quoted UI copy or examples, explanations, questions, historical wording, model/MCP arguments do not authorize regeneration. Pass a concise game-specific visual brief that names the required gameplay entities, background exclusions, tiling needs, and viewport constraints. Do not call either generation tool for greetings, date questions, or text-only code fixes. 3. Treat the tool result as authoritative. Read `mode`, `assetPaths`, `slicePaths`, `resources`, and every entry in both `warnings` and `sliceWarnings`. `resources` is the client's safe projection of registered Canvas identities; use only its returned relative paths and identities. Never invent a resource, slice, platform identity, warning-free result, or successful regeneration. -4. A newly created or explicitly regenerated standard package is complete only when `slicePaths` contains the four canonical independent slices. An empty or partial `slicePaths` result never satisfies an independent-asset requirement; stop and report the warning instead of guessing atlas coordinates or fabricating derivatives. A trusted legacy complete sheet may still be used without slices only when the current request does not require independent assets. -5. Inspect the returned background, complete sheet, and available slice previews before integrating them. Then use suitable returned runtime assets in the game's actual visible experience and confirm their visible use in desktop and mobile playtest evidence. `art-spec.png` is a reference specification, not a runtime background, character, prop, or effect. Background exclusions, seamless tiling, entity semantics, and final draw dimensions are visual/runtime acceptance checks; a prompt alone does not prove them. A hidden or side-panel preview does not count as gameplay use. +4. A newly created or explicitly regenerated standard package is complete only when `slicePaths` contains the four canonical independent slices. An empty or partial `slicePaths` result never satisfies an independent-asset requirement: if a `sliceWarning` reports too many or unusable elements, narrow the edit/generation brief or generate the needed independent images and continue the integration; do not guess atlas coordinates, fabricate derivatives, or silently fall back to placeholders. A trusted legacy complete sheet may still be used without slices only when the current request does not require independent assets. +5. Inspect the returned background, complete sheet, and available slice previews before integrating them. Then use suitable returned runtime assets in the game's actual visible experience and confirm their visible use in desktop and mobile playtest evidence. The implementation is incomplete while generated assets remain unused, are referenced only by documentation, or are replaced by emoji, CSS shapes, or other placeholders where the brief requires the generated art. `art-spec.png` is a reference specification, not a runtime background, character, prop, or effect. Background exclusions, seamless tiling, entity semantics, and final draw dimensions are visual/runtime acceptance checks; a prompt alone does not prove them. A hidden or side-panel preview does not count as gameplay use. 6. Preserve warning details in the final report. If the tool reports missing credentials, uncertain operation state, invalid provenance, download failure, or decode failure, stop and report the actionable reason; do not substitute generated CSS shapes and call the platform step complete. Before interpreting async recovery, source-preserved warnings, or slice warnings, read `references/platform-art-contract.md`. diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 19f473cca..b2aaf7048 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -4999,7 +4999,7 @@ case "$extra_roots" in *'"method":"skills/extraRoots/set"'*) ;; *) exit 87 ;; es printf '%s\n' '{"id":2,"result":{}}' IFS= read -r skills_list case "$skills_list" in *'"method":"skills/list"'*) ;; *) exit 88 ;; esac -printf '%s\n' '{"id":3,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}' +printf '%s\n' '{"id":3,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}' while IFS= read -r line; do :; done "#, ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index bf7c9ef2e..2e4cdad61 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -10,7 +10,7 @@ const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024; const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。"; -const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。DirectProject 的 Phaser 迁移固定使用 workspaceMode=DirectProject:识别已有 game/index.html 后,完整迁移状态、输入、敌人/守卫、波次、胜负、重开和画布绘制到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后才可 preview.start,并分别 preview.validate 桌面与移动视口。不能把 Phaser 项目走 gameHtml 单文件协议。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明并识别实际引擎与工程结构。用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎,而当前目录不具备对应工程结构时,必须先说明不匹配并提出澄清;在澄清前不得把请求改写成 Phaser/Web 实现,也不得写文件、安装依赖、构建或试玩。仅当用户确认继续当前工程或提供了匹配的项目目录后才执行。识别为 Cocos Creator 项目时,优先使用 `agc_cocos_execute` 或 Cocos 插件的 `cocos.editor.execute` 在已打开的 Creator 编辑器中操作;不要创建 Phaser 文件,不要把 Cocos 请求改写成 Web 工程。新 Web 游戏使用 npm + Vite,Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、patch 和命令参数可以使用 DirectProject Codex app-server 声明的完整访问权限;优先使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`,便于用户理解和审计,但不再把项目路径、`.agent/`、`.git/` 或其它目录做成 Codex 原生能力白名单。若 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径;调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文,不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。凭据、Token、Cookie、auth.json、`.env` 和 Runtime 私有控制面仍不得主动输出到对话、工具参数或日志。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; +const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。DirectProject 的 Phaser 迁移固定使用 workspaceMode=DirectProject:识别已有 game/index.html 后,完整迁移状态、输入、敌人/守卫、波次、胜负、重开和画布绘制到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后才可 preview.start,并分别 preview.validate 桌面与移动视口。不能把 Phaser 项目走 gameHtml 单文件协议。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明并识别实际引擎与工程结构。用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎,而当前目录不具备对应工程结构时,必须先说明不匹配并提出澄清;在澄清前不得把请求改写成 Phaser/Web 实现,也不得写文件、安装依赖、构建或试玩。仅当用户确认继续当前工程或提供了匹配的项目目录后才执行。识别为 Cocos Creator 项目时,优先使用 `agc_cocos_execute` 或 Cocos 插件的 `cocos.editor.execute` 在已打开的 Creator 编辑器中操作;不要创建 Phaser 文件,不要把 Cocos 请求改写成 Web 工程。新 Web 游戏使用 npm + Vite,Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、patch 和命令参数可以使用 DirectProject Codex app-server 声明的完整访问权限;优先使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`,便于用户理解和审计,但不再把项目路径、`.agent/`、`.git/` 或其它目录做成 Codex 原生能力白名单。若 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径;调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文,不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。凭据、Token、Cookie、auth.json、`.env` 和 Runtime 私有控制面仍不得主动输出到对话、工具参数或日志。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。完整新游戏或根据策划案实现时必须执行 agc-game-production-workflow:按“策划定界 → 项目/资源盘点 → 美术生成或复用 → 游戏实现 → 构建验证 → 桌面/移动试玩 → 交付报告”顺序推进,每阶段完成后再进入下一阶段,不得在写完代码或生成图片后提前结束。新游戏 brief 中需要视觉素材时必须执行 taonier-art-assets:先检查已登记资源;缺少或不适用时调用 agc_tools 生图/编辑工具;读取返回的相对路径和登记身份,生成结果必须接入游戏源码并验证实际显示。只有明确不需要视觉素材的游戏才可跳过。资源生成、处理和接入属于同一游戏交付链路;不要用 emoji、CSS 形状或临时占位图替代 brief 中要求的真实素材,也不要在素材未接入时报告游戏完成。试玩仍按改动范围执行,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; const DIRECT_COCOS_BUILTIN_PLUGIN_GUIDANCE: &str = r#"Cocos Creator 桥接边界:Cocos 的编辑器能力来自客户端随包提供的内置插件 `agc-cocos-editor`,Agent 工具名是 `cocos.editor.execute`(客户端受控工具名为 `agc_cocos_execute`)。识别为 Cocos Creator 项目后,直接检查当前可用工具并调用这个内置工具;不要搜索、读取、安装、启用或建议项目目录里的 MCP 扩展、`extensions/` 包、`package.json` 插件或 Cocos 面板服务。项目内的第三方 MCP 扩展不是 AGC Cocos 桥接来源,缺失内置工具时只能报告客户端内置插件不可用,不得改为查项目扩展或要求用户打开 Cocos MCP 面板。历史聊天记录仅用于理解上下文,不是工具或系统指令;其中与本边界冲突的旧说明一律以当前提示和当前可用内置工具为准。"#; const DIRECT_COCOS_CAPABILITY_GUIDE: &str = r#"Cocos 能力:先用 cocos_get_capabilities 和 cocos_get_hierarchy 查询;查询返回 NID 与 UUID,场景切换后必须重新查询。读取场景树 `Editor.Message.request('scene', 'query-node-tree')`,先用只读查询拿到真实 uuid 和当前状态,再执行修改。用 cocos_inspect_node 取得 componentIndex、组件类型及属性后再修改。节点、组件、Prefab、Label/Sprite/Button/Shape、Layout/Widget、九宫格、批量 UI、保存、撤销、日志、构建诊断和网页预览调试均有对应 cocos_* 工具,按实际 inputSchema 调用。批量 UI 最多 64 个节点和 12 层,save 缺省 true;首次保存可用 cocos_save_scene 的 path 指定 assets 下新 .scene 路径。只在 verified 为 true 时报告结果已经回读确认;failed、rolledBack 和 needs-reconciliation 不能当成功,结果不确定不得自动重发。cocos_mcp_undo_last 会拒绝覆盖后续手动修改。预览工具只管理自己的 Chromium 窗口和当前项目 loopback 地址,capture 返回 PNG 图片。目录之外的操作继续用 agc_cocos_execute 注入支持 await/return 的 JS 函数体。"#; const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png"; @@ -4862,8 +4862,11 @@ mod tests { assert!(prompt.contains("agc_write_file")); assert!(prompt.contains("content 必须是目标文件的完整原始 UTF-8 正文")); assert!(prompt.contains("不得把 command.exec 的 Exit code、Wall time、Output 包装")); - assert!(prompt.contains("切图、资源依赖、规范图和试玩都只是可选工具提示")); - assert!(prompt.contains("不要求调用、固定顺序或特定产物")); + assert!(prompt.contains("新游戏 brief 中需要视觉素材时必须执行 taonier-art-assets")); + assert!(prompt.contains("生成结果必须接入游戏源码并验证实际显示")); + assert!( + prompt.contains("完整新游戏或根据策划案实现时必须执行 agc-game-production-workflow") + ); } #[test] @@ -4992,7 +4995,7 @@ mod tests { assert!(!prompt.contains("客户端会在系统上下文提供有界的当前游戏文件快照")); assert!(prompt.contains("Codex 不直接保存或伪造项目版本")); assert!(prompt.contains("普通对话直接回答且不触碰工作区")); - assert!(prompt.contains("切图、资源依赖、规范图和试玩都只是可选工具提示")); + assert!(prompt.contains("新游戏 brief 中需要视觉素材时必须执行 taonier-art-assets")); assert!(prompt.contains("用户不需要、也不得向你提供、配置、粘贴或创建 API Key")); assert!(prompt.contains("工具返回 401/403 时,只说明 AGC 客户端登录或权限状态异常并停止")); assert!(!prompt.contains("Use real platform assets only")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs index cdccf120d..b41e326f7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs @@ -1156,6 +1156,10 @@ mod tests { assert!(with_canvas.contains("根据当前玩法需求编写规格和界面建议")); assert!(with_canvas.contains("用途、数量、输出路径、尺寸、参考资源和是否需要 spritesheet")); assert!(with_canvas.contains("再调用 canvas.asset_generate")); + assert!(with_canvas.contains("调用 canvas.asset_generate")); + assert!(with_canvas.contains("不要使用固定图片合同")); + assert!(with_canvas.contains("不修改 game/index.html")); + assert!(!with_canvas.contains("不调用 canvas.asset_generate")); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs index f5bcfdf30..78a9cf3d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs @@ -801,7 +801,7 @@ fn agent_runtime_action_receipt_safe_detail_with_owner( let initial_step = route.get("initialStep")?.as_str()?; let render_mode = route.get("renderMode")?.as_str()?; if resource_id.is_empty() - || initial_step != "visual-binding" + || initial_step != "asset-separation" || render_mode != "final-preview" { return None; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs index 9cb4fda28..5c5f9e602 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs @@ -6,15 +6,16 @@ use std::path::{Component, Path}; const AGC_SKILL_PACK_MANIFEST: &[u8] = include_bytes!("../../resources/agc-skills/manifest.json"); const AGC_SKILL_PACK_SCHEMA_VERSION: &str = "agc-skill-pack.v1"; -pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 5] = [ +pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 6] = [ "agc-browser-playtest", "agc-client-projection", + "agc-game-production-workflow", "agc-project-structure", "agc-web-game-development", "taonier-art-assets", ]; -const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 15] = [ +const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 18] = [ ( "agc-browser-playtest/SKILL.md", include_bytes!("../../resources/agc-skills/agc-browser-playtest/SKILL.md"), @@ -43,6 +44,20 @@ const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 15] = [ "../../resources/agc-skills/agc-client-projection/references/projection-contract.md" ), ), + ( + "agc-game-production-workflow/SKILL.md", + include_bytes!("../../resources/agc-skills/agc-game-production-workflow/SKILL.md"), + ), + ( + "agc-game-production-workflow/agents/openai.yaml", + include_bytes!("../../resources/agc-skills/agc-game-production-workflow/agents/openai.yaml"), + ), + ( + "agc-game-production-workflow/references/workflow-contract.md", + include_bytes!( + "../../resources/agc-skills/agc-game-production-workflow/references/workflow-contract.md" + ), + ), ( "agc-project-structure/SKILL.md", include_bytes!("../../resources/agc-skills/agc-project-structure/SKILL.md"), @@ -291,10 +306,10 @@ mod tests { use super::*; #[test] - fn bundled_skill_pack_is_exactly_the_five_reviewed_skills() { + fn bundled_skill_pack_is_exactly_the_six_reviewed_skills() { let manifest = validated_skill_pack_manifest().expect("validated manifest"); assert_eq!(manifest.schema_version, "agc-skill-pack.v1"); - assert_eq!(manifest.skills.len(), 5); + assert_eq!(manifest.skills.len(), 6); assert!(manifest.skills.iter().all(|entry| entry.sha256.len() == 64)); let serialized = serde_json::to_string( &manifest 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 3813a0eb6..4d5030c2a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -339,6 +339,41 @@ async fn recognize_ui( ui_editor::commands::recognize_ui_impl(project_path, state).await } +#[tauri::command] +async fn separate_ui( + project_path: String, + asset_id: String, + state: ui_editor::state::State, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + ui_editor::commands::separate_ui_impl(project_path, asset_id, state).await +} + +#[tauri::command] +fn inspect_separation_recovery( + project_path: String, + asset_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + ui_editor::commands::separation::inspect_separation_recovery(root, &asset_id) +} + +#[tauri::command] +fn finalize_separation(project_path: String, asset_id: String) -> Result<(), String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + ui_editor::commands::separation::finalize_separation(root, &asset_id) +} + +#[tauri::command] +fn discard_separation_recovery(project_path: String, asset_id: String) -> Result<(), String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + ui_editor::commands::separation::discard_separation_recovery(root, &asset_id) +} + #[tauri::command] async fn merge_ui(state: ui_editor::state::State) -> Result { ui_editor::commands::merge_ui_impl(state).await @@ -2689,6 +2724,10 @@ fn main() { check_ui_editor_font_glyph_coverage, suggest_ui_design_semantic, recognize_ui, + separate_ui, + inspect_separation_recovery, + finalize_separation, + discard_separation_recovery, merge_ui, bind_components, load_ui_design_state, 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 index 4585eee85..797f8c44d 100644 --- 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 @@ -5,11 +5,9 @@ use crate::ui_editor::commands::utils::{ strict_json_schema, }; use crate::ui_editor::component::text::FontSource; -use crate::ui_editor::component::Component; +use crate::ui_editor::component::{Component, NodeComponent}; 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::persistence::UI_DESIGN_STATE_MAX_NODES; use crate::ui_editor::state::State; use crate::ui_editor::utils::{FontAssetId, NodeId, SpriteAssetId}; use platform_llm::{ @@ -31,10 +29,10 @@ const SYSTEM_PROMPT: &str = r#" 你是游戏 UI 组件绑定器。你会看到全部 UI 参考图、可编辑节点说明,以及本批独立素材的真实像素。 * 只对视觉上确实需要改变组件的节点返回 changes; -* 每个 change 的 components 是该节点完整的新渲染栈,空数组表示明确清空。数组顺序从底到顶渲染。 -* 对每个 Component,直接完整返回其全部参数. +* 每个 change 的 component 是该节点完整的新组件;纯结构节点返回 "PureNode",有组件返回 {"WithComponent": <完整 Component>}。 +* 对 Component,直接完整返回其全部参数. * 有任何困难或者不确定把状态设为 NeedReview,说明中文原因。 -* 纯结构节点可以返回空数组并标为 NoProblem。 +* 纯结构节点可以返回 "PureNode" 并标为 NoProblem。 * 容器背景等推荐使用Simple + preserve_aspect: false 实现与node大小一致 * 面向用户的 reason 使用中文。 @@ -53,8 +51,8 @@ enum DraftStatus { #[schemars(deny_unknown_fields)] struct BindingChangeDraft { node_id: NodeId, - components: Vec, - components_status: DraftStatus, + component: NodeComponent, + component_status: DraftStatus, } #[derive(Clone, Debug, Deserialize, JsonSchema)] @@ -68,8 +66,8 @@ struct BindingResponse { #[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, + pub component: NodeComponent, + pub component_status: StageStatus, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] @@ -83,7 +81,7 @@ struct EditableNodeContext<'a> { node_id: &'a NodeId, name: &'a str, description: &'a str, - components: &'a [Component], + component: Option<&'a Component>, } #[derive(Debug, Serialize)] @@ -106,7 +104,7 @@ fn collect_editable_nodes<'a>(node: &'a Node, output: &mut Vec UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE { - return Err(format!( - "单个组件绑定栈不能超过 {UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE} 个组件" - )); + let Some(object) = change.as_object() else { + return Err("组件绑定 change 缺少 component 字段".to_string()); + }; + let Some(component) = object.get("component") else { + return Err("组件绑定 change 缺少 component 字段".to_string()); + }; + let valid_component = component == "PureNode" + || component + .as_object() + .and_then(|value| value.get("WithComponent")) + .is_some_and(serde_json::Value::is_object); + if !valid_component { + return Err( + "组件绑定 change 的 component 必须是 PureNode 或 WithComponent 对象".to_string(), + ); } } Ok(()) @@ -212,7 +217,7 @@ fn validate_and_materialize( if !changed_ids.insert(change.node_id.clone()) { return Err(format!("组件绑定重复返回节点:{}", change.node_id.as_str())); } - for component in &change.components { + if let NodeComponent::WithComponent(component) = &change.component { match component { Component::Image(image) => { if image @@ -232,18 +237,22 @@ fn validate_and_materialize( } } } - let components = change.components; - let components_status = match change.components_status { + let component_status = match change.component_status { DraftStatus::NoProblem => StageStatus::NoProblem, DraftStatus::NeedReview(reason) if reason.trim().is_empty() => { return Err("组件待审状态必须包含原因".to_string()) } - DraftStatus::NeedReview(reason) => StageStatus::NeedReview(reason), + DraftStatus::NeedReview(reason) => { + if matches!(&change.component, NodeComponent::PureNode) { + return Err("纯结构节点不能标记为组件待审".to_string()); + } + StageStatus::NeedReview(reason) + } }; materialized.push(BindingChange { node_id: change.node_id, - components, - components_status, + component: change.component, + component_status, }); } Ok(BindingDTO { @@ -440,8 +449,8 @@ mod tests { ]); let unapproved = BindingChangeDraft { node_id: id("other"), - components: Vec::new(), - components_status: DraftStatus::NoProblem, + component: NodeComponent::PureNode, + component_status: DraftStatus::NoProblem, }; assert!( validate_and_materialize(vec![unapproved], &editable, &known, &HashSet::new()).is_err() @@ -450,7 +459,7 @@ mod tests { // 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( + component: NodeComponent::WithComponent(Component::Image( crate::ui_editor::component::image::ImageComponent { target_graphic: Some( SpriteAssetId::new("other-batch-sprite").expect("valid sprite"), @@ -459,8 +468,8 @@ mod tests { preserve_aspect: false, }, }, - )], - components_status: DraftStatus::NoProblem, + )), + component_status: DraftStatus::NoProblem, }; assert!( validate_and_materialize(vec![other_batch], &editable, &known, &HashSet::new()).is_ok() @@ -469,15 +478,15 @@ mod tests { // 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( + component: NodeComponent::WithComponent(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, + )), + component_status: DraftStatus::NoProblem, }; assert!( validate_and_materialize(vec![unknown], &editable, &known, &HashSet::new()).is_err() @@ -491,8 +500,8 @@ mod tests { text.font = FontSource::Bound(FontAssetId::new("unknown-font").expect("valid font")); let change = BindingChangeDraft { node_id: id("editable"), - components: vec![Component::Text(text)], - components_status: DraftStatus::NoProblem, + component: NodeComponent::WithComponent(Component::Text(text)), + component_status: DraftStatus::NoProblem, }; let error = validate_and_materialize( @@ -506,13 +515,13 @@ mod tests { } #[test] - fn materialization_preserves_changed_only_empty_component_lists() { + fn materialization_preserves_pure_node_change() { let editable = HashSet::from([id("editable")]); let result = validate_and_materialize( vec![BindingChangeDraft { node_id: id("editable"), - components: Vec::new(), - components_status: DraftStatus::NoProblem, + component: NodeComponent::PureNode, + component_status: DraftStatus::NoProblem, }], &editable, &HashSet::new(), @@ -520,8 +529,28 @@ mod tests { ) .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); + assert!(matches!( + result.changes[0].component, + NodeComponent::PureNode + )); + assert_eq!(result.changes[0].component_status, StageStatus::NoProblem); + } + + #[test] + fn materialization_rejects_problematic_pure_node() { + let editable = HashSet::from([id("editable")]); + let error = validate_and_materialize( + vec![BindingChangeDraft { + node_id: id("editable"), + component: NodeComponent::PureNode, + component_status: DraftStatus::NeedReview("缺少可确认的组件".to_string()), + }], + &editable, + &HashSet::new(), + &HashSet::new(), + ) + .expect_err("pure node cannot carry a component review status"); + assert!(error.contains("纯结构节点")); } #[test] @@ -575,20 +604,26 @@ mod tests { } #[test] - fn binding_response_bounds_changes_and_each_component_stack() { + fn binding_response_bounds_changes_and_uses_single_component_shape() { let too_many_changes = serde_json::json!({ - "changes": [{"components": []}, {"components": []}] + "changes": [{"component": "PureNode"}, {"component": "PureNode"}] }); assert!(validate_binding_response_shape(&too_many_changes, 1).is_err()); - let too_many_components = serde_json::json!({ + let one_component = serde_json::json!({ "changes": [{ - "components": (0..=UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE) - .map(|_| serde_json::Value::Null) - .collect::>() + "node_id": "editable", + "component": "PureNode", + "component_status": "NoProblem" }] }); - assert!(validate_binding_response_shape(&too_many_components, 1).is_err()); + assert!(validate_binding_response_shape(&one_component, 1).is_ok()); + let parsed = parse_binding_response(&one_component.to_string(), 1) + .expect("explicit PureNode payload should parse"); + assert!(matches!( + parsed.changes[0].component, + NodeComponent::PureNode + )); } #[tokio::test] 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 index e8a789948..0a32724d6 100644 --- 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 @@ -174,7 +174,7 @@ mod materialize { }; use crate::ui_editor::layout::transform::Transform; use crate::ui_editor::state::{State, UITree}; - use crate::ui_editor::utils::{NodeId, UIDesignImageId}; + use crate::ui_editor::utils::{random_node_id, NodeId, UIDesignImageId}; use std::collections::{HashMap, HashSet}; #[derive(Clone)] @@ -230,12 +230,11 @@ mod materialize { Ok(best_index) } - fn random_node_id(occupied: &mut HashSet) -> Result { + fn unique_random_node_id(occupied: &mut HashSet) -> NodeId { loop { - let id = NodeId::new(uuid::Uuid::new_v4().simple().to_string()) - .map_err(|error| format!("生成合并容器节点 ID 失败:{error}"))?; + let id = random_node_id(); if occupied.insert(id.clone()) { - return Ok(id); + return id; } } } @@ -286,7 +285,7 @@ mod materialize { } Ok(BuiltNode { node: LayoutNode { - id: random_node_id(occupied_ids)?, + id: unique_random_node_id(occupied_ids), layout: crate::ui_editor::layout::control_layout::ControlLayout::with_transform( original_transform, @@ -295,12 +294,12 @@ mod materialize { name: container_name, description: container_description, layout_status: StageStatus::NoProblem, - components_status: StageStatus::NoProblem, + component_status: StageStatus::NoProblem, allow_llm_edit_layout: true, allow_llm_edit_component: true, source: NodeSource::Llm, }, - components: Vec::new(), + component: None, children_display_mode: ChildrenDisplayMode::Exclusive, children: members.into_iter().map(|member| member.node).collect(), }, @@ -525,7 +524,6 @@ mod tests { 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}; @@ -542,12 +540,12 @@ mod tests { name: id.to_string(), description: String::new(), layout_status: StageStatus::NoProblem, - components_status: StageStatus::NoProblem, + component_status: StageStatus::NoProblem, allow_llm_edit_layout: true, allow_llm_edit_component: true, source: NodeSource::Human, }, - components: Vec::::new(), + component: None, children_display_mode: ChildrenDisplayMode::Stack, children, } @@ -586,7 +584,7 @@ mod tests { ChildrenDisplayMode::Exclusive ); assert_eq!( - result.root.metadata.components_status, + result.root.metadata.component_status, StageStatus::NoProblem ); assert_eq!(result.root.children.len(), 2); 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 index e2e1a1bc5..d85432e07 100644 --- 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 @@ -1,6 +1,7 @@ pub mod binding; pub mod merge; pub mod recognition; +pub mod separation; pub mod ui_design_suggestion; pub mod utils; @@ -10,5 +11,7 @@ pub use merge::MergeDTO; pub(crate) use merge::{merge_ui_impl, merge_ui_impl_with_provider}; pub use recognition::RecognitionDTO; pub(crate) use recognition::{recognize_ui_impl, recognize_ui_impl_with_provider}; +pub(crate) use separation::separate_ui_impl; +pub use separation::{SeparationDTO, SeparationRecoveryDTO}; 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 index 081db0bb1..97e7ba78c 100644 --- 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 @@ -4,6 +4,7 @@ use crate::ui_editor::commands::utils::{ parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, request_ui_editor_llm, strict_json_schema, }; +use crate::ui_editor::component::{Component, NodeComponent}; use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode; use crate::ui_editor::layout::control_layout::ControlLayout; use crate::ui_editor::layout::dimension::UIRect; @@ -11,7 +12,7 @@ use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata, NodeSourc 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 crate::ui_editor::utils::{random_node_id, NodeId, UIDesignImageId}; use nalgebra::{Point2, Vector2}; use platform_llm::{ LlmFunctionTool, LlmMessage, LlmMessageContentPart, LlmRunRequest, LlmToolChoice, @@ -27,25 +28,31 @@ 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,不识别场景人物、地形、建筑、光影和背景装饰。 +* 只识别 UI元素. 要区分动态内容, 不要白费力气识别应该由程序生成/绘制的内容.(此类内容应该用一个整体节点+自然语言描述) 除此之外必须完整包含所有元素,结构. * 无法确定类型、层级、关系时,在 UnSure 中写明原因。 * 返回的 trees 必须与输入图片一一对应,每张输入图片只能有一棵树,不能合并多张图片的树。 每棵树的 src_ui_design_image_id 必须等于对应输入图片标注的 id。 +* 每棵树root的 global_pos_x_px、global_pos_y_px、width_px、height_px、local_anchor 仅为占位并会被忽略,给合法值即可. * 每棵树必须使用自己的输入图片原始像素坐标系(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等 +* 粒度要求: 尽可能细致, 以可交互,方便程序化控制的最小单位为准. 包括不限于: icon, 进度条的底槽、填充和外框; slider的底槽, dragger等. +* 为每个节点直接返回 component. + 无背景的逻辑容器返回 "PureNode",不要返回 null. + 有背景的容器推荐使用Simple+不锁定宽高比的Image component. + 目前我们只做识别, 不要求图片字体的具体绑定参数. + 文字组件要求: 艺术字等作为图片组件, 其余正常文字要作为单独的节点识别. +* 多行文本只使用一个节点. +* 不鼓励兄弟节点相互重叠. +* 对于面板等容器的背景等, 必须作为父节点的组件, 禁止新增冗余的所谓"背景节点". 例如:对于全局的背景直接作为root节点的图片组件, 禁止另外添加节点 "#; @@ -109,13 +116,14 @@ struct RecognitionNode { description: String, children: Vec, confidence: Confidence, + component: NodeComponent, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] #[schemars(deny_unknown_fields)] struct RecognitionTree { src_ui_design_image_id: UIDesignImageId, - children: Vec, + root: RecognitionNode, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] @@ -143,10 +151,14 @@ fn validate_recognition_response_shape(value: &serde_json::Value) -> Result<(), return Err(format!("识别结果最多包含 {MAX_REFERENCES} 棵界面树")); } for tree in trees { - let children = tree + let root = tree + .get("root") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| "识别树缺少 root 节点".to_string())?; + let children = root .get("children") .and_then(serde_json::Value::as_array) - .ok_or_else(|| "识别树缺少 children 数组".to_string())?; + .ok_or_else(|| "识别树根节点缺少 children 数组".to_string())?; let mut stack = children .iter() .map(|node| (node, 1usize)) @@ -215,11 +227,6 @@ fn anchor_ranges(anchor: &Anchor) -> Result<(Vector2, Vector2), 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 { @@ -301,21 +308,18 @@ fn convert_node( .map(|child| convert_node(child, image_id, image, target_rect)) .collect::, _>>()?; Ok(LayoutNode { - id: random_node_id()?, + 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, + component_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(), + component: source.component.clone().into_option(), children_display_mode: ChildrenDisplayMode::Stack, children, }) @@ -323,6 +327,26 @@ fn convert_node( fn validate_confidence(nodes: &[RecognitionNode]) -> Result<(), String> { for node in nodes { + if let NodeComponent::WithComponent(component) = &node.component { + if matches!( + component, + Component::Image(crate::ui_editor::component::image::ImageComponent { + target_graphic: Some(_), + .. + }) + ) { + return Err("识别阶段不能返回已绑定的 SpriteAssetId".to_string()); + } + if matches!( + component, + Component::Text(crate::ui_editor::component::text::TextComponent { + font: crate::ui_editor::component::text::FontSource::Bound(_), + .. + }) + ) { + return Err("识别阶段不能返回已绑定的字体素材".to_string()); + } + } if let Confidence::UnSure(reason) = &node.confidence { if reason.trim().is_empty() { return Err("UnSure 必须包含审阅原因".to_string()); @@ -346,7 +370,7 @@ fn validate_tree_image_ids( if !seen.insert(tree.src_ui_design_image_id.clone()) { return Err("LLM 为同一界面图返回了重复 UI 树".to_string()); } - validate_confidence(&tree.children)?; + validate_confidence(std::slice::from_ref(&tree.root))?; } if seen.len() != allowed.len() { return Err("LLM 未为当前识别上下文的每张界面图返回 UI 树".to_string()); @@ -387,6 +411,7 @@ mod tests { description: String::new(), children: Vec::new(), confidence: Confidence::Confident, + component: NodeComponent::PureNode, } } @@ -425,8 +450,8 @@ mod tests { .collect::>(); let response = serde_json::json!({ "trees": [ - {"children": leaves.clone()}, - {"children": leaves} + {"root": {"children": leaves.clone()}}, + {"root": {"children": leaves}} ] }); validate_recognition_response_shape(&response) @@ -439,7 +464,7 @@ mod tests { .map(|_| serde_json::json!({"children": []})) .collect::>(); assert!(validate_recognition_response_shape(&serde_json::json!({ - "trees": [{"children": oversized}] + "trees": [{"root": {"children": oversized}}] })) .is_err()); @@ -448,7 +473,7 @@ mod tests { nested = serde_json::json!({"children": [nested]}); } assert!(validate_recognition_response_shape(&serde_json::json!({ - "trees": [{"children": [nested]}] + "trees": [{"root": {"children": [nested]}}] })) .is_err()); } @@ -490,6 +515,31 @@ mod tests { description: String::new(), children: Vec::new(), confidence: Confidence::UnSure(String::new()), + component: NodeComponent::PureNode, + }; + assert!(validate_confidence(&[node]).is_err()); + } + + #[test] + fn recognition_rejects_bound_font_references() { + let mut text = crate::ui_editor::component::text::TextComponent::default(); + text.font = crate::ui_editor::component::text::FontSource::Bound( + crate::ui_editor::utils::FontAssetId::new("font").expect("valid font id"), + ); + 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: "文本".to_string(), + description: String::new(), + children: Vec::new(), + confidence: Confidence::Confident, + component: NodeComponent::WithComponent(Component::Text(text)), }; assert!(validate_confidence(&[node]).is_err()); } @@ -506,7 +556,7 @@ mod tests { 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); + assert_eq!(converted.metadata.component_status, StageStatus::NoProblem); } #[test] @@ -542,7 +592,7 @@ mod tests { let slave = UIDesignImageId::new("slave").expect("valid image id"); let tree = |id: UIDesignImageId| RecognitionTree { src_ui_design_image_id: id, - children: Vec::new(), + root: test_node(), }; assert!(validate_tree_image_ids( @@ -775,24 +825,37 @@ pub(crate) async fn recognize_ui_impl_with_provider( .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 + // The recognition root is a real UI node. Its pixel geometry and + // anchor are intentionally ignored; the page root always fills + // the design image while the other recognition fields take effect. + let recognition_root = tree.root; + let children = recognition_root .children .iter() .map(|node| convert_node(node, &image_id, image, root_rect)) .collect::, _>>()?; + let root_layout_status = match recognition_root.confidence { + Confidence::Confident => StageStatus::NoProblem, + Confidence::UnSure(reason) => StageStatus::NeedReview(reason), + }; + let root_name = if recognition_root.name.trim().is_empty() { + "页面根节点".to_string() + } else { + recognition_root.name + }; let root = LayoutNode { - id: random_node_id()?, + 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, + name: root_name, + description: recognition_root.description, + layout_status: root_layout_status, + component_status: StageStatus::NoProblem, allow_llm_edit_layout: true, allow_llm_edit_component: true, - source: NodeSource::System, + source: NodeSource::Llm, }, - components: Vec::new(), + component: recognition_root.component.into_option(), children_display_mode: ChildrenDisplayMode::Stack, children, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/area.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/area.rs new file mode 100644 index 000000000..d861884d8 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/area.rs @@ -0,0 +1,480 @@ +use super::model::BindingArea; +use image::RgbaImage; +use std::time::Instant; + +/// Each edge may move by at most this many pixels from the area returned by +/// the visual model. Keep this policy explicit so changing it is an +/// intentional workflow decision rather than a scattered numeric literal. +pub(crate) const MAX_BINDING_AREA_EDGE_ADJUSTMENT_PX: u32 = 32; + +/// Alpha values below this threshold are treated as transparent for boundary +/// detection. The cropped pixels themselves are preserved unchanged. +pub(crate) const MIN_VISIBLE_ALPHA: u8 = 16; + +/// An edge needs this many consecutive visible pixels to count as supported. +/// The requirement is reduced to the edge length for one-pixel-wide elements. +pub(crate) const MIN_CONSECUTIVE_VISIBLE_EDGE_PIXELS: usize = 2; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct NormalizedBindingArea { + pub(crate) area: BindingArea, + pub(crate) changed: bool, + pub(crate) clamped: bool, + pub(crate) transparent: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum EdgeDirection { + Inward, + Outward, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct Rect { + left: u32, + top: u32, + right: u32, + bottom: u32, +} + +impl Rect { + fn from_area(area: BindingArea) -> Self { + Self { + left: area.global_pos_x_px, + top: area.global_pos_y_px, + right: area.global_pos_x_px + area.width_px, + bottom: area.global_pos_y_px + area.height_px, + } + } + + fn into_area(self) -> BindingArea { + BindingArea { + global_pos_x_px: self.left, + global_pos_y_px: self.top, + width_px: self.right - self.left, + height_px: self.bottom - self.top, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Edge { + Left, + Right, + Top, + Bottom, +} + +impl Edge { + const ALL: [Self; 4] = [Self::Left, Self::Right, Self::Top, Self::Bottom]; +} + +fn pixel_is_visible(alpha: u8) -> bool { + alpha >= MIN_VISIBLE_ALPHA +} + +fn has_consecutive_visible_pixels(alphas: I, required: usize) -> bool +where + I: IntoIterator, +{ + let required = required.max(1); + let mut consecutive = 0usize; + for alpha in alphas { + if pixel_is_visible(alpha) { + consecutive = consecutive.saturating_add(1); + if consecutive >= required { + return true; + } + } else { + consecutive = 0; + } + } + false +} + +fn edge_has_visible_pixel(image: &RgbaImage, rect: Rect, edge: Edge) -> bool { + let edge_length = match edge { + Edge::Left | Edge::Right => rect.bottom - rect.top, + Edge::Top | Edge::Bottom => rect.right - rect.left, + } as usize; + let required = MIN_CONSECUTIVE_VISIBLE_EDGE_PIXELS.max(1).min(edge_length); + match edge { + Edge::Left | Edge::Right => { + let x = if edge == Edge::Left { + rect.left + } else { + rect.right - 1 + }; + has_consecutive_visible_pixels( + (rect.top..rect.bottom).map(|y| image.get_pixel(x, y).0[3]), + required, + ) + } + Edge::Top | Edge::Bottom => { + let y = if edge == Edge::Top { + rect.top + } else { + rect.bottom - 1 + }; + has_consecutive_visible_pixels( + (rect.left..rect.right).map(|x| image.get_pixel(x, y).0[3]), + required, + ) + } + } +} + +fn rect_has_visible_pixel(image: &RgbaImage, rect: Rect) -> bool { + (rect.top..rect.bottom) + .any(|y| (rect.left..rect.right).any(|x| pixel_is_visible(image.get_pixel(x, y).0[3]))) +} + +fn edge_direction(image: &RgbaImage, rect: Rect, edge: Edge) -> EdgeDirection { + if edge_has_visible_pixel(image, rect, edge) { + EdgeDirection::Outward + } else { + EdgeDirection::Inward + } +} + +fn move_edge(rect: &mut Rect, edge: Edge, direction: EdgeDirection) { + match (edge, direction) { + (Edge::Left, EdgeDirection::Inward) => rect.left += 1, + (Edge::Left, EdgeDirection::Outward) => rect.left -= 1, + (Edge::Right, EdgeDirection::Inward) => rect.right -= 1, + (Edge::Right, EdgeDirection::Outward) => rect.right += 1, + (Edge::Top, EdgeDirection::Inward) => rect.top += 1, + (Edge::Top, EdgeDirection::Outward) => rect.top -= 1, + (Edge::Bottom, EdgeDirection::Inward) => rect.bottom -= 1, + (Edge::Bottom, EdgeDirection::Outward) => rect.bottom += 1, + } +} + +fn edge_coordinate(rect: Rect, edge: Edge) -> u32 { + match edge { + Edge::Left => rect.left, + Edge::Right => rect.right, + Edge::Top => rect.top, + Edge::Bottom => rect.bottom, + } +} + +fn edge_displacement(original: Rect, current: Rect, edge: Edge) -> u32 { + edge_coordinate(original, edge).abs_diff(edge_coordinate(current, edge)) +} + +fn edge_adjustment_limit() -> u32 { + MAX_BINDING_AREA_EDGE_ADJUSTMENT_PX +} + +fn reached_adjustment_limit(original: Rect, current: Rect, edge: Edge) -> bool { + edge_displacement(original, current, edge) >= edge_adjustment_limit() +} + +fn can_move_geometrically( + image: &RgbaImage, + current: Rect, + edge: Edge, + direction: EdgeDirection, +) -> bool { + match (edge, direction) { + (Edge::Left, EdgeDirection::Inward) => current.left + 1 < current.right, + (Edge::Left, EdgeDirection::Outward) => current.left > 0, + (Edge::Right, EdgeDirection::Inward) => current.right > current.left + 1, + (Edge::Right, EdgeDirection::Outward) => current.right < image.width(), + (Edge::Top, EdgeDirection::Inward) => current.top + 1 < current.bottom, + (Edge::Top, EdgeDirection::Outward) => current.top > 0, + (Edge::Bottom, EdgeDirection::Inward) => current.bottom > current.top + 1, + (Edge::Bottom, EdgeDirection::Outward) => current.bottom < image.height(), + } +} + +fn next_edge_rect(rect: Rect, edge: Edge, direction: EdgeDirection) -> Option { + let mut next = rect; + match (edge, direction) { + (Edge::Left, EdgeDirection::Inward) if rect.left + 1 < rect.right => next.left += 1, + (Edge::Left, EdgeDirection::Outward) if rect.left > 0 => next.left -= 1, + (Edge::Right, EdgeDirection::Inward) if rect.right > rect.left + 1 => next.right -= 1, + (Edge::Right, EdgeDirection::Outward) => next.right = next.right.checked_add(1)?, + (Edge::Top, EdgeDirection::Inward) if rect.top + 1 < rect.bottom => next.top += 1, + (Edge::Top, EdgeDirection::Outward) if rect.top > 0 => next.top -= 1, + (Edge::Bottom, EdgeDirection::Inward) if rect.bottom > rect.top + 1 => next.bottom -= 1, + (Edge::Bottom, EdgeDirection::Outward) => next.bottom = next.bottom.checked_add(1)?, + _ => return None, + } + Some(next) +} + +fn edge_requires_move(image: &RgbaImage, rect: Rect, edge: Edge, direction: EdgeDirection) -> bool { + match direction { + EdgeDirection::Inward => !edge_has_visible_pixel(image, rect, edge), + EdgeDirection::Outward => { + if !edge_has_visible_pixel(image, rect, edge) { + return false; + } + if !can_move_geometrically(image, rect, edge, direction) { + return true; + } + next_edge_rect(rect, edge, direction) + .is_some_and(|next| edge_has_visible_pixel(image, next, edge)) + } + } +} + +fn apply_edge_step( + image: &RgbaImage, + original: Rect, + current: Rect, + edge: Edge, + direction: EdgeDirection, +) -> (Rect, bool, bool) { + if !edge_requires_move(image, current, edge, direction) { + return (current, false, false); + } + if reached_adjustment_limit(original, current, edge) + || !can_move_geometrically(image, current, edge, direction) + { + return (current, false, true); + } + let mut next = current; + move_edge(&mut next, edge, direction); + (next, true, false) +} + +/// Normalizes a model-provided area using visible pixels on the processed +/// transparent image. Each edge chooses inward/outward direction once from +/// its initial scan and then moves monotonically, so sparse pixels cannot make +/// the boundary oscillate. The four edge steps are calculated from the same +/// rectangle on each round. +pub(crate) fn normalize_binding_area( + image: &RgbaImage, + original_area: BindingArea, +) -> Result { + let started = Instant::now(); + if let Err(error) = original_area.validate_in(image.width(), image.height()) { + app_log!( + "ui_separation.area.timing outcome=error elapsed_us={} rounds=0 image_width={} image_height={} area=({}, {}, {}, {})", + started.elapsed().as_micros(), + image.width(), + image.height(), + original_area.global_pos_x_px, + original_area.global_pos_y_px, + original_area.width_px, + original_area.height_px + ); + return Err(error.to_string()); + } + let original = Rect::from_area(original_area); + let directions = Edge::ALL.map(|edge| edge_direction(image, original, edge)); + let mut current = original; + let mut clamped = false; + let mut active = [true; 4]; + let mut rounds = 0u32; + + // TODO: Replace the deliberately simple pixel-by-pixel scan if real UI + // design sizes show this path to be a measurable bottleneck. + while active.iter().any(|value| *value) { + rounds = rounds.saturating_add(1); + let before = current; + let mut next = current; + let mut moved = [false; 4]; + for (index, edge) in Edge::ALL.into_iter().enumerate() { + if !active[index] { + continue; + } + let (candidate, did_move, reached_limit) = + apply_edge_step(image, original, current, edge, directions[index]); + if reached_limit { + clamped = true; + active[index] = false; + } else if !did_move { + active[index] = false; + } + moved[index] = did_move; + match edge { + Edge::Left => next.left = candidate.left, + Edge::Right => next.right = candidate.right, + Edge::Top => next.top = candidate.top, + Edge::Bottom => next.bottom = candidate.bottom, + } + } + if next.left >= next.right { + clamped = true; + if moved[0] { + active[0] = false; + } + if moved[1] { + active[1] = false; + } + next.left = current.left; + next.right = current.right; + } + if next.top >= next.bottom { + clamped = true; + if moved[2] { + active[2] = false; + } + if moved[3] { + active[3] = false; + } + next.top = current.top; + next.bottom = current.bottom; + } + current = next; + if current == before { + break; + } + } + + let area = current.into_area(); + let normalized = NormalizedBindingArea { + changed: area != original_area, + area, + clamped, + transparent: !rect_has_visible_pixel(image, current), + }; + app_log!( + "ui_separation.area.timing outcome=ok elapsed_us={} rounds={} image_width={} image_height={} area=({}, {}, {}, {}) changed={} clamped={} transparent={}", + started.elapsed().as_micros(), + rounds, + image.width(), + image.height(), + original_area.global_pos_x_px, + original_area.global_pos_y_px, + original_area.width_px, + original_area.height_px, + normalized.changed, + normalized.clamped, + normalized.transparent + ); + Ok(normalized) +} + +#[cfg(test)] +mod tests { + use super::*; + use image::{Rgba, RgbaImage}; + + fn image_with_rect( + width: u32, + height: u32, + left: u32, + top: u32, + right: u32, + bottom: u32, + ) -> RgbaImage { + let mut image = RgbaImage::from_pixel(width, height, Rgba([0, 0, 0, 0])); + for y in top..bottom { + for x in left..right { + image.put_pixel(x, y, Rgba([255, 255, 255, 255])); + } + } + image + } + + fn area(x: u32, y: u32, width: u32, height: u32) -> BindingArea { + BindingArea { + global_pos_x_px: x, + global_pos_y_px: y, + width_px: width, + height_px: height, + } + } + + #[test] + fn shrinks_empty_edges_to_visible_bounds() { + let image = image_with_rect(32, 32, 10, 11, 16, 18); + let result = normalize_binding_area(&image, area(6, 7, 14, 16)).unwrap(); + assert_eq!(result.area, area(10, 11, 6, 7)); + assert!(result.changed); + assert!(!result.clamped); + assert!(!result.transparent); + } + + #[test] + fn expands_visible_edges_to_cover_the_element() { + let image = image_with_rect(32, 32, 10, 11, 16, 18); + let result = normalize_binding_area(&image, area(11, 12, 4, 5)).unwrap(); + assert_eq!(result.area, area(10, 11, 6, 7)); + assert!(result.changed); + assert!(!result.clamped); + assert!(!result.transparent); + } + + #[test] + fn adjusts_each_edge_independently() { + let image = image_with_rect(32, 32, 10, 11, 16, 18); + let result = normalize_binding_area(&image, area(10, 12, 10, 3)).unwrap(); + assert_eq!(result.area, area(10, 11, 6, 7)); + } + + #[test] + fn ignores_low_alpha_halo_while_preserving_visible_bounds() { + let mut image = RgbaImage::from_pixel(16, 16, Rgba([0, 0, 0, 0])); + for y in 6..10 { + for x in 5..9 { + image.put_pixel(x, y, Rgba([255, 255, 255, 255])); + } + } + image.put_pixel(4, 7, Rgba([255, 255, 255, 1])); + image.put_pixel(9, 8, Rgba([255, 255, 255, 8])); + let result = normalize_binding_area(&image, area(4, 5, 6, 6)).unwrap(); + assert_eq!(result.area, area(5, 6, 4, 4)); + } + + #[test] + fn ignores_isolated_visible_edge_pixel() { + let mut image = image_with_rect(16, 16, 4, 4, 6, 8); + image.put_pixel(6, 4, Rgba([255, 255, 255, 255])); + let result = normalize_binding_area(&image, area(4, 4, 2, 4)).unwrap(); + assert_eq!(result.area, area(4, 4, 2, 4)); + } + + #[test] + fn fully_transparent_image_uses_the_same_path() { + let image = RgbaImage::from_pixel(32, 32, Rgba([0, 0, 0, 0])); + let result = normalize_binding_area(&image, area(10, 10, 10, 10)).unwrap(); + assert_eq!(result.area, area(14, 14, 2, 2)); + assert!(result.changed); + assert!(result.transparent); + } + + #[test] + fn caps_each_edge_at_absolute_pixel_limit() { + let image = image_with_rect(128, 128, 0, 0, 128, 128); + let result = normalize_binding_area(&image, area(48, 48, 8, 8)).unwrap(); + assert_eq!(result.area, area(16, 16, 72, 72)); + assert!(result.clamped); + } + + #[test] + fn clamps_expansion_to_image_edges() { + let image = image_with_rect(16, 16, 0, 0, 4, 4); + let result = normalize_binding_area(&image, area(1, 1, 2, 2)).unwrap(); + assert_eq!(result.area, area(0, 0, 4, 4)); + assert!(result.clamped); + } + + #[test] + fn exact_split_at_adjustment_limit_is_not_clamped() { + let image = image_with_rect(16, 16, 4, 4, 8, 8); + let result = normalize_binding_area(&image, area(5, 5, 2, 2)).unwrap(); + assert_eq!(result.area, area(4, 4, 4, 4)); + assert!(!result.clamped); + } + + #[test] + fn one_pixel_area_expands_with_configured_adjustment_limit() { + let image = image_with_rect(8, 8, 2, 2, 5, 5); + let result = normalize_binding_area(&image, area(3, 3, 1, 1)).unwrap(); + assert_eq!(result.area, area(2, 2, 3, 3)); + assert!(!result.clamped); + } + + #[test] + fn rejects_zero_sized_or_out_of_bounds_model_areas() { + let image = RgbaImage::from_pixel(16, 16, Rgba([0, 0, 0, 0])); + assert!(normalize_binding_area(&image, area(0, 0, 0, 1)).is_err()); + assert!(normalize_binding_area(&image, area(15, 15, 2, 2)).is_err()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/image_preprocess.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/image_preprocess.rs new file mode 100644 index 000000000..147a6dce1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/image_preprocess.rs @@ -0,0 +1,123 @@ +use super::area::MIN_VISIBLE_ALPHA; +use base64::Engine as _; +use image::{ImageFormat, ImageReader, Rgba, RgbaImage}; +use std::fs; +use std::io::Cursor; +use std::path::{Path, PathBuf}; + +pub(crate) const VISUAL_BINDING_TRANSPARENT_MARKER_RGBA: [u8; 4] = [255, 0, 255, 255]; +const MAX_PROCESSED_IMAGE_BYTES: usize = 64 * 1024 * 1024; +const MAX_PROCESSED_IMAGE_DIMENSION: u32 = 2880; + +pub(crate) async fn preprocess_for_visual_binding( + processed_url: String, + sidecar: PathBuf, +) -> Result { + tokio::task::spawn_blocking(move || { + preprocess_for_visual_binding_blocking(&processed_url, &sidecar) + }) + .await + .map_err(|error| format!("视觉绑定预处理任务失败:{error}"))? +} + +fn preprocess_for_visual_binding_blocking( + processed_url: &str, + sidecar: &Path, +) -> Result { + let encoded = processed_url + .split_once(',') + .map(|(_, data)| data) + .ok_or_else(|| "处理图 data URL 无效".to_string())?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded.trim()) + .map_err(|error| format!("解析处理图失败:{error}"))?; + if bytes.len() > MAX_PROCESSED_IMAGE_BYTES { + return Err(format!( + "处理图超过 {} MiB 字节上限", + MAX_PROCESSED_IMAGE_BYTES / 1024 / 1024 + )); + } + let dimensions = ImageReader::new(Cursor::new(&bytes)) + .with_guessed_format() + .map_err(|error| format!("解析处理图格式失败:{error}"))? + .into_dimensions() + .map_err(|error| format!("读取处理图尺寸失败:{error}"))?; + if dimensions.0 > MAX_PROCESSED_IMAGE_DIMENSION || dimensions.1 > MAX_PROCESSED_IMAGE_DIMENSION + { + return Err("处理图尺寸超出上限".to_string()); + } + let mut image = image::load_from_memory(&bytes) + .map_err(|error| format!("解码处理图失败:{error}"))? + .to_rgba8(); + for pixel in image.pixels_mut() { + if pixel.0[3] < MIN_VISIBLE_ALPHA { + *pixel = Rgba(VISUAL_BINDING_TRANSPARENT_MARKER_RGBA); + } else { + pixel.0[3] = 255; + } + } + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut Cursor::new(&mut png), ImageFormat::Png) + .map_err(|error| format!("编码视觉绑定预览失败:{error}"))?; + let debug_name = format!("binding-{}.png", uuid::Uuid::new_v4().simple()); + if let Err(error) = fs::write(sidecar.join(&debug_name), &png) { + app_log!( + "ui_separation.warning stage=visual_binding_preview_write file={} error={error}", + debug_name + ); + } + Ok(format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(png) + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use image::Rgba; + use tempfile::tempdir; + + fn data_url(image: RgbaImage) -> String { + let mut bytes = Vec::new(); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png) + .expect("encode fixture"); + format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + ) + } + + #[test] + fn preprocesses_alpha_using_existing_visibility_threshold() { + let mut image = RgbaImage::from_pixel(4, 1, Rgba([10, 20, 30, 255])); + image.put_pixel(0, 0, Rgba([1, 2, 3, 0])); + image.put_pixel(1, 0, Rgba([4, 5, 6, MIN_VISIBLE_ALPHA - 1])); + image.put_pixel(2, 0, Rgba([7, 8, 9, MIN_VISIBLE_ALPHA])); + image.put_pixel(3, 0, Rgba([11, 12, 13, 254])); + let directory = tempdir().expect("create sidecar fixture"); + + let url = preprocess_for_visual_binding_blocking(&data_url(image), directory.path()) + .expect("preprocess fixture"); + let encoded = url.split_once(',').expect("data URL").1; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .expect("decode output"); + let output = image::load_from_memory(&bytes) + .expect("decode output png") + .to_rgba8(); + + assert_eq!( + output.get_pixel(0, 0).0, + VISUAL_BINDING_TRANSPARENT_MARKER_RGBA + ); + assert_eq!( + output.get_pixel(1, 0).0, + VISUAL_BINDING_TRANSPARENT_MARKER_RGBA + ); + assert_eq!(output.get_pixel(2, 0).0, [7, 8, 9, 255]); + assert_eq!(output.get_pixel(3, 0).0, [11, 12, 13, 255]); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs new file mode 100644 index 000000000..5bdf90008 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs @@ -0,0 +1,550 @@ +mod area; +pub(crate) mod image_preprocess; +mod model; +mod persistence; +mod prompt; +mod tree; +mod workflow; + +pub use model::*; +pub use persistence::*; +pub use tree::*; +pub use workflow::apply_batch_patch; +pub use workflow::batch::{image_edit_dimension_for_area, next_image_batch}; +pub(crate) use workflow::separate_ui_impl; +#[cfg(test)] +mod tests { + use super::*; + use crate::ui_editor::component::image::{ImageComponent, ImageType}; + use crate::ui_editor::component::text::TextComponent; + 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; + use crate::ui_editor::layout::node::{NodeMetadata, NodeSource, StageStatus}; + 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::Vector2; + use std::collections::HashMap; + use std::path::Path; + use typed_floats::tf32::StrictlyPositiveFinite; + + fn node(id: &str, component: Option, children: Vec) -> Node { + Node { + id: NodeId::new(id).unwrap(), + layout: ControlLayout::default(), + metadata: NodeMetadata { + name: id.to_string(), + description: String::new(), + layout_status: StageStatus::NoProblem, + component_status: StageStatus::NoProblem, + allow_llm_edit_layout: true, + allow_llm_edit_component: true, + source: NodeSource::Llm, + }, + component, + children_display_mode: ChildrenDisplayMode::Stack, + children, + } + } + fn state(root: Node) -> State { + let image_id = UIDesignImageId::new("page").unwrap(); + State { + ui_trees: vec![UITree { + src_ui_design: image_id.clone(), + root, + }], + ui_design_images: HashMap::from([( + image_id, + UIDesignImage { + metadata: crate::ui_editor::resource::ui_design_image::UIDesignImageMetadata { + name: "page".to_string(), + description: String::new(), + role: None, + slave_to: None, + }, + path: "page.png".to_string(), + pixel_size: Vector2::new(100.0, 100.0), + pixels_per_unit: StrictlyPositiveFinite::new(1.0).unwrap(), + }, + )]), + sprite_assets: HashMap::new(), + font_assets: HashMap::new(), + } + } + #[test] + fn construction_filters_pure_nodes_and_passes_children_through() { + let image = Component::Image(ImageComponent { + target_graphic: None, + image_type: ImageType::Simple { + preserve_aspect: false, + }, + }); + let root = node( + "root", + None, + vec![node( + "container", + None, + vec![node("image", Some(image), vec![])], + )], + ); + let result = construct_separation_state(&state(root)); + assert_eq!(result.trees[0].root.children[0].id.as_str(), "image"); + } + + #[test] + fn construction_keeps_real_root_for_root_image() { + let image = Component::Image(ImageComponent { + target_graphic: None, + image_type: ImageType::Simple { + preserve_aspect: false, + }, + }); + let root = node("root-image", Some(image), vec![]); + let result = construct_separation_state(&state(root)); + let tree = &result.trees[0]; + assert_eq!(tree.root.id.as_str(), "root-image"); + assert!(tree.root_extractable); + } + + #[test] + fn construction_keeps_text_as_removal_only_context() { + let image = Component::Image(ImageComponent { + target_graphic: None, + image_type: ImageType::Simple { + preserve_aspect: false, + }, + }); + let text = Component::Text(TextComponent::new("按钮")); + let root = node( + "root", + None, + vec![node( + "outer-image", + Some(image.clone()), + vec![node("text", Some(text.clone()), vec![])], + )], + ); + let result = construct_separation_state(&state(root)); + let outer = &result.trees[0].root.children[0]; + assert_eq!(outer.id.as_str(), "outer-image"); + assert_eq!(outer.kind, SeparationNodeKind::ImageTarget); + assert_eq!(outer.children[0].kind, SeparationNodeKind::TextRemovalOnly); + + let nested_root = node( + "root", + None, + vec![node( + "outer-image", + Some(image.clone()), + vec![node( + "inner-image", + Some(image), + vec![node("text", Some(text), vec![])], + )], + )], + ); + let nested = construct_separation_state(&state(nested_root)); + let inner = &nested.trees[0].root.children[0].children[0]; + assert_eq!(inner.kind, SeparationNodeKind::ImageTarget); + assert_eq!(inner.children[0].kind, SeparationNodeKind::TextRemovalOnly); + } + + #[test] + fn root_image_keeps_text_as_removal_only_context() { + let root = node( + "root-image", + Some(Component::Image(ImageComponent { + target_graphic: None, + image_type: ImageType::Simple { + preserve_aspect: false, + }, + })), + vec![node( + "text", + Some(Component::Text(TextComponent::new("标题"))), + vec![], + )], + ); + let result = construct_separation_state(&state(root)); + assert_eq!( + result.trees[0].root.children[0].kind, + SeparationNodeKind::TextRemovalOnly + ); + } + #[test] + fn binding_validation_requires_exact_batch_coverage() { + let node = SeparationNode { + id: NodeId::new("image").unwrap(), + kind: SeparationNodeKind::ImageTarget, + global_pos_x_px: 0, + global_pos_y_px: 0, + width_px: 1, + height_px: 1, + note: SeparationNote { + description: "image".to_string(), + rework_notes: Vec::new(), + }, + children: vec![], + rework_count: 0, + }; + assert!( + validate_binding_response(&BindingResp { decisions: vec![] }, &[&node], (1, 1)) + .is_err() + ); + } + + #[test] + fn binding_validation_rejects_area_outside_processed_image() { + let node = SeparationNode { + id: NodeId::new("image").unwrap(), + kind: SeparationNodeKind::ImageTarget, + global_pos_x_px: 0, + global_pos_y_px: 0, + width_px: 1, + height_px: 1, + note: SeparationNote::default(), + children: vec![], + rework_count: 0, + }; + let response = BindingResp { + decisions: vec![BindingDecision::Ok { + to_node: node.id.clone(), + extracted_area: BindingArea { + global_pos_x_px: 1, + global_pos_y_px: 0, + width_px: 1, + height_px: 1, + }, + }], + }; + let error = validate_binding_response(&response, &[&node], (1, 1)).unwrap_err(); + assert!(error.contains("超出处理图边界")); + } + + #[test] + fn separation_note_prompt_keeps_rework_notes_in_order() { + let without_notes = SeparationNote { + description: "按钮".to_string(), + rework_notes: Vec::new(), + }; + assert_eq!(without_notes.as_prompt(), "desc: 按钮"); + + let with_notes = SeparationNote { + description: "按钮".to_string(), + rework_notes: vec!["保留圆角".to_string(), "去掉阴影".to_string()], + }; + assert_eq!( + with_notes.as_prompt(), + "desc: 按钮\nprevious rework notes:\n- 保留圆角\n- 去掉阴影" + ); + } + + #[test] + fn need_rework_appends_note_and_final_attempt_becomes_problematic() { + let image = Component::Image(ImageComponent { + target_graphic: None, + image_type: ImageType::Simple { + preserve_aspect: false, + }, + }); + let mut separation = construct_separation_state(&state(node( + "root", + None, + vec![node("image", Some(image), vec![])], + ))); + let id = NodeId::new("image").unwrap(); + let paths = HashMap::new(); + + for note in ["第一次意见", "第二次意见", "最后一次意见"] { + let batch_nodes = next_image_batch(&separation, &separation.trees[0]) + .into_iter() + .cloned() + .collect::>(); + apply_batch_patch( + &mut separation, + 0, + &batch_nodes, + &[BindingDecision::NeedRework { + to_node: id.clone(), + advice: note.to_string(), + }], + &paths, + (1, 1), + ) + .unwrap(); + } + + let node = &separation.trees[0].root.children[0]; + assert_eq!( + node.note.rework_notes, + ["第一次意见", "第二次意见", "最后一次意见"] + ); + assert_eq!(separation.problematic_nodes.len(), 1); + assert_eq!( + separation.problematic_nodes[0].rework_count, + MAX_REWORK_COUNT + ); + assert_eq!( + separation.problematic_nodes[0].problem_history, + vec!["第一次意见", "第二次意见", "最后一次意见"] + ); + assert_eq!(node.rework_count, MAX_REWORK_COUNT); + assert!(next_image_batch(&separation, &separation.trees[0]).is_empty()); + } + + #[test] + fn binding_validation_rejects_overlong_rework_note() { + let node = SeparationNode { + id: NodeId::new("image").unwrap(), + kind: SeparationNodeKind::ImageTarget, + global_pos_x_px: 0, + global_pos_y_px: 0, + width_px: 1, + height_px: 1, + note: SeparationNote::default(), + children: vec![], + rework_count: 0, + }; + let decision = BindingDecision::NeedRework { + to_node: node.id.clone(), + advice: "x".repeat(MAX_REWORK_NOTE_CHARS + 1), + }; + assert!(validate_binding_response( + &BindingResp { + decisions: vec![decision] + }, + &[&node], + (1, 1) + ) + .is_err()); + } + #[test] + fn sidecar_name_uses_asset_id_digest() { + let dir = separation_sidecar_dir(Path::new("/tmp/project"), "ui:1").unwrap(); + assert!(dir.to_string_lossy().contains("ui_1-")); + assert!(dir.to_string_lossy().ends_with("-separation")); + } + + #[test] + fn patch_collects_bound_and_keeps_tree_topology() { + let image = Component::Image(ImageComponent { + target_graphic: None, + image_type: ImageType::Simple { + preserve_aspect: false, + }, + }); + let mut state = construct_separation_state(&state(node( + "root", + None, + vec![node("image", Some(image), vec![])], + ))); + let id = NodeId::new("image").unwrap(); + let decisions = vec![BindingDecision::Ok { + to_node: id.clone(), + extracted_area: BindingArea { + global_pos_x_px: 0, + global_pos_y_px: 0, + width_px: 1, + height_px: 1, + }, + }]; + let paths = HashMap::from([(id.clone(), "ui/.sidecar/cut.png".to_string())]); + let batch_nodes = next_image_batch(&state, &state.trees[0]) + .into_iter() + .cloned() + .collect::>(); + apply_batch_patch(&mut state, 0, &batch_nodes, &decisions, &paths, (1, 1)).unwrap(); + assert_eq!(state.bound[0].node_id, id); + assert_eq!(state.trees[0].root.children.len(), 1); + } + + #[test] + fn batch_selection_keeps_dfs_order_even_when_rectangles_overlap() { + let a = SeparationNode { + id: NodeId::new("a").unwrap(), + kind: SeparationNodeKind::ImageTarget, + global_pos_x_px: 0, + global_pos_y_px: 0, + width_px: 10, + height_px: 10, + note: SeparationNote::default(), + children: vec![], + rework_count: 0, + }; + let b = SeparationNode { + id: NodeId::new("b").unwrap(), + kind: SeparationNodeKind::ImageTarget, + global_pos_x_px: 5, + global_pos_y_px: 5, + width_px: 10, + height_px: 10, + note: SeparationNote::default(), + children: vec![], + rework_count: 0, + }; + let c = SeparationNode { + id: NodeId::new("c").unwrap(), + kind: SeparationNodeKind::ImageTarget, + global_pos_x_px: 20, + global_pos_y_px: 0, + width_px: 5, + height_px: 5, + note: SeparationNote::default(), + children: vec![], + rework_count: 0, + }; + let tree = SeparationTree { + src_ui_design: UIDesignImageId::new("page").unwrap(), + root: SeparationNode { + id: NodeId::new("root").unwrap(), + kind: SeparationNodeKind::PureContainer, + global_pos_x_px: 0, + global_pos_y_px: 0, + width_px: 100, + height_px: 100, + note: SeparationNote::default(), + children: vec![a, b, c], + rework_count: 0, + }, + root_extractable: false, + }; + let state = SeparationState { + schema_version: SEPARATION_STATE_SCHEMA_VERSION.to_string(), + trees: vec![tree.clone()], + bound: vec![], + problematic_nodes: vec![], + }; + let batch = next_image_batch(&state, &tree); + assert_eq!( + batch + .iter() + .map(|node| node.id.as_str()) + .collect::>(), + vec!["a", "b", "c"] + ); + } + + #[test] + fn batch_selection_skips_text_removal_only_nodes() { + let image = Component::Image(ImageComponent { + target_graphic: None, + image_type: ImageType::Simple { + preserve_aspect: false, + }, + }); + let text = Component::Text(TextComponent::new("标题")); + let separation = construct_separation_state(&state(node( + "root", + None, + vec![ + node("text", Some(text), vec![]), + node("image", Some(image), vec![]), + ], + ))); + + let batch = next_image_batch(&separation, &separation.trees[0]); + assert_eq!( + batch + .iter() + .map(|node| node.id.as_str()) + .collect::>(), + vec!["image"] + ); + } + + #[test] + fn batch_selection_includes_extractable_root_before_children() { + let image = Component::Image(ImageComponent { + target_graphic: None, + image_type: ImageType::Simple { + preserve_aspect: false, + }, + }); + let separation = construct_separation_state(&state(node( + "root-image", + Some(image.clone()), + vec![node("child-image", Some(image), vec![])], + ))); + + let batch = next_image_batch(&separation, &separation.trees[0]); + assert_eq!( + batch + .iter() + .map(|node| node.id.as_str()) + .collect::>(), + vec!["root-image", "child-image"] + ); + } + + fn image_separation_node(id: &str, width_px: u32, height_px: u32) -> SeparationNode { + SeparationNode { + id: NodeId::new(id).unwrap(), + kind: SeparationNodeKind::ImageTarget, + global_pos_x_px: 0, + global_pos_y_px: 0, + width_px, + height_px, + note: SeparationNote::default(), + children: vec![], + rework_count: 0, + } + } + + fn separation_with_children( + children: Vec, + ) -> (SeparationState, SeparationTree) { + let tree = SeparationTree { + src_ui_design: UIDesignImageId::new("page").unwrap(), + root: SeparationNode { + id: NodeId::new("root").unwrap(), + kind: SeparationNodeKind::PureContainer, + global_pos_x_px: 0, + global_pos_y_px: 0, + width_px: 100, + height_px: 100, + note: SeparationNote::default(), + children, + rework_count: 0, + }, + root_extractable: false, + }; + let separation = SeparationState { + schema_version: SEPARATION_STATE_SCHEMA_VERSION.to_string(), + trees: vec![tree.clone()], + bound: vec![], + problematic_nodes: vec![], + }; + (separation, tree) + } + + #[test] + fn batch_selection_stops_before_second_node_that_exceeds_area_budget() { + let first_area = IMAGE_EDIT_AREA_LIMIT_PX / 2 + 1; + let first = image_separation_node("first", first_area as u32, 1); + let second = image_separation_node("second", first_area as u32, 1); + let (separation, tree) = separation_with_children(vec![first, second]); + + let batch = next_image_batch(&separation, &tree); + assert_eq!( + batch + .iter() + .map(|node| node.id.as_str()) + .collect::>(), + vec!["first"] + ); + } + + #[test] + fn batch_selection_accepts_first_oversized_node_to_guarantee_progress() { + let oversized = + image_separation_node("oversized", (IMAGE_EDIT_AREA_LIMIT_PX + 1) as u32, 1); + let (separation, tree) = separation_with_children(vec![oversized]); + + let batch = next_image_batch(&separation, &tree); + assert_eq!(batch.len(), 1); + assert_eq!(batch[0].id.as_str(), "oversized"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/binding.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/binding.rs new file mode 100644 index 000000000..e527c7613 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/binding.rs @@ -0,0 +1,97 @@ +use crate::ui_editor::utils::NodeId; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BindingAreaValidationError { + ZeroDimension, + OutOfBounds, +} + +impl std::fmt::Display for BindingAreaValidationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::ZeroDimension => "BindingArea 宽度和高度必须大于 0", + Self::OutOfBounds => "BindingArea 超出处理图边界", + }) + } +} + +impl std::error::Error for BindingAreaValidationError {} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct BindingArea { + pub global_pos_x_px: u32, + pub global_pos_y_px: u32, + pub width_px: u32, + pub height_px: u32, +} + +impl BindingArea { + pub fn validate_in(&self, w: u32, h: u32) -> Result<(), BindingAreaValidationError> { + if self.width_px == 0 || self.height_px == 0 { + return Err(BindingAreaValidationError::ZeroDimension); + } + if self + .global_pos_x_px + .checked_add(self.width_px) + .is_none_or(|v| v > w) + || self + .global_pos_y_px + .checked_add(self.height_px) + .is_none_or(|v| v > h) + { + return Err(BindingAreaValidationError::OutOfBounds); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub enum BindingDecision { + Ok { + extracted_area: BindingArea, + to_node: NodeId, + }, + NeedRework { + advice: String, + to_node: NodeId, + }, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] +pub struct BindingResp { + pub decisions: Vec, +} + +#[cfg(test)] +mod tests { + use super::{BindingArea, BindingAreaValidationError}; + + #[test] + fn validates_binding_area_with_typed_errors() { + let zero = BindingArea { + global_pos_x_px: 0, + global_pos_y_px: 0, + width_px: 0, + height_px: 1, + }; + assert_eq!( + zero.validate_in(10, 10), + Err(BindingAreaValidationError::ZeroDimension) + ); + + let outside = BindingArea { + global_pos_x_px: 10, + global_pos_y_px: 0, + width_px: 1, + height_px: 1, + }; + assert_eq!( + outside.validate_in(10, 10), + Err(BindingAreaValidationError::OutOfBounds) + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/mod.rs new file mode 100644 index 000000000..74a2c773b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/mod.rs @@ -0,0 +1,20 @@ +mod binding; +mod node; +mod note; +mod result; + +pub use binding::*; +pub use node::*; +pub use note::*; +pub use result::*; + +pub const SEPARATION_STATE_SCHEMA_VERSION: &str = "ui-editor-separation-state.v2"; +pub const MAX_REWORK_COUNT: u32 = 3; +pub const MAX_REWORK_NOTE_CHARS: usize = 512; +pub const IMAGE_EDIT_MAX_DIMENSION_PX: u64 = 2880; +pub const IMAGE_EDIT_MIN_DIMENSION_PX: u64 = 816; +pub const IMAGE_EDIT_DIMENSION_ALIGNMENT_PX: u64 = 16; +pub const IMAGE_EDIT_AREA_UTILIZATION_PERCENT: u64 = 80; +pub const IMAGE_EDIT_AREA_LIMIT_PX: u64 = + IMAGE_EDIT_MAX_DIMENSION_PX * IMAGE_EDIT_MAX_DIMENSION_PX * IMAGE_EDIT_AREA_UTILIZATION_PERCENT + / 100; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/node.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/node.rs new file mode 100644 index 000000000..d925b7e36 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/node.rs @@ -0,0 +1,43 @@ +use crate::ui_editor::utils::{NodeId, UIDesignImageId}; +use serde::{Deserialize, Serialize}; +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 SeparationNodeKind { + ImageTarget, + TextRemovalOnly, + PureContainer, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct SeparationNode { + pub id: NodeId, + pub kind: SeparationNodeKind, + pub global_pos_x_px: u32, + pub global_pos_y_px: u32, + pub width_px: u32, + pub height_px: u32, + pub note: super::SeparationNote, + pub children: Vec, + pub rework_count: u32, +} + +impl SeparationNode { + pub fn as_prompt(&self) -> String { + format!( + "node_id={} note: {}", + self.id.as_str(), + self.note.as_prompt() + ) + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct SeparationTree { + pub src_ui_design: UIDesignImageId, + pub root: SeparationNode, + pub root_extractable: bool, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/note.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/note.rs new file mode 100644 index 000000000..a044254b1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/note.rs @@ -0,0 +1,45 @@ +use super::MAX_REWORK_NOTE_CHARS; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +pub const MAX_NOTE_DESCRIPTION_CHARS: usize = 1024; + +pub fn sanitize_prompt_text(value: &str, max_chars: usize) -> String { + value + .chars() + .filter_map(|character| { + if character.is_control() { + Some(' ') + } else if character == '`' { + Some(''') + } else { + Some(character) + } + }) + .take(max_chars) + .collect() +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct SeparationNote { + pub description: String, + pub rework_notes: Vec, +} + +impl SeparationNote { + pub fn as_prompt(&self) -> String { + let mut prompt = format!( + "desc: {}", + sanitize_prompt_text(&self.description, MAX_NOTE_DESCRIPTION_CHARS) + ); + if !self.rework_notes.is_empty() { + prompt.push_str("\nprevious rework notes:"); + for note in &self.rework_notes { + prompt.push_str("\n- "); + prompt.push_str(&sanitize_prompt_text(note, MAX_REWORK_NOTE_CHARS)); + } + } + prompt + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/result.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/result.rs new file mode 100644 index 000000000..8dbb38383 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/result.rs @@ -0,0 +1,45 @@ +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 BoundNode { + pub node_id: NodeId, + pub cut_image_path: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct ProblematicNode { + pub node_id: NodeId, + pub problem_description: String, + #[serde(default)] + pub problem_history: Vec, + pub rework_count: u32, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct SeparationState { + pub schema_version: String, + pub trees: Vec, + pub bound: Vec, + pub problematic_nodes: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct SeparationDTO { + pub bound_nodes: Vec, + pub problematic_nodes: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct SeparationRecoveryDTO { + pub exists: bool, + pub bound_node_count: usize, + pub problematic_node_count: usize, + pub has_pending_tree: bool, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/persistence.rs new file mode 100644 index 000000000..7a4e44507 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/persistence.rs @@ -0,0 +1,302 @@ +use super::model::*; +use crate::ui_editor::commands::separation::*; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use uuid::Uuid; + +const SEPARATION_STATE_MAX_BYTES: usize = 8 * 1024 * 1024; +pub fn separation_sidecar_dir(root: &Path, asset_id: &str) -> Result { + if asset_id.trim().is_empty() || asset_id.trim() != asset_id { + app_log!("ui_separation.error stage=sidecar_dir reason=invalid_asset_id"); + return Err("UI 资源 ID 无效".to_string()); + } + let dir = root.join("ui").join(format!( + ".{}-separation", + crate::ui_editor::persistence::generated_file_stem(asset_id) + )); + if !dir.starts_with(root) { + app_log!("ui_separation.error stage=sidecar_dir reason=path_escape"); + return Err("separation sidecar 路径越界".to_string()); + } + app_log!( + "ui_separation.sidecar_resolved asset_id={} directory={}", + asset_id, + dir.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("") + ); + Ok(dir) +} + +pub fn separation_state_path(root: &Path, asset_id: &str) -> Result { + Ok(separation_sidecar_dir(root, asset_id)?.join("state.json")) +} + +pub fn project_relative_path(root: &Path, path: &Path) -> Result { + let relative = path + .strip_prefix(root) + .map_err(|_| "separation 产物必须位于项目目录内".to_string())?; + let value = relative.to_string_lossy().replace('\\', "/"); + if value.is_empty() || value.starts_with('/') || value.split('/').any(|part| part == "..") { + return Err("separation 产物相对路径无效".to_string()); + } + Ok(value) +} + +pub async fn write_separation_state(path: PathBuf, state: &SeparationState) -> Result<(), String> { + if state.schema_version != SEPARATION_STATE_SCHEMA_VERSION { + app_log!("ui_separation.error stage=state_write reason=schema_mismatch"); + return Err("不支持的 separation state schema".to_string()); + } + let owned = state.clone(); + tokio::task::spawn_blocking(move || { + let bytes = serde_json::to_vec_pretty(&owned) + .map_err(|error| format!("序列化 separation state 失败:{error}"))?; + write_separation_state_blocking(&path, &bytes) + }) + .await + .map_err(|error| format!("写入 separation state 任务失败:{error}"))? +} + +fn write_separation_state_blocking(path: &Path, bytes: &[u8]) -> Result<(), String> { + if bytes.len() > SEPARATION_STATE_MAX_BYTES { + app_log!( + "ui_separation.error stage=state_write reason=too_large bytes={} max_bytes={}", + bytes.len(), + SEPARATION_STATE_MAX_BYTES + ); + return Err(format!( + "separation state 超过 {} 字节上限", + SEPARATION_STATE_MAX_BYTES + )); + } + app_log!( + "ui_separation.state_write.start file={} bytes={}", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""), + bytes.len() + ); + let parent = path.parent().ok_or_else(|| { + app_log!("ui_separation.error stage=state_write reason=missing_parent"); + "separation state 路径缺少父目录".to_string() + })?; + fs::create_dir_all(parent).map_err(|error| { + app_log!("ui_separation.error stage=state_write reason=create_parent error={error}"); + format!("创建 separation sidecar 失败:{error}") + })?; + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + app_log!("ui_separation.error stage=state_write reason=unsafe_target"); + return Err("separation state 目标必须是普通文件".to_string()); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + app_log!("ui_separation.error stage=state_write reason=target_metadata error={error}"); + return Err(format!("检查 separation state 目标失败:{error}")); + } + } + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("state.json"); + let temporary = parent.join(format!(".{file_name}.tmp.{}", Uuid::new_v4())); + let mut temporary_file = OpenOptions::new(); + temporary_file.write(true).create_new(true); + let mut file = temporary_file.open(&temporary).map_err(|error| { + app_log!("ui_separation.error stage=state_write reason=write_temp error={error}"); + format!("写入 separation state 失败:{error}") + })?; + if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_data()) { + let _ = fs::remove_file(&temporary); + app_log!("ui_separation.error stage=state_write reason=write_temp error={error}"); + return Err(format!("写入 separation state 失败:{error}")); + } + drop(file); + if let Err(error) = replace_separation_state_atomically(&temporary, path) { + let _ = fs::remove_file(&temporary); + app_log!("ui_separation.error stage=state_write reason=install error={error}"); + return Err(format!("安装 separation state 失败:{error}")); + } + app_log!( + "ui_separation.state_write.completed file={} bytes={}", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""), + fs::metadata(path) + .map(|metadata| metadata.len()) + .unwrap_or(0) + ); + Ok(()) +} + +#[cfg(not(windows))] +fn replace_separation_state_atomically(temporary: &Path, target: &Path) -> std::io::Result<()> { + fs::rename(temporary, target) +} + +#[cfg(windows)] +fn replace_separation_state_atomically(temporary: &Path, target: &Path) -> std::io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let source = temporary + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let destination = target + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + // SAFETY: both buffers are owned, UTF-16 encoded, and NUL-terminated; they + // remain alive for the duration of the call, which only reads the paths. + let moved = unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if moved == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + +pub fn read_separation_state(path: &Path) -> Result { + app_log!( + "ui_separation.state_read.start file={}", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("") + ); + let metadata = fs::metadata(path).map_err(|error| { + app_log!("ui_separation.error stage=state_read reason=metadata error={error}"); + format!("读取 separation state 信息失败:{error}") + })?; + if metadata.len() > SEPARATION_STATE_MAX_BYTES as u64 { + app_log!( + "ui_separation.error stage=state_read reason=too_large bytes={} max_bytes={}", + metadata.len(), + SEPARATION_STATE_MAX_BYTES + ); + return Err(format!( + "separation state 超过 {} 字节上限", + SEPARATION_STATE_MAX_BYTES + )); + } + let bytes = fs::read(path).map_err(|error| { + app_log!("ui_separation.error stage=state_read reason=read error={error}"); + format!("读取 separation state 失败:{error}") + })?; + if bytes.len() > SEPARATION_STATE_MAX_BYTES { + return Err(format!( + "separation state 超过 {} 字节上限", + SEPARATION_STATE_MAX_BYTES + )); + } + let state: SeparationState = serde_json::from_slice(&bytes).map_err(|error| { + app_log!("ui_separation.error stage=state_read reason=parse error={error}"); + format!("解析 separation state 失败:{error}") + })?; + if state.schema_version != SEPARATION_STATE_SCHEMA_VERSION { + app_log!("ui_separation.error stage=state_read reason=schema_mismatch"); + return Err("不支持的 separation state schema".to_string()); + } + app_log!( + "ui_separation.state_read.completed bytes={} trees={} bound={} problematic={}", + bytes.len(), + state.trees.len(), + state.bound.len(), + state.problematic_nodes.len() + ); + Ok(state) +} + +pub async fn read_separation_state_async(path: PathBuf) -> Result { + tokio::task::spawn_blocking(move || read_separation_state(&path)) + .await + .map_err(|error| format!("读取 separation state 任务失败:{error}"))? +} + +pub fn separation_dto(state: &SeparationState) -> SeparationDTO { + app_log!( + "ui_separation.dto bound_nodes={} problematic_nodes={} remaining_trees={}", + state.bound.len(), + state.problematic_nodes.len(), + state.trees.len() + ); + SeparationDTO { + bound_nodes: state.bound.clone(), + problematic_nodes: state.problematic_nodes.clone(), + } +} + +pub fn inspect_separation_recovery( + root: &Path, + asset_id: &str, +) -> Result { + let state_path = separation_state_path(root, asset_id)?; + if !state_path.exists() { + return Ok(SeparationRecoveryDTO { + exists: false, + bound_node_count: 0, + problematic_node_count: 0, + has_pending_tree: false, + }); + } + let state = read_separation_state(&state_path)?; + Ok(SeparationRecoveryDTO { + exists: true, + bound_node_count: state.bound.len(), + problematic_node_count: state.problematic_nodes.len(), + has_pending_tree: state + .trees + .iter() + .any(|tree| !tree.root.children.is_empty() || tree.root_extractable), + }) +} + +pub fn finalize_separation(root: &Path, asset_id: &str) -> Result<(), String> { + remove_separation_state(root, asset_id) +} + +pub fn discard_separation_recovery(root: &Path, asset_id: &str) -> Result<(), String> { + remove_separation_state(root, asset_id) +} + +fn remove_separation_state(root: &Path, asset_id: &str) -> Result<(), String> { + let state_path = separation_state_path(root, asset_id)?; + match fs::remove_file(&state_path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("删除 separation state 失败:{error}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn atomic_state_replacement_overwrites_existing_target() { + let directory = tempfile::tempdir().expect("create temporary state directory"); + let target = directory.path().join("state.json"); + let temporary = directory.path().join("state.json.tmp"); + fs::write(&target, b"old").expect("write old state"); + fs::write(&temporary, b"new").expect("write new state"); + + replace_separation_state_atomically(&temporary, &target) + .expect("replacement should overwrite existing state"); + + assert_eq!(fs::read(&target).expect("read replaced state"), b"new"); + assert!(!temporary.exists()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/prompt/binding.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/prompt/binding.rs new file mode 100644 index 000000000..e9253e810 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/prompt/binding.rs @@ -0,0 +1,46 @@ +use crate::ui_editor::commands::separation::image_preprocess::VISUAL_BINDING_TRANSPARENT_MARKER_RGBA; +use crate::ui_editor::commands::separation::SeparationNode; + +pub(crate) fn gen_binding_prompt(nodes: &[&SeparationNode]) -> String { + let [marker_red, marker_green, marker_blue, marker_alpha] = + VISUAL_BINDING_TRANSPARENT_MARKER_RGBA; + let marker_color = format!("rgba({marker_red}, {marker_green}, {marker_blue}, {marker_alpha})"); + let binding_system_prompt = format!( + r#" + You will be given a src UI design image and a processed image, where some ui elements are separated. + You need to recognize and review the separation using the given tool. + field notes: + * extracted_area MUST be the recognized area from the processed image, INSTEAD OF from the src image. + The processed image is the only authoritative image for extracted_area. + Return the pixel bounding box of the extracted element as it appears in the processed image. + Do not copy, infer, or reuse the source node rectangle. + The src image is only for identifying which semantic UI element belongs to to_node. + + Here are the separation requirements: + Preserve hard edges and the exact visible shape. + The processed image is an opaque visual-binding preview containing the requested image layers. + The solid color {marker_color} is an intentional transparency marker added by this workflow before this request. + It is not an image-edit defect and is not part of any UI element. + Do not include this marker color in the extracted area. + Do not use the source node rectangle as the extracted area. + And you should also review if the extracted's successfully meet the src image: + * shape + * color + * style + * edge process + ... + + if not, use the `NeedRework` data structure in the tool to indicate the node id and advice. + your advice (less than 20 words) will be used to improve the separation next time. + + these nodes need handling: + "# + ); + let mut result = binding_system_prompt; + result.reserve(512); + for elem in nodes { + result.push_str(&elem.as_prompt()); + result.push('\n'); + } + result +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/prompt/extract.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/prompt/extract.rs new file mode 100644 index 000000000..766363f18 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/prompt/extract.rs @@ -0,0 +1,171 @@ +use crate::ui_editor::commands::separation::model::{SeparationState, SeparationTree}; +use crate::ui_editor::commands::separation::{ + sanitize_prompt_text, SeparationNode, SeparationNodeKind, MAX_NOTE_DESCRIPTION_CHARS, + MAX_REWORK_NOTE_CHARS, +}; +use crate::ui_editor::utils::NodeId; +use serde::Serialize; +use std::collections::HashSet; + +#[derive(Serialize)] +struct ExtractPromptDocument { + ui_layer_tree: ExtractPromptNode, +} + +#[derive(Serialize)] +struct ExtractPromptNode { + index: usize, + status: ExtractPromptStatus, + rect: ExtractPromptRect, + description: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + rework_notes: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + children: Vec, +} + +#[derive(Serialize)] +enum ExtractPromptStatus { + #[serde(rename = "OUTPUT_THIS_TURN")] + OutputThisTurn, + #[serde(rename = "DONE")] + Done, + #[serde(rename = "CONTEXT_ONLY")] + ContextOnly, + #[serde(rename = "REMOVE_ONLY")] + RemoveOnly, +} + +#[derive(Serialize)] +struct ExtractPromptRect { + x: u32, + y: u32, + width: u32, + height: u32, +} + +pub(crate) fn gen_extract_prompt( + state: &SeparationState, + tree: &SeparationTree, + batch: &[&SeparationNode], +) -> Result { + let mut result = r#" + This is a UI design image, not a normal photo/illustration. Extract it strictly as UI elements/layers, not as a generic foreground/background extraction. + Treat distinct UI element as its own layer with hard, clean, pixel-accurate edges and full transparency outside the element. + Generate one transparent atlas at the requested canvas size. You may move or scale output layers so they do not cover one another. + Preserve hard edges and the exact visible shape. Never split a scene/background into multiple scene layers. + Ordinary text is editable UI text: remove it from its parent image/background and do not generate a text raster layer. + Extract only the image nodes marked OUTPUT_THIS_TURN. Reconstruct every child/text layer that is listed under a parent but is not an output target. + + UI layer tree: +"# + .to_string(); + result.reserve(2048); + let target_ids = batch + .iter() + .map(|node| node.id.clone()) + .collect::>(); + let terminal_ids = state + .bound + .iter() + .map(|node| node.node_id.clone()) + .collect::>(); + let mut index = 1; + let document = ExtractPromptDocument { + ui_layer_tree: project_node(&tree.root, &target_ids, &terminal_ids, &mut index), + }; + let yaml = serde_yaml::to_string(&document) + .map_err(|error| format!("UI separation extract prompt projection failed: {error}"))?; + + result.push_str("```yaml\n"); + result.push_str(&yaml); + result.push_str("```\n"); + Ok(result) +} + +fn project_node( + node: &SeparationNode, + target_ids: &HashSet, + terminal_ids: &HashSet, + index: &mut usize, +) -> ExtractPromptNode { + let current_index = *index; + *index += 1; + + let status = match node.kind { + SeparationNodeKind::TextRemovalOnly => ExtractPromptStatus::RemoveOnly, + SeparationNodeKind::PureContainer => ExtractPromptStatus::ContextOnly, + SeparationNodeKind::ImageTarget if target_ids.contains(&node.id) => { + ExtractPromptStatus::OutputThisTurn + } + SeparationNodeKind::ImageTarget if terminal_ids.contains(&node.id) => { + ExtractPromptStatus::Done + } + SeparationNodeKind::ImageTarget => ExtractPromptStatus::ContextOnly, + }; + + let children = node + .children + .iter() + .map(|child| project_node(child, target_ids, terminal_ids, index)) + .collect(); + + ExtractPromptNode { + index: current_index, + status, + rect: ExtractPromptRect { + x: node.global_pos_x_px, + y: node.global_pos_y_px, + width: node.width_px, + height: node.height_px, + }, + description: sanitize_prompt_text(&node.note.description, MAX_NOTE_DESCRIPTION_CHARS), + rework_notes: node + .note + .rework_notes + .iter() + .map(|note| sanitize_prompt_text(note, MAX_REWORK_NOTE_CHARS)) + .collect(), + children, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui_editor::commands::separation::model::SeparationNote; + + #[test] + fn projects_source_node_to_prompt_view() { + let node = SeparationNode { + id: NodeId::new("image").unwrap(), + kind: SeparationNodeKind::ImageTarget, + global_pos_x_px: 1, + global_pos_y_px: 2, + width_px: 3, + height_px: 4, + note: SeparationNote { + description: "按钮".to_string(), + rework_notes: vec!["保留圆角".to_string()], + }, + children: vec![], + rework_count: 0, + }; + let mut index = 1; + let target_ids = HashSet::from([node.id.clone()]); + let projected = project_node(&node, &target_ids, &HashSet::new(), &mut index); + + assert_eq!(projected.index, 1); + assert!(matches!( + projected.status, + ExtractPromptStatus::OutputThisTurn + )); + assert_eq!(projected.rect.x, 1); + assert_eq!(projected.rect.y, 2); + assert_eq!(projected.rect.width, 3); + assert_eq!(projected.rect.height, 4); + assert_eq!(projected.description, "按钮"); + assert_eq!(projected.rework_notes, vec!["保留圆角".to_string()]); + assert!(projected.children.is_empty()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/prompt/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/prompt/mod.rs new file mode 100644 index 000000000..219a1164c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/prompt/mod.rs @@ -0,0 +1,5 @@ +mod binding; +mod extract; + +pub(super) use binding::gen_binding_prompt; +pub(super) use extract::gen_extract_prompt; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/tree.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/tree.rs new file mode 100644 index 000000000..d632796ef --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/tree.rs @@ -0,0 +1,191 @@ +use super::model::*; +use crate::ui_editor::component::{image::ImageComponent, Component}; +use crate::ui_editor::layout::node::Node; +use crate::ui_editor::state::State; +use std::collections::HashSet; + +fn is_unbound_image(node: &Node) -> bool { + matches!( + node.component.as_ref(), + Some(Component::Image(ImageComponent { + target_graphic: None, + .. + })) + ) +} + +fn has_image_component(node: &Node) -> bool { + matches!(node.component.as_ref(), Some(Component::Image(_))) +} + +fn has_text_component(node: &Node) -> bool { + matches!(node.component.as_ref(), Some(Component::Text(_))) +} + +fn node_pixel_rect( + node: &Node, + parent: &crate::ui_editor::layout::dimension::UIRect, + ppu: f32, +) -> (u32, u32, u32, u32) { + let rect = node.layout.transform.resolve(parent); + ( + (rect.min.x * ppu).max(0.0).round() as u32, + (rect.min.y * ppu).max(0.0).round() as u32, + (rect.size.x * ppu).max(0.0).round() as u32, + (rect.size.y * ppu).max(0.0).round() as u32, + ) +} + +fn node_description(node: &Node) -> String { + let name = node.metadata.name.trim(); + let description = node.metadata.description.trim(); + match (name.is_empty(), description.is_empty()) { + (true, true) => "未命名 UI 图片元素".to_string(), + (false, true) => name.to_string(), + (true, false) => description.to_string(), + (false, false) => format!("{name}:{description}"), + } +} + +fn collect_todo_nodes( + node: &Node, + parent: &crate::ui_editor::layout::dimension::UIRect, + ppu: f32, + output: &mut Vec, +) { + let rect = node.layout.transform.resolve(parent); + let mut children = Vec::new(); + for child in &node.children { + collect_todo_nodes(child, &rect, ppu, &mut children); + } + let kind = if is_unbound_image(node) { + Some(SeparationNodeKind::ImageTarget) + } else if has_text_component(node) && !has_image_component(node) { + Some(SeparationNodeKind::TextRemovalOnly) + } else { + None + }; + if let Some(kind) = kind { + let (x, y, w, h) = node_pixel_rect(node, parent, ppu); + if w > 0 && h > 0 { + output.push(SeparationNode { + id: node.id.clone(), + kind, + global_pos_x_px: x, + global_pos_y_px: y, + width_px: w, + height_px: h, + note: SeparationNote { + description: node_description(node), + rework_notes: Vec::new(), + }, + children, + rework_count: 0, + }); + } else { + output.extend(children); + } + } else { + output.extend(children); + } +} + +pub fn construct_separation_state(state: &State) -> SeparationState { + let trees = state + .ui_trees + .iter() + .filter_map(|tree| { + let image = state.ui_design_images.get(&tree.src_ui_design)?; + let ppu = image.pixels_per_unit.get(); + let size = image.pixel_size / ppu; + let root_rect = + crate::ui_editor::layout::dimension::UIRect::new(nalgebra::Point2::origin(), size); + let root_resolved = tree.root.layout.transform.resolve(&root_rect); + let mut children = Vec::new(); + for child in &tree.root.children { + collect_todo_nodes(child, &root_resolved, ppu, &mut children); + } + let root_extractable = is_unbound_image(&tree.root); + if !root_extractable && children.is_empty() { + return None; + } + let (x, y, w, h) = node_pixel_rect(&tree.root, &root_rect, ppu); + Some(SeparationTree { + src_ui_design: tree.src_ui_design.clone(), + root: SeparationNode { + id: tree.root.id.clone(), + kind: if root_extractable { + SeparationNodeKind::ImageTarget + } else if has_text_component(&tree.root) && !has_image_component(&tree.root) { + SeparationNodeKind::TextRemovalOnly + } else { + SeparationNodeKind::PureContainer + }, + global_pos_x_px: x, + global_pos_y_px: y, + width_px: w, + height_px: h, + note: SeparationNote { + description: node_description(&tree.root), + rework_notes: Vec::new(), + }, + children, + rework_count: 0, + }, + root_extractable, + }) + }) + .collect(); + SeparationState { + schema_version: SEPARATION_STATE_SCHEMA_VERSION.to_string(), + trees, + bound: Vec::new(), + problematic_nodes: Vec::new(), + } +} + +pub fn validate_binding_response( + response: &BindingResp, + batch: &[&SeparationNode], + processed_dimensions: (u32, u32), +) -> Result<(), String> { + let expected = batch + .iter() + .filter(|node| matches!(node.kind, SeparationNodeKind::ImageTarget)) + .map(|node| node.id.clone()) + .collect::>(); + let mut seen = HashSet::new(); + for decision in &response.decisions { + let node_id = match decision { + BindingDecision::Ok { to_node, .. } | BindingDecision::NeedRework { to_node, .. } => { + to_node + } + }; + if !expected.contains(node_id) { + return Err(format!("视觉绑定返回了未知节点:{}", node_id.as_str())); + } + if !seen.insert(node_id.clone()) { + return Err(format!("视觉绑定重复返回节点:{}", node_id.as_str())); + } + if let BindingDecision::Ok { extracted_area, .. } = decision { + extracted_area + .validate_in(processed_dimensions.0, processed_dimensions.1) + .map_err(|error| format!("节点 {} 的分离区域无效:{error}", node_id.as_str()))?; + } + if let BindingDecision::NeedRework { advice, .. } = decision { + if advice.trim().is_empty() { + return Err("NeedRework 必须包含问题描述".to_string()); + } + if advice.chars().count() > MAX_REWORK_NOTE_CHARS { + // TODO add this back in prompt + return Err(format!( + "NeedRework 问题描述不能超过 {MAX_REWORK_NOTE_CHARS} 个字符" + )); + } + } + } + if seen.len() != expected.len() { + return Err("视觉绑定未覆盖当前 batch 的全部节点".to_string()); + } + Ok(()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/batch.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/batch.rs new file mode 100644 index 000000000..13024a78b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/batch.rs @@ -0,0 +1,146 @@ +use crate::ui_editor::commands::separation::model::*; +use crate::ui_editor::utils::NodeId; +use std::collections::HashSet; + +#[derive(Debug)] +pub struct ImageBatch<'a> { + pub nodes: Vec<&'a SeparationNode>, + pub area_px: u64, + pub image_edit_dimension_px: u32, +} + +/// Derive the square raw image-edit canvas from the selected source area. +/// The calculation lives beside batch selection so the area budget and +/// request size cannot drift apart. +pub fn image_edit_dimension_for_area(area_px: u64) -> u32 { + let max = u128::from(IMAGE_EDIT_MAX_DIMENSION_PX); + let limit = u128::from(IMAGE_EDIT_AREA_LIMIT_PX); + let area = u128::from(area_px); + let raw = if area >= limit { + IMAGE_EDIT_MAX_DIMENSION_PX + } else if area == 0 { + 0 + } else { + // Find floor(max * sqrt(area / limit)) without floating-point rounding. + let target = max * max * area; + let mut low = 0u64; + let mut high = IMAGE_EDIT_MAX_DIMENSION_PX; + while low < high { + let mid = low + (high - low + 1) / 2; + if u128::from(mid) * u128::from(mid) * limit <= target { + low = mid; + } else { + high = mid - 1; + } + } + low + }; + let alignment = IMAGE_EDIT_DIMENSION_ALIGNMENT_PX; + let aligned = raw / alignment * alignment; + aligned.clamp(IMAGE_EDIT_MIN_DIMENSION_PX, IMAGE_EDIT_MAX_DIMENSION_PX) as u32 +} + +pub(crate) fn terminal_node_ids(state: &SeparationState) -> HashSet { + state + .bound + .iter() + .map(|n| n.node_id.clone()) + .chain(state.problematic_nodes.iter().map(|n| n.node_id.clone())) + .collect() +} + +fn collect_dfs_batch<'a>( + node: &'a SeparationNode, + is_root: bool, + root_extractable: bool, + terminal: &HashSet, + selected: &mut Vec<&'a SeparationNode>, + area: &mut u64, +) -> bool { + let is_target = matches!(node.kind, SeparationNodeKind::ImageTarget) + && (!is_root || root_extractable) + && !terminal.contains(&node.id); + if is_target { + let node_area = u64::from(node.width_px).saturating_mul(u64::from(node.height_px)); + let would_exceed = area.saturating_add(node_area) > IMAGE_EDIT_AREA_LIMIT_PX; + if selected.is_empty() || !would_exceed { + selected.push(node); + *area = area.saturating_add(node_area); + } else { + return true; + } + } + for child in &node.children { + if collect_dfs_batch(child, false, root_extractable, terminal, selected, area) { + return true; + } + } + false +} + +pub fn next_image_batch_with_size<'a>( + state: &SeparationState, + tree: &'a SeparationTree, +) -> ImageBatch<'a> { + let terminal = terminal_node_ids(state); + let mut selected = Vec::new(); + let mut area = 0; + collect_dfs_batch( + &tree.root, + true, + tree.root_extractable, + &terminal, + &mut selected, + &mut area, + ); + let image_edit_dimension_px = image_edit_dimension_for_area(area); + app_log!( + "ui_separation.batch_selected image_id={} image_nodes={} area_px={} image_edit_dimension_px={}", + tree.src_ui_design.as_str(), + selected.len(), + area, + image_edit_dimension_px + ); + ImageBatch { + nodes: selected, + area_px: area, + image_edit_dimension_px, + } +} + +pub fn next_image_batch<'a>( + state: &SeparationState, + tree: &'a SeparationTree, +) -> Vec<&'a SeparationNode> { + next_image_batch_with_size(state, tree).nodes +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn image_edit_dimension_uses_minimum_and_alignment() { + assert_eq!(image_edit_dimension_for_area(0), 816); + assert_eq!(image_edit_dimension_for_area(1), 816); + assert_eq!( + image_edit_dimension_for_area(IMAGE_EDIT_AREA_LIMIT_PX / 16), + 816 + ); + assert_eq!( + image_edit_dimension_for_area(IMAGE_EDIT_AREA_LIMIT_PX), + 2880 + ); + assert_eq!(image_edit_dimension_for_area(u64::MAX), 2880); + } + + #[test] + fn image_edit_dimension_rounds_down_to_sixteen_pixels() { + let max_squared = IMAGE_EDIT_MAX_DIMENSION_PX * IMAGE_EDIT_MAX_DIMENSION_PX; + let area_just_below_1536 = IMAGE_EDIT_AREA_LIMIT_PX * 1536 * 1536 / max_squared; + let area_at_1536 = (IMAGE_EDIT_AREA_LIMIT_PX * 1536 * 1536).div_ceil(max_squared); + + assert_eq!(image_edit_dimension_for_area(area_just_below_1536), 1520); + assert_eq!(image_edit_dimension_for_area(area_at_1536), 1536); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/binding.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/binding.rs new file mode 100644 index 000000000..255a204c7 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/binding.rs @@ -0,0 +1,164 @@ +use crate::config::{build_game_creator_llm_client_from_llm_config, load_game_creator_app_config}; +use crate::ui_editor::commands::separation::image_preprocess; +use crate::ui_editor::commands::separation::prompt::gen_binding_prompt; +use crate::ui_editor::commands::separation::{ + validate_binding_response, BindingResp, SeparationNode, +}; +use crate::ui_editor::commands::utils::{ + parse_limited_llm_tool_arguments, request_ui_editor_llm, run_with_repair_history, + strict_json_schema, +}; +use platform_llm::{ + LlmFunctionTool, LlmMessage, LlmMessageContentPart, LlmRunRequest, LlmToolChoice, +}; +use std::path::PathBuf; +use std::time::Instant; + +pub(super) async fn visual_binding( + source_url: String, + processed_url: String, + sidecar: PathBuf, + nodes: &[&SeparationNode], + processed_dimensions: (u32, u32), +) -> Result { + let started = Instant::now(); + let result = visual_binding_inner( + source_url, + processed_url, + sidecar, + nodes, + processed_dimensions, + ) + .await; + app_log!( + "ui_separation.visual_binding.timing outcome={} elapsed_ms={} nodes={}", + if result.is_ok() { "ok" } else { "error" }, + started.elapsed().as_millis(), + nodes.len() + ); + result +} + +async fn visual_binding_inner( + source_url: String, + processed_url: String, + sidecar: PathBuf, + nodes: &[&SeparationNode], + processed_dimensions: (u32, u32), +) -> Result { + app_log!( + "ui_separation.visual_binding.start nodes={} source_url_chars={} processed_url_chars={}", + nodes.len(), + source_url.chars().count(), + processed_url.chars().count() + ); + let preprocess_started = Instant::now(); + let binding_processed_url = + match image_preprocess::preprocess_for_visual_binding(processed_url, sidecar).await { + Ok(value) => { + app_log!( + "ui_separation.visual_binding.preprocess.timing outcome=ok elapsed_ms={}", + preprocess_started.elapsed().as_millis() + ); + value + } + Err(error) => { + app_log!( + "ui_separation.visual_binding.preprocess.timing outcome=error elapsed_ms={}", + preprocess_started.elapsed().as_millis() + ); + app_log!("ui_separation.error stage=visual_binding_preprocess error={error}"); + return Err(error); + } + }; + let llm_config = load_game_creator_app_config() + .map_err(|e| { + app_log!("ui_separation.error stage=visual_binding reason=load_config error={e}"); + e.to_string() + })? + .llm; + let client = + build_game_creator_llm_client_from_llm_config(&llm_config, "llm").map_err(|e| { + app_log!("ui_separation.error stage=visual_binding reason=build_client error={e}"); + e.to_string() + })?; + let schema = strict_json_schema::().map_err(|error| { + app_log!("ui_separation.error stage=visual_binding reason=build_schema error={error}"); + error + })?; + let tool = LlmFunctionTool::new( + "bind_ui_elements", + "确认处理图中的区域对应哪些 UI 节点", + schema, + ) + .with_strict(true); + let initial_history = vec![ + LlmMessage::system(gen_binding_prompt(nodes)), + LlmMessage::user_multimodal(vec![ + LlmMessageContentPart::InputText { + text: "processed image:".to_string(), + }, + LlmMessageContentPart::InputImage { + image_url: binding_processed_url, + }, + LlmMessageContentPart::InputText { + text: "src image:".to_string(), + }, + LlmMessageContentPart::InputImage { + image_url: source_url.clone(), + }, + ]), + ]; + // 严格工具 schema 由 provider 负责约束正常模型输出;这里的解析失败只代表极小概率 + // 的传输/响应损坏,因此沿用有限重试,不再为理论上的坏载荷扩展业务修复协议。 + let result = run_with_repair_history( + 2, + initial_history, + |history| { + let tool = tool.clone(); + let client = client.clone(); + let llm_config = llm_config.clone(); + async move { + let request = LlmRunRequest::new(history) + .with_function_tools(vec![tool.clone()]) + .with_tool_choice(LlmToolChoice::Required); + let request_started = Instant::now(); + let response = request_ui_editor_llm(&client, &llm_config, request).await; + app_log!( + "ui_separation.llm.timing outcome={} elapsed_ms={}", + if response.is_ok() { "ok" } else { "error" }, + request_started.elapsed().as_millis() + ); + response + .map_err(|e| e.to_string()) + .and_then(|response| { + response + .tool_calls + .into_iter() + .find(|call| call.name == "bind_ui_elements") + .map(|call| call.arguments) + .ok_or_else(|| "视觉绑定模型未返回工具调用".to_string()) + }) + .and_then(|arguments| parse_limited_llm_tool_arguments(&arguments)) + .and_then(|args| { + serde_json::from_value::(args) + .map_err(|e| format!("视觉绑定结果无效:{e}")) + }) + } + }, + |value: &BindingResp| validate_binding_response(value, nodes, processed_dimensions), + ) + .await; + match &result { + Ok(value) => app_log!( + "ui_separation.visual_binding.completed nodes={} decisions={}", + nodes.len(), + value.decisions.len() + ), + Err(error) => app_log!( + "ui_separation.error stage=visual_binding reason=failed nodes={} error={error}", + nodes.len() + ), + } + result +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/cut.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/cut.rs new file mode 100644 index 000000000..e39de4ba8 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/cut.rs @@ -0,0 +1,132 @@ +use crate::ui_editor::commands::separation::area::normalize_binding_area; +use crate::ui_editor::commands::separation::model::BindingArea; +use image::ImageFormat; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +pub(super) async fn cut_processed_image( + source: PathBuf, + area: BindingArea, + target: PathBuf, +) -> Result<(), String> { + let started = Instant::now(); + let result = cut_processed_image_inner(source, area, target).await; + app_log!( + "ui_separation.cut_image.timing outcome={} elapsed_ms={}", + if result.is_ok() { "ok" } else { "error" }, + started.elapsed().as_millis() + ); + result +} + +async fn cut_processed_image_inner( + source: PathBuf, + area: BindingArea, + target: PathBuf, +) -> Result<(), String> { + app_log!( + "ui_separation.cut_image.start source_file={} target_file={} area=({}, {}, {}, {})", + source + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""), + target + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""), + area.global_pos_x_px, + area.global_pos_y_px, + area.width_px, + area.height_px + ); + tokio::task::spawn_blocking(move || cut_processed_image_blocking(&source, &area, &target)) + .await + .map_err(|error| format!("裁切处理图任务失败:{error}"))? +} + +fn cut_processed_image_blocking( + source: &Path, + area: &BindingArea, + target: &Path, +) -> Result<(), String> { + let image = image::open(source) + .map_err(|e| format!("读取处理图失败:{e}"))? + .to_rgba8(); + let normalized = normalize_binding_area(&image, *area)?; + if normalized.transparent { + return Err("分离区域没有可见像素".to_string()); + } + let original_area = *area; + let normalized_area = normalized.area; + app_log!( + "ui_separation.cut_image.normalized changed={} clamped={} transparent={} original_area=({}, {}, {}, {}) normalized_area=({}, {}, {}, {})", + normalized.changed, + normalized.clamped, + normalized.transparent, + original_area.global_pos_x_px, + original_area.global_pos_y_px, + original_area.width_px, + original_area.height_px, + normalized_area.global_pos_x_px, + normalized_area.global_pos_y_px, + normalized_area.width_px, + normalized_area.height_px + ); + let cropped = image::imageops::crop_imm( + &image, + normalized_area.global_pos_x_px, + normalized_area.global_pos_y_px, + normalized_area.width_px, + normalized_area.height_px, + ) + .to_image(); + cropped + .save_with_format(target, ImageFormat::Png) + .map_err(|e| format!("写入 cut 图片失败:{e}"))?; + app_log!( + "ui_separation.cut_image.completed target_file={} width={} height={}", + target + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""), + normalized_area.width_px, + normalized_area.height_px + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use image::{Rgba, RgbaImage}; + + #[test] + fn cuts_using_small_processed_image_dimensions() { + let directory = tempfile::tempdir().expect("创建临时目录失败"); + let source = directory.path().join("processed.png"); + let target = directory.path().join("cut.png"); + let mut image = RgbaImage::from_pixel(816, 816, Rgba([0, 0, 0, 0])); + for y in 120..152 { + for x in 700..800 { + image.put_pixel(x, y, Rgba([255, 255, 255, 255])); + } + } + image.save(&source).expect("写入处理图失败"); + + cut_processed_image_blocking( + &source, + &BindingArea { + global_pos_x_px: 700, + global_pos_y_px: 120, + width_px: 100, + height_px: 32, + }, + &target, + ) + .expect("裁切处理图失败"); + + let cropped = image::open(target).expect("读取 cut 图片失败"); + assert_eq!(cropped.width(), 100); + assert_eq!(cropped.height(), 32); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs new file mode 100644 index 000000000..9477e2a37 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs @@ -0,0 +1,244 @@ +use crate::platform_session::PlatformSessionSnapshot; +use base64::Engine as _; +use serde::Deserialize; +use std::path::PathBuf; +use std::time::Instant; +use std::{fs, io::Cursor}; + +const MAX_IMAGE_PAYLOAD_BYTES: usize = 64 * 1024 * 1024; + +fn decode_bounded_base64(value: &str, label: &str) -> Result, String> { + if value.len() > (MAX_IMAGE_PAYLOAD_BYTES / 3) * 4 + 4 { + return Err(format!( + "{label}超过 {} MiB 字节上限", + MAX_IMAGE_PAYLOAD_BYTES / 1024 / 1024 + )); + } + let decoded = base64::engine::general_purpose::STANDARD + .decode(value.trim()) + .map_err(|error| format!("解码{label}失败:{error}"))?; + if decoded.len() > MAX_IMAGE_PAYLOAD_BYTES { + return Err(format!( + "{label}超过 {} MiB 字节上限", + MAX_IMAGE_PAYLOAD_BYTES / 1024 / 1024 + )); + } + Ok(decoded) +} + +#[derive(Deserialize)] +struct RawEditResponse { + data: Vec, +} + +#[derive(Deserialize)] +struct RawEditItem { + b64_json: String, +} + +pub(super) async fn raw_extract( + session: &PlatformSessionSnapshot, + image_data_url: &str, + prompt: &str, + width: u32, + height: u32, +) -> Result { + let started = Instant::now(); + let result = raw_extract_inner(session, image_data_url, prompt, width, height).await; + app_log!( + "ui_separation.image_edit.timing outcome={} elapsed_ms={} width={} height={}", + if result.is_ok() { "ok" } else { "error" }, + started.elapsed().as_millis(), + width, + height + ); + result +} + +async fn raw_extract_inner( + session: &PlatformSessionSnapshot, + image_data_url: &str, + prompt: &str, + width: u32, + height: u32, +) -> Result { + app_log!( + "ui_separation.image_edit.start width={} height={} prompt_chars={}", + width, + height, + prompt.chars().count() + ); + let (mime, data) = image_data_url + .split_once(',') + .ok_or_else(|| "界面图 data URL 无效".to_string())?; + let mime = mime + .strip_prefix("data:") + .and_then(|value| value.strip_suffix(";base64")); + let is_png = mime.is_some_and(|value| value.eq_ignore_ascii_case("image/png")); + let image_bytes = decode_bounded_base64(data, "源图")?; + if image_bytes.is_empty() { + return Err("源图不能为空".to_string()); + } + let image_bytes = if is_png { + image_bytes + } else { + tokio::task::spawn_blocking(move || normalize_source_image_to_png(image_bytes)) + .await + .map_err(|error| format!("转换源图任务失败:{error}"))?? + }; + let client = crate::http_client::agc_main_site_client_builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|error| format!("创建图片编辑客户端失败:{error}"))?; + let url = format!( + "{}/api/raw/v1/images/edit", + session.api_base_url.trim_end_matches('/') + ); + let image_part = reqwest::multipart::Part::bytes(image_bytes) + .file_name("image.png") + .mime_str("image/png") + .map_err(|error| format!("构造图片编辑文件部件失败:{error}"))?; + let body = reqwest::multipart::Form::new() + .part("image", image_part) + .text("prompt", prompt.to_string()) + .text("width", width.to_string()) + .text("height", height.to_string()) + .text("output_format", "png") + .text("background", "transparent"); + let response = crate::http_client::with_agc_main_site_marker( + client + .post(url) + .bearer_auth(&session.access_token) + .multipart(body), + ) + .send() + .await + .map_err(|error| { + app_log!("ui_separation.error stage=image_edit reason=send error={error}"); + format!("图片分离请求失败:{error}") + })?; + if !response.status().is_success() { + app_log!( + "ui_separation.error stage=image_edit reason=http_status status={}", + response.status() + ); + return Err(format!("图片分离请求失败(HTTP {})", response.status())); + } + let payload = response.json::().await.map_err(|error| { + app_log!("ui_separation.error stage=image_edit reason=parse_response error={error}"); + format!("解析图片分离响应失败:{error}") + })?; + let result = payload + .data + .into_iter() + .next() + .map(|item| format!("data:image/png;base64,{}", item.b64_json)) + .ok_or_else(|| "图片分离响应没有图像".to_string()); + match &result { + Ok(value) => app_log!( + "ui_separation.image_edit.completed data_url_chars={}", + value.chars().count() + ), + Err(error) => { + app_log!("ui_separation.error stage=image_edit reason=empty_result error={error}") + } + } + result +} + +fn normalize_source_image_to_png(image_bytes: Vec) -> Result, String> { + let image = image::load_from_memory(&image_bytes) + .map_err(|error| format!("解码非 PNG 源图失败:{error}"))?; + let mut png_bytes = Vec::new(); + image + .write_to(&mut Cursor::new(&mut png_bytes), image::ImageFormat::Png) + .map_err(|error| format!("将源图转换为 PNG 失败:{error}"))?; + Ok(png_bytes) +} + +#[cfg(test)] +mod tests { + use super::normalize_source_image_to_png; + use image::{DynamicImage, ImageFormat, Rgb, RgbImage}; + use std::io::Cursor; + + #[test] + fn converts_jpeg_source_to_png() { + let image = DynamicImage::ImageRgb8(RgbImage::from_pixel(2, 2, Rgb([255, 0, 0]))); + let mut jpeg = Vec::new(); + image + .write_to(&mut Cursor::new(&mut jpeg), ImageFormat::Jpeg) + .expect("encode jpeg"); + + let png = normalize_source_image_to_png(jpeg).expect("convert jpeg"); + assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n"); + } + + #[test] + fn converts_webp_source_to_png() { + let image = DynamicImage::ImageRgb8(RgbImage::from_pixel(2, 2, Rgb([0, 128, 255]))); + let mut webp = Vec::new(); + image + .write_to(&mut Cursor::new(&mut webp), ImageFormat::WebP) + .expect("encode webp"); + + let png = normalize_source_image_to_png(webp).expect("convert webp"); + assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n"); + } +} + +pub(super) async fn write_processed_image( + processed_url: String, + target: PathBuf, +) -> Result<(u32, u32), String> { + let started = Instant::now(); + let result = write_processed_image_inner(processed_url, target).await; + app_log!( + "ui_separation.processed_image.write.timing outcome={} elapsed_ms={}", + if result.is_ok() { "ok" } else { "error" }, + started.elapsed().as_millis() + ); + result +} + +async fn write_processed_image_inner( + processed_url: String, + target: PathBuf, +) -> Result<(u32, u32), String> { + app_log!( + "ui_separation.processed_image.write.start target_file={} data_url_chars={}", + target + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""), + processed_url.chars().count() + ); + tokio::task::spawn_blocking(move || { + let encoded = processed_url + .split_once(',') + .map(|(_, data)| data) + .ok_or_else(|| "处理图 data URL 无效".to_string())?; + let processed_bytes = decode_bounded_base64(encoded, "处理图")?; + let dimensions = image::ImageReader::new(Cursor::new(processed_bytes.as_slice())) + .with_guessed_format() + .map_err(|error| format!("识别处理图格式失败:{error}"))? + .into_dimensions() + .map_err(|error| format!("读取处理图尺寸失败:{error}"))?; + let byte_len = processed_bytes.len(); + fs::write(&target, processed_bytes) + .map_err(|error| format!("写入处理图失败:{}: {error}", target.display())) + .map(|_| { + app_log!( + "ui_separation.processed_image.write.completed target_file={} bytes={}", + target + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""), + byte_len + ); + dimensions + }) + }) + .await + .map_err(|error| format!("写入处理图任务失败:{error}"))? +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/mod.rs new file mode 100644 index 000000000..c1960f6de --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/mod.rs @@ -0,0 +1,273 @@ +pub mod batch; +mod binding; +mod cut; +mod extract; +mod patch; + +pub use patch::apply_batch_patch; + +use self::batch::next_image_batch_with_size; +use super::model::*; +use super::persistence::{ + project_relative_path, read_separation_state_async, separation_dto, separation_sidecar_dir, + separation_state_path, write_separation_state, +}; +use super::prompt::gen_extract_prompt; +use crate::platform_session::current_platform_session; +use crate::ui_editor::commands::utils::read_ui_reference_image_data_url; +use crate::ui_editor::state::State; +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use std::time::Instant; +use uuid::Uuid; + +pub(crate) async fn separate_ui_impl( + project_path: String, + asset_id: String, + state: State, +) -> Result { + app_log!( + "ui_separation.start asset_id={} ui_trees={} ui_images={} sprites={}", + asset_id, + state.ui_trees.len(), + state.ui_design_images.len(), + state.sprite_assets.len() + ); + let session = current_platform_session().ok_or_else(|| "请先登录平台账号".to_string())?; + let root = Path::new(project_path.trim()); + let sidecar = separation_sidecar_dir(root, &asset_id).map_err(|error| { + app_log!( + "ui_separation.error stage=sidecar_dir asset_id={} error={error}", + asset_id + ); + error + })?; + tokio::task::spawn_blocking({ + let sidecar = sidecar.clone(); + move || fs::create_dir_all(sidecar) + }) + .await + .map_err(|error| format!("创建 separation sidecar 任务失败:{error}"))? + .map_err(|error| { + app_log!( + "ui_separation.error stage=sidecar_create asset_id={} error={error}", + asset_id + ); + format!("创建 separation sidecar 失败:{error}") + })?; + let state_path = separation_state_path(root, &asset_id)?; + let restored = state_path.exists(); + let mut separation = if restored { + app_log!("ui_separation.state_restore.start asset_id={asset_id}"); + read_separation_state_async(state_path.clone()) + .await + .map_err(|error| { + app_log!( + "ui_separation.error stage=state_restore asset_id={} error={error}", + asset_id + ); + error + })? + } else { + app_log!("ui_separation.state_construct.start asset_id={asset_id}"); + super::tree::construct_separation_state(&state) + }; + app_log!( + "ui_separation.state_ready asset_id={} restored={} trees={} bound={} problematic={}", + asset_id, + restored, + separation.trees.len(), + separation.bound.len(), + separation.problematic_nodes.len() + ); + + for tree_index in 0..separation.trees.len() { + let tree = &separation.trees[tree_index]; + let image_id = tree.src_ui_design.clone(); + let image = state + .ui_design_images + .get(&image_id) + .ok_or_else(|| "缺少源界面图".to_string())?; + let source_path = crate::project::resolve_local_project_path(root, &image.path)?; + let source_url = read_ui_reference_image_data_url(source_path) + .await + .map_err(|error| { + app_log!( + "ui_separation.error stage=read_source tree_index={} image_id={} error={error}", + tree_index, + image_id.as_str() + ); + error + })?; + app_log!( + "ui_separation.tree_start tree_index={} image_id={} width={} height={}", + tree_index, + image_id.as_str(), + image.pixel_size.x.round() as u32, + image.pixel_size.y.round() as u32 + ); + write_separation_state(state_path.clone(), &separation) + .await + .map_err(|error| { + app_log!( + "ui_separation.error stage=state_checkpoint tree_index={} error={error}", + tree_index + ); + error + })?; + let mut batch_index = 0usize; + loop { + let batch_started = Instant::now(); + let Some(current_tree) = separation.trees.get(tree_index) else { + break; + }; + let batch_selection = next_image_batch_with_size(&separation, current_tree); + let batch_area_px = batch_selection.area_px; + let image_edit_dimension_px = batch_selection.image_edit_dimension_px; + let batch_nodes = batch_selection + .nodes + .into_iter() + .cloned() + .collect::>(); + if batch_nodes.is_empty() { + app_log!( + "ui_separation.tree_completed tree_index={} image_id={} bound={} problematic={}", + tree_index, image_id.as_str(), separation.bound.len(), separation.problematic_nodes.len() + ); + break; + } + let batch = batch_nodes.iter().collect::>(); + let prompt = gen_extract_prompt(&separation, current_tree, &batch)?; + app_log!( + "ui_separation.batch_start tree_index={} batch_index={} nodes={} area_px={} image_edit_dimension_px={} prompt_chars={} rework_total={}", + tree_index, batch_index, batch.len(), batch_area_px, image_edit_dimension_px, + prompt.chars().count(), + batch.iter().map(|node| node.rework_count).sum::() + ); + let processed_url = match extract::raw_extract( + &session, + &source_url, + &prompt, + image_edit_dimension_px, + image_edit_dimension_px, + ) + .await + { + Ok(value) => value, + Err(error) => { + app_log!("ui_separation.error stage=image_edit tree_index={} batch_index={} error={error}", tree_index, batch_index); + if let Err(checkpoint_error) = + write_separation_state(state_path.clone(), &separation).await + { + app_log!("ui_separation.error stage=state_checkpoint tree_index={} batch_index={} error={checkpoint_error}", tree_index, batch_index); + } + return Err(error); + } + }; + let processed_path = sidecar.join(format!("processed-{}.png", Uuid::new_v4().simple())); + let processed_dimensions = match extract::write_processed_image( + processed_url.clone(), + processed_path.clone(), + ) + .await + { + Ok(dimensions) => dimensions, + Err(error) => { + app_log!("ui_separation.error stage=processed_image_write tree_index={} batch_index={} error={error}", tree_index, batch_index); + if let Err(checkpoint_error) = + write_separation_state(state_path.clone(), &separation).await + { + app_log!("ui_separation.error stage=state_checkpoint tree_index={} batch_index={} error={checkpoint_error}", tree_index, batch_index); + } + return Err(error); + } + }; + let binding = match binding::visual_binding( + source_url.clone(), + processed_url, + sidecar.clone(), + &batch, + processed_dimensions, + ) + .await + { + Ok(value) => value, + Err(error) => { + app_log!("ui_separation.error stage=visual_binding tree_index={} batch_index={} error={error}", tree_index, batch_index); + if let Err(checkpoint_error) = + write_separation_state(state_path.clone(), &separation).await + { + app_log!("ui_separation.error stage=state_checkpoint tree_index={} batch_index={} error={checkpoint_error}", tree_index, batch_index); + } + return Err(error); + } + }; + app_log!( + "ui_separation.binding_decisions tree_index={} batch_index={} decisions={}", + tree_index, + batch_index, + binding.decisions.len() + ); + let mut cut_paths = HashMap::new(); + let mut cut_error = None; + for decision in &binding.decisions { + if let BindingDecision::Ok { + to_node, + extracted_area, + } = decision + { + let node_id = to_node.as_str(); + let cut_path = sidecar.join(format!("cut-{}.png", Uuid::new_v4())); + match cut::cut_processed_image( + processed_path.clone(), + *extracted_area, + cut_path.clone(), + ) + .await + { + Ok(()) => { + cut_paths + .insert(to_node.clone(), project_relative_path(root, &cut_path)?); + } + Err(error) => { + app_log!("ui_separation.error stage=cut_image tree_index={} batch_index={} node_id={} error={error}", tree_index, batch_index, node_id); + cut_error = Some(format!("节点 {} 的分离区域无效:{error}", node_id)); + break; + } + } + } + } + if let Some(error) = cut_error { + app_log!("ui_separation.error stage=cut_batch tree_index={} batch_index={} error={error}", tree_index, batch_index); + if let Err(checkpoint_error) = + write_separation_state(state_path.clone(), &separation).await + { + app_log!("ui_separation.error stage=state_checkpoint tree_index={} batch_index={} error={checkpoint_error}", tree_index, batch_index); + } + return Err(error); + } + patch::apply_batch_patch( + &mut separation, + tree_index, + &batch_nodes, + &binding.decisions, + &cut_paths, + processed_dimensions, + )?; + write_separation_state(state_path.clone(), &separation).await?; + app_log!( + "ui_separation.batch_completed tree_index={} batch_index={} cuts={} bound={} problematic={} elapsed_ms={}", + tree_index, batch_index, cut_paths.len(), separation.bound.len(), separation.problematic_nodes.len(), batch_started.elapsed().as_millis() + ); + batch_index += 1; + } + } + app_log!( + "ui_separation.completed asset_id={} bound_nodes={} problematic_nodes={}", + asset_id, + separation.bound.len(), + separation.problematic_nodes.len() + ); + Ok(separation_dto(&separation)) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/patch.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/patch.rs new file mode 100644 index 000000000..5bdc82a23 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/patch.rs @@ -0,0 +1,116 @@ +use crate::ui_editor::commands::separation::{ + validate_binding_response, BindingDecision, BindingResp, BoundNode, ProblematicNode, + SeparationNode, SeparationState, MAX_REWORK_COUNT, +}; +use crate::ui_editor::utils::NodeId; +use std::collections::HashMap; + +pub fn apply_batch_patch( + state: &mut SeparationState, + tree_index: usize, + batch_nodes: &[SeparationNode], + decisions: &[BindingDecision], + cut_paths: &HashMap, + processed_dimensions: (u32, u32), +) -> Result<(), String> { + app_log!( + "ui_separation.batch_patch.start tree_index={} decisions={} cut_paths={}", + tree_index, + decisions.len(), + cut_paths.len() + ); + if state.trees.get(tree_index).is_none() { + return Err("separation tree 索引无效".to_string()); + } + let batch = batch_nodes.iter().collect::>(); + validate_binding_response( + &BindingResp { + decisions: decisions.to_vec(), + }, + &batch, + processed_dimensions, + )?; + for decision in decisions { + if let BindingDecision::Ok { to_node, .. } = decision { + if !cut_paths.contains_key(to_node) { + return Err(format!("缺少节点 {} 的 cut 图片", to_node.as_str())); + } + } + } + let rework_counts = batch_nodes + .iter() + .map(|node| (node.id.clone(), node.rework_count)) + .collect::>(); + let tree = state.trees.get_mut(tree_index).expect("tree index checked"); + for decision in decisions { + match decision { + BindingDecision::Ok { to_node, .. } => { + let path = cut_paths + .get(to_node) + .expect("cut path presence validated before patch"); + state.bound.push(BoundNode { + node_id: to_node.clone(), + cut_image_path: path.clone(), + }); + } + BindingDecision::NeedRework { + to_node, + advice: problem_description, + } => { + append_rework_note(&mut tree.root, to_node, problem_description); + let count = rework_counts.get(to_node).copied().unwrap_or(0) + 1; + increment_rework_count(&mut tree.root, to_node, count); + if count >= MAX_REWORK_COUNT { + let problem_history = rework_history(&tree.root, to_node); + state.problematic_nodes.push(ProblematicNode { + node_id: to_node.clone(), + problem_description: problem_description.clone(), + problem_history, + rework_count: count, + }); + } + } + } + } + app_log!( + "ui_separation.batch_patch.completed tree_index={} bound={} problematic={} pending_root_children={}", + tree_index, + state.bound.len(), + state.problematic_nodes.len(), + tree.root.children.len() + ); + Ok(()) +} + +fn append_rework_note(node: &mut SeparationNode, id: &NodeId, note: &str) -> bool { + if node.id == *id { + node.note.rework_notes.push(note.to_string()); + return true; + } + node.children + .iter_mut() + .any(|child| append_rework_note(child, id, note)) +} + +fn increment_rework_count(node: &mut SeparationNode, id: &NodeId, count: u32) { + if node.id == *id { + node.rework_count = count; + return; + } + for child in &mut node.children { + increment_rework_count(child, id, count); + } +} + +fn rework_history(node: &SeparationNode, id: &NodeId) -> Vec { + if node.id == *id { + return node.note.rework_notes.clone(); + } + node.children + .iter() + .find_map(|child| { + let history = rework_history(child, id); + (!history.is_empty()).then_some(history) + }) + .unwrap_or_default() +} 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 index 4faa40579..852a23c9d 100644 --- 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 @@ -1,9 +1,11 @@ use crate::agent::request_game_creator_llm_text; use crate::config::{apply_game_creator_llm_reasoning_effort, parse_game_creator_llm_api_kind}; use base64::Engine as _; -use platform_llm::{LlmClient, LlmError, LlmRunRequest, LlmRunResponse}; +use platform_llm::{LlmClient, LlmError, LlmMessage, LlmRunRequest, LlmRunResponse}; use schemars::JsonSchema; +use serde::Serialize; use std::fs::File; +use std::future::Future; use std::io::Read; use std::path::{Path, PathBuf}; @@ -25,6 +27,63 @@ pub(crate) async fn request_ui_editor_llm( request_game_creator_llm_text(client, llm, request).await } +/// 按 append-only history 重试结构化 LLM 请求;仅业务校验失败会追加反馈消息。 +/// +/// 现役调用方把重试次数固定在很小的范围(生产路径为 2 次),初始提示词和工具 +/// schema 也受 provider 的请求预算约束;因此最多追加两份反馈,不会形成需要额外 +/// 截断策略的上下文无限增长。这里保留完整模型输出,便于模型修正业务校验失败。 +pub(crate) async fn run_with_repair_history( + max_retries: usize, + initial_history: Vec, + requester: Requester, + validator: Validator, +) -> Result +where + T: Serialize, + Requester: Fn(Vec) -> Fut, + Fut: Future>, + Validator: Fn(&T) -> Result<(), String>, +{ + let mut history = initial_history; + for attempt in 0..=max_retries { + let value = match requester(history.clone()).await { + Ok(value) => value, + Err(error) if attempt < max_retries => { + app_log!( + "ui_editor.llm.retry request_error attempt={} max_retries={} error={}", + attempt + 1, + max_retries, + error + ); + tokio::time::sleep(std::time::Duration::from_millis(200 * (attempt as u64 + 1))) + .await; + continue; + } + Err(error) => return Err(error), + }; + match validator(&value) { + Ok(()) => return Ok(value), + Err(error) if attempt < max_retries => { + app_log!( + "ui_editor.llm.retry validation_error attempt={} max_retries={} error={}", + attempt + 1, + max_retries, + error + ); + tokio::time::sleep(std::time::Duration::from_millis(200 * (attempt as u64 + 1))) + .await; + let serialized = serde_json::to_string(&value) + .map_err(|serialize_error| format!("序列化修复反馈失败:{serialize_error}"))?; + history.push(LlmMessage::system(format!( + "上一次模型输出:\n{serialized}\n\n业务校验失败:\n{error}\n\n请修正并完整返回。" + ))); + } + Err(error) => return Err(error), + } + } + Err("LLM 重试未返回结果".to_string()) +} + pub(crate) fn parse_limited_llm_tool_arguments( arguments: &str, ) -> Result { @@ -144,6 +203,107 @@ mod tests { ); } + #[tokio::test] + async fn repair_history_zero_retries_calls_once_with_initial_history() { + let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let seen = calls.clone(); + let initial_history = vec![LlmMessage::user("初始 prompt")]; + let result = run_with_repair_history( + 0, + initial_history.clone(), + move |history| { + let seen = seen.clone(); + async move { + seen.lock().unwrap().push(history); + Ok::<_, String>(serde_json::json!({"ok": true})) + } + }, + |_| Ok(()), + ) + .await + .expect("single turn should succeed"); + assert_eq!(result, serde_json::json!({"ok": true})); + assert_eq!(calls.lock().unwrap().as_slice(), &[initial_history]); + } + + #[tokio::test] + async fn repair_history_request_error_retries_without_appending_history() { + let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let attempts = std::sync::Arc::new(std::sync::Mutex::new(0usize)); + let seen_calls = calls.clone(); + let seen_attempts = attempts.clone(); + let initial_history = vec![LlmMessage::user("初始 prompt")]; + let result = run_with_repair_history( + 1, + initial_history.clone(), + move |history| { + seen_calls.lock().unwrap().push(history); + let attempt = { + let mut attempts = seen_attempts.lock().unwrap(); + let attempt = *attempts; + *attempts += 1; + attempt + }; + async move { + if attempt == 0 { + Err("网络错误".to_string()) + } else { + Ok::<_, String>(serde_json::json!({"ok": true})) + } + } + }, + |_| Ok(()), + ) + .await + .expect("retry-only error should recover"); + assert_eq!(result, serde_json::json!({"ok": true})); + assert_eq!( + calls.lock().unwrap().as_slice(), + &[initial_history.clone(), initial_history] + ); + } + + #[tokio::test] + async fn repair_history_business_failure_appends_serialized_value_and_error() { + let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let attempts = std::sync::Arc::new(std::sync::Mutex::new(0usize)); + let seen_calls = calls.clone(); + let seen_attempts = attempts.clone(); + let initial_history = vec![LlmMessage::user("初始 prompt")]; + let result = run_with_repair_history( + 1, + initial_history.clone(), + move |history| { + seen_calls.lock().unwrap().push(history); + let mut attempts = seen_attempts.lock().unwrap(); + let attempt = *attempts; + *attempts += 1; + async move { Ok::<_, String>(serde_json::json!({"attempt": attempt})) } + }, + |value: &serde_json::Value| { + if value["attempt"] == 0 { + Err("业务校验失败".to_string()) + } else { + Ok(()) + } + }, + ) + .await + .expect("business feedback should recover"); + assert_eq!(result, serde_json::json!({"attempt": 1})); + let calls = calls.lock().unwrap(); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0], initial_history); + assert_eq!(calls[1].len(), 2); + assert_eq!(calls[1][0], LlmMessage::user("初始 prompt")); + assert_eq!( + calls[1][1], + LlmMessage::system( + "上一次模型输出:\n{\"attempt\":0}\n\n业务校验失败:\n业务校验失败\n\n请修正并完整返回。" + ) + ); + } + #[test] fn reference_image_rejects_file_over_five_mib_before_reading() { let directory = tempfile::tempdir().expect("reference image fixture"); 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 index 14ab9c854..664f40e42 100644 --- 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 @@ -9,3 +9,27 @@ pub enum Component { Image(image::ImageComponent), Text(text::TextComponent), } + +/// LLM 工具返回的节点组件载荷。 +/// +/// 这里不能直接使用 `Option`:部分模型在严格工具 schema 下不会稳定地产生 +/// `null`。用显式的 `PureNode` / `WithComponent` 外部枚举表达两种情况,既保留纯结构节点 +/// 的语义,也让工具调用始终返回一个可判别的对象;落入编辑器 `Node` 时再映射为 +/// `Option`。 +#[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 NodeComponent { + PureNode, + WithComponent(Component), +} + +impl NodeComponent { + pub fn into_option(self) -> Option { + match self { + Self::PureNode => None, + Self::WithComponent(component) => Some(component), + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs index e0c8199e7..e908a3117 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs @@ -199,14 +199,13 @@ fn render_node_with_scale( json!({"childrenDisplayMode": "Exclusive", "childrenRendered": "all"}), ) }); - let components = node - .components - .iter() + let component = node + .component + .as_ref() .map(|component| render_component(state, component)) - .collect::, _>>()? - .into_iter() + .transpose()? .map(|fragment| fragment.into_string()) - .collect::>(); + .unwrap_or_default(); let children = node .children .iter() @@ -226,7 +225,7 @@ fn render_node_with_scale( (comment) @if let Some(group_comment) = exclusive_comment { (group_comment) } div ui-node-id=(node.id.as_str()) style=(style) { - (PreEscaped(components.concat())) + (PreEscaped(component)) (PreEscaped(children.concat())) } }) 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 index 9375e913f..be70cdca6 100644 --- 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 @@ -11,7 +11,7 @@ pub struct Node { pub id: NodeId, pub layout: ControlLayout, pub metadata: NodeMetadata, - pub components: Vec, + pub component: Option, pub children_display_mode: ChildrenDisplayMode, pub children: Vec, } @@ -50,7 +50,7 @@ pub struct NodeMetadata { pub name: String, pub description: String, pub layout_status: StageStatus, - pub components_status: StageStatus, + pub component_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/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs index bc7d08d88..73b55d9ce 100644 --- 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 @@ -25,7 +25,6 @@ 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)] @@ -196,7 +195,7 @@ pub(crate) fn generate_ui_design_code_at( }) } -fn generated_file_stem(asset_id: &str) -> String { +pub(crate) fn generated_file_stem(asset_id: &str) -> String { let mut stem = String::new(); for character in asset_id.chars() { if character.is_ascii_alphanumeric() || matches!(character, '_' | '-') { @@ -747,12 +746,8 @@ fn validate_node( } } } - 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 { + validate_component_status(node.component.as_ref(), &node.metadata.component_status)?; + if let Some(component) = &node.component { match component { Component::Image(image) => { if image @@ -778,6 +773,22 @@ fn validate_node( Ok(()) } +fn validate_component_status( + component: Option<&Component>, + status: &crate::ui_editor::layout::node::StageStatus, +) -> Result<(), String> { + if component.is_none() + && matches!( + status, + crate::ui_editor::layout::node::StageStatus::NeedReview(_) + | crate::ui_editor::layout::node::StageStatus::Blocked(_) + ) + { + return Err("纯结构节点的 component_status 必须为 NoProblem".to_string()); + } + 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} 无效")); @@ -881,12 +892,12 @@ mod tests { "name": "页面根节点", "description": "", "layout_status": "NoProblem", - "components_status": "NoProblem", + "component_status": "NoProblem", "allow_llm_edit_layout": true, "allow_llm_edit_component": true, "source": "System" }, - "components": [], + "component": null, "children_display_mode": "Stack", "children": [{ "id": "dragged-node", @@ -907,17 +918,17 @@ mod tests { "name": "拖拽节点", "description": "", "layout_status": "NoProblem", - "components_status": "NoProblem", + "component_status": "NoProblem", "allow_llm_edit_layout": true, "allow_llm_edit_component": true, "source": "Human" }, - "components": [{ + "component": { "Image": { "target_graphic": "spirit", "image_type": { "Simple": { "preserve_aspect": false } } } - }], + }, "children_display_mode": "Stack", "children": [] }] @@ -1095,12 +1106,12 @@ mod tests { "name": "根节点", "description": "", "layout_status": "NoProblem", - "components_status": "NoProblem", + "component_status": "NoProblem", "allow_llm_edit_layout": true, "allow_llm_edit_component": true, "source": "System" }, - "components": [], + "component": null, "children_display_mode": "Stack", "children": [] } @@ -1317,12 +1328,12 @@ mod tests { "name": "根节点", "description": "", "layout_status": "NoProblem", - "components_status": "NoProblem", + "component_status": "NoProblem", "allow_llm_edit_layout": false, "allow_llm_edit_component": false, "source": "System" }, - "components": [{ + "component": { "Text": { "content": "标题", "font": {"Bound": "missing-font"}, @@ -1334,7 +1345,7 @@ mod tests { "vertical_overflow": "Truncate", "line_spacing": 1.0 } - }], + }, "children_display_mode": "Stack", "children": [] } @@ -1361,4 +1372,30 @@ mod tests { .expect_err("missing Text font reference must be rejected"); assert!(error.contains("Text 组件引用了不存在的字体素材")); } + + #[test] + fn component_status_matrix_keeps_pure_nodes_unproblematic() { + use crate::ui_editor::component::image::ImageComponent; + + assert!(validate_component_status( + None, + &crate::ui_editor::layout::node::StageStatus::NoProblem, + ) + .is_ok()); + assert!(validate_component_status( + None, + &crate::ui_editor::layout::node::StageStatus::NeedReview("原因".to_string()), + ) + .is_err()); + assert!(validate_component_status( + None, + &crate::ui_editor::layout::node::StageStatus::Blocked("原因".to_string()), + ) + .is_err()); + assert!(validate_component_status( + Some(&Component::Image(ImageComponent::new())), + &crate::ui_editor::layout::node::StageStatus::NeedReview("等待素材".to_string()), + ) + .is_ok()); + } } 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 index de48e841b..d282ac64e 100644 --- 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 @@ -43,3 +43,10 @@ define_id!(FontAssetId); define_id!(SpriteAssetId); define_id!(UIDesignImageId); define_id!(NodeId); + +const NODE_ID_LENGTH: usize = 16; + +pub fn random_node_id() -> NodeId { + let uuid = uuid::Uuid::new_v4().simple().to_string(); + NodeId::new(&uuid[..NODE_ID_LENGTH]).expect("generated node ID is valid") +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs index 829782148..3c7d9a4f1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs @@ -296,7 +296,7 @@ pub(crate) async fn run_ui_workflow_at_with_provider( ) { let route = UiWorkflowFinalStageRoute { resource_id: statuses[0].ui_asset_id.clone(), - initial_step: "visual-binding".to_string(), + initial_step: "asset-separation".to_string(), render_mode: "final-preview".to_string(), }; if finalized { @@ -1145,8 +1145,8 @@ fn apply_binding_changes( ) -> usize { let mut changed = 0; if let Some(change) = changes.get(&node.id) { - node.components = change.components.clone(); - node.metadata.components_status = change.components_status.clone(); + node.component = change.component.clone().into_option(); + node.metadata.component_status = change.component_status.clone(); changed += 1; } for child in &mut node.children { @@ -1163,7 +1163,7 @@ fn apply_binding_changes( fn state_has_renderable_component(state: &crate::ui_editor::state::State) -> bool { fn has_component(node: &Node) -> bool { - !node.components.is_empty() || node.children.iter().any(has_component) + node.component.is_some() || node.children.iter().any(has_component) } state.ui_trees.iter().any(|tree| has_component(&tree.root)) } @@ -1315,14 +1315,14 @@ fn derive_page_status( } fn collect_binding_blockers(node: &Node, component_count: &mut usize, blockers: &mut Vec) { - *component_count += node.components.len(); + *component_count += node.component.is_some() as usize; if let StageStatus::NeedReview(reason) | StageStatus::Blocked(reason) = &node.metadata.layout_status { blockers.push(format!("{} 布局未通过:{reason}", node.metadata.name)); } if let StageStatus::NeedReview(reason) | StageStatus::Blocked(reason) = - &node.metadata.components_status + &node.metadata.component_status { blockers.push(format!("{} 组件未通过:{reason}", node.metadata.name)); } diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index 8cf521851..7fc0d7cf1 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Genarrative AI Game Creator", - "version": "0.1.27", + "version": "0.1.29", "identifier": "world.genarrative.ai-game-creator", "build": { "beforeDevCommand": "npm --prefix ../.. run agc:serve", 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 deleted file mode 100644 index 47a8a3a17..000000000 --- a/apps/ai-game-creator-shell/src/features/ui-editor/binding.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 84e6cc6ce..000000000 --- a/apps/ai-game-creator-shell/src/features/ui-editor/bindingOverview.ts +++ /dev/null @@ -1,119 +0,0 @@ -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/recognition.ts b/apps/ai-game-creator-shell/src/features/ui-editor/recognition.ts index 2238c6b40..68bd9e003 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/recognition.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/recognition.ts @@ -3,7 +3,7 @@ import type { State } from './types/State'; /** * 识别结果是整棵结构草稿树的替换结果,不与旧树逐节点合并。 - * 识别阶段有意不携带视觉组件;组件绑定由后续 visual-binding 阶段完成。 + * 识别阶段有意不携带 SpriteAsset;视觉素材由后续 asset-separation 阶段自动切分并回填。 */ export function applyRecognitionResult( state: State, 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 index af3e0c929..759792923 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/requisites.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/requisites.ts @@ -72,7 +72,7 @@ export function validateComponentRecognitionPrerequisites( return issues; } -export function validateAssetRecognitionPrerequisites( +export function validateAssetSeparationPrerequisites( state: State, ): UiEditorPrerequisiteIssue[] { const issues = validateComponentRecognitionPrerequisites(state); @@ -91,57 +91,9 @@ export function validateAssetRecognitionPrerequisites( }); } } - 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 { @@ -213,17 +165,43 @@ export function validateStructureRecognitionResult( return issues; } -export function validateVisualBindingResult( +export function validateAssetSeparationResult( state: State, ): UiEditorPrerequisiteIssue[] { const issues: UiEditorPrerequisiteIssue[] = []; for (const tree of state.ui_trees) { visitNodes([tree.root], (node) => { - if (node.components.length == 0) { + if (node.component === null) { return; } - const issue = componentStatusIssue(node.metadata.components_status); + const issue = componentStatusIssue(node.metadata.component_status); if (issue) issues.push(issue); + const component = node.component; + if ('Image' in component) { + const targetGraphic = component.Image.target_graphic; + if (targetGraphic === null) { + issues.push({ + code: 'missing-separated-image', + message: '图片组件尚未完成素材切分', + }); + } else if (!(targetGraphic in state.sprite_assets)) { + issues.push({ + code: 'missing-target-graphic', + message: '图片组件引用的切分素材不存在', + resourceId: targetGraphic, + }); + } + } + 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, + }); + } + } }); } return issues; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/separationOverview.ts b/apps/ai-game-creator-shell/src/features/ui-editor/separationOverview.ts new file mode 100644 index 000000000..99fc27163 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/separationOverview.ts @@ -0,0 +1,134 @@ +import { + collectUiTreeNodeTargets, + isBlocked, + isNeedReview, + type UiTreeNodeTarget, +} from './stageStatusOverview'; +import type { Component } from './types/Component'; +import type { FontAsset } from './types/FontAsset'; +import type { SpriteAsset } from './types/SpriteAsset'; +import type { UITree } from './types/UITree'; + +export type ComponentSeparationCounts = { + componentsNeedingAssets: number; + assetSlots: number; + boundSlots: number; + pendingSlots: number; +}; + +export type SeparationOverview = ComponentSeparationCounts & { + needsAttention: number; + blocked: number; + independentAssets: number; +}; + +const EMPTY_COMPONENT_SEPARATION_COUNTS: ComponentSeparationCounts = { + componentsNeedingAssets: 0, + assetSlots: 0, + boundSlots: 0, + pendingSlots: 0, +}; + +function countRequiredAssetSlot(isBound: boolean): ComponentSeparationCounts { + return { + componentsNeedingAssets: 1, + assetSlots: 1, + boundSlots: isBound ? 1 : 0, + pendingSlots: isBound ? 0 : 1, + }; +} + +export function getImageSeparationCounts( + component: Extract['Image'], + spriteAssets?: Record, +): ComponentSeparationCounts { + return countRequiredAssetSlot( + component.target_graphic !== null && + (spriteAssets === undefined || component.target_graphic in spriteAssets), + ); +} + +export function getTextSeparationCounts( + component: Extract['Text'], + fontAssets?: Record, +): ComponentSeparationCounts { + if ( + typeof component.font !== 'object' || + component.font === null || + !('Bound' in component.font) + ) { + return EMPTY_COMPONENT_SEPARATION_COUNTS; + } + return countRequiredAssetSlot( + fontAssets === undefined || component.font.Bound in fontAssets, + ); +} + +export function getComponentSeparationCounts( + component: Component, + spriteAssets?: Record, + fontAssets?: Record, +): ComponentSeparationCounts { + if ('Image' in component) { + return getImageSeparationCounts(component.Image, spriteAssets); + } + if ('Text' in component) { + return getTextSeparationCounts(component.Text, fontAssets); + } + return EMPTY_COMPONENT_SEPARATION_COUNTS; +} + +function addSeparationCounts( + overview: ComponentSeparationCounts, + counts: ComponentSeparationCounts, +) { + overview.componentsNeedingAssets += counts.componentsNeedingAssets; + overview.assetSlots += counts.assetSlots; + overview.boundSlots += counts.boundSlots; + overview.pendingSlots += counts.pendingSlots; +} + +export function getSeparationOverview( + uiTrees: UITree[], + spriteAssets: Record, + fontAssets: Record = {}, +): SeparationOverview { + const overview: SeparationOverview = { + ...EMPTY_COMPONENT_SEPARATION_COUNTS, + needsAttention: 0, + blocked: 0, + independentAssets: Object.keys(spriteAssets).length, + }; + + for (const { node } of collectUiTreeNodeTargets(uiTrees)) { + const status = node.metadata.component_status; + if (isBlocked(status)) overview.blocked += 1; + if (isBlocked(status) || isNeedReview(status)) { + overview.needsAttention += 1; + } + if (node.component) { + addSeparationCounts( + overview, + getComponentSeparationCounts(node.component, spriteAssets, fontAssets), + ); + } + } + + return overview; +} + +export function nodeHasPendingSeparation(target: UiTreeNodeTarget): boolean { + return ( + target.node.component !== null && + getComponentSeparationCounts(target.node.component).pendingSlots > 0 + ); +} + +export function nodeNeedsComponentReview(target: UiTreeNodeTarget): boolean { + const status = target.node.metadata.component_status; + return isBlocked(status) || isNeedReview(status); +} + +export function nodeHasBlockedComponents(target: UiTreeNodeTarget): boolean { + return isBlocked(target.node.metadata.component_status); +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/separationStatus.ts b/apps/ai-game-creator-shell/src/features/ui-editor/separationStatus.ts new file mode 100644 index 000000000..d789e7045 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/separationStatus.ts @@ -0,0 +1,43 @@ +import { collectUiTreeNodeTargets } from './stageStatusOverview'; +import type { Node as UiNode } from './types/Node'; +import type { ProblematicNode } from './types/ProblematicNode'; +import type { UITree } from './types/UITree'; + +export function separationProblemReason(problematic: ProblematicNode): string { + const history = problematic.problem_history + .filter((item) => item.trim()) + .join('\n'); + const details = history || problematic.problem_description; + return `自动切分重试已达上限(${problematic.rework_count} 次)${details ? `\n${details}` : ''}`; +} + +export function clearSeparationComponentStatus(node: UiNode): void { + node.metadata.component_status = 'NoProblem'; +} + +export function applySeparationProblematicStatuses( + uiTrees: UITree[], + problematicNodes: ProblematicNode[], +): string[] { + const targets = new Map( + collectUiTreeNodeTargets(uiTrees).map((target) => [target.node.id, target]), + ); + const errors: string[] = []; + for (const problematic of problematicNodes) { + const target = targets.get(problematic.node_id); + if (!target) { + errors.push(`问题节点 ${problematic.node_id} 已不存在,已保留问题记录`); + continue; + } + if (!target.node.component || !('Image' in target.node.component)) { + errors.push( + `问题节点 ${problematic.node_id} 不是可处理的 Image 组件,已保留问题记录`, + ); + continue; + } + target.node.metadata.component_status = { + NeedReview: separationProblemReason(problematic), + }; + } + return errors; +} 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 index c2f9e9c00..80dc27fac 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/stageStatusOverview.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/stageStatusOverview.ts @@ -6,7 +6,7 @@ import type { UITree } from './types/UITree'; export type StageStatusField = Extract< keyof NodeMetadata, - 'layout_status' | 'components_status' + 'layout_status' | 'component_status' >; export type UiTreeNodeTarget = { 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 deleted file mode 100644 index ec71edbfd..000000000 --- a/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingChange.ts +++ /dev/null @@ -1,6 +0,0 @@ -// 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 deleted file mode 100644 index beb340894..000000000 --- a/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingDTO.ts +++ /dev/null @@ -1,4 +0,0 @@ -// 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/BoundNode.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/BoundNode.ts new file mode 100644 index 000000000..b27bee935 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/BoundNode.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 { NodeId } from "./NodeId"; + +export type BoundNode = { node_id: NodeId, cut_image_path: string, }; 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 index c05d8743a..bac60a9ca 100644 --- 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 @@ -5,4 +5,4 @@ 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, }; +export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, component: Component | null, children_display_mode: ChildrenDisplayMode, children: Array, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeComponent.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeComponent.ts new file mode 100644 index 000000000..b3116576c --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeComponent.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 { Component } from "./Component"; + +/** + * LLM 工具返回的节点组件载荷。 + * + * 这里不能直接使用 `Option`:部分模型在严格工具 schema 下不会稳定地产生 + * `null`。用显式的 `PureNode` / `WithComponent` 外部枚举表达两种情况,既保留纯结构节点 + * 的语义,也让工具调用始终返回一个可判别的对象;落入编辑器 `Node` 时再映射为 + * `Option`。 + */ +export type NodeComponent = "PureNode" | { "WithComponent": Component }; 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 index 43aee349e..f822b7d4b 100644 --- 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 @@ -2,4 +2,4 @@ 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, }; +export type NodeMetadata = { name: string, description: string, layout_status: StageStatus, component_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/ProblematicNode.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/ProblematicNode.ts new file mode 100644 index 000000000..0badc8890 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/ProblematicNode.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 { NodeId } from "./NodeId"; + +export type ProblematicNode = { node_id: NodeId, problem_description: string, problem_history: Array, rework_count: number, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationDTO.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationDTO.ts new file mode 100644 index 000000000..7408ed0d0 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationDTO.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 { BoundNode } from "./BoundNode"; +import type { ProblematicNode } from "./ProblematicNode"; + +export type SeparationDTO = { bound_nodes: Array, problematic_nodes: Array, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationNode.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationNode.ts new file mode 100644 index 000000000..a3fffd4e0 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationNode.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 { NodeId } from "./NodeId"; +import type { SeparationNodeKind } from "./SeparationNodeKind"; +import type { SeparationNote } from "./SeparationNote"; + +export type SeparationNode = { id: NodeId, kind: SeparationNodeKind, global_pos_x_px: number, global_pos_y_px: number, width_px: number, height_px: number, note: SeparationNote, children: Array, rework_count: number, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationNodeKind.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationNodeKind.ts new file mode 100644 index 000000000..86f060e82 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationNodeKind.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 SeparationNodeKind = "ImageTarget" | "TextRemovalOnly" | "PureContainer"; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationNote.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationNote.ts new file mode 100644 index 000000000..0cb01f4ee --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationNote.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 SeparationNote = { description: string, rework_notes: Array, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationRecoveryDTO.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationRecoveryDTO.ts new file mode 100644 index 000000000..38c5bb8b2 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationRecoveryDTO.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 SeparationRecoveryDTO = { exists: boolean, bound_node_count: number, problematic_node_count: number, has_pending_tree: boolean, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationState.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationState.ts new file mode 100644 index 000000000..fa180b51e --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationState.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 { BoundNode } from "./BoundNode"; +import type { ProblematicNode } from "./ProblematicNode"; +import type { SeparationTree } from "./SeparationTree"; + +export type SeparationState = { schema_version: string, trees: Array, bound: Array, problematic_nodes: Array, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationTree.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationTree.ts new file mode 100644 index 000000000..2196bac3b --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/SeparationTree.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 { SeparationNode } from "./SeparationNode"; +import type { UIDesignImageId } from "./UIDesignImageId"; + +export type SeparationTree = { src_ui_design: UIDesignImageId, root: SeparationNode, root_extractable: boolean, }; 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/useUiEditorState.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts index a65ed50dc..a83ab93b4 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts @@ -64,14 +64,12 @@ export type NodeMetadataPatch = Partial< | 'name' | 'description' | 'layout_status' - | 'components_status' + | 'component_status' | 'allow_llm_edit_layout' | 'allow_llm_edit_component' > >; -export type ComponentIndex = number; - export type NodeTransformOptions = { keepChildrenUnchanged?: boolean; }; @@ -106,6 +104,12 @@ function visitNodes(node: Node, visit: (node: Node) => void): void { for (const child of node.children) visitNodes(child, visit); } +function isProblematicComponentStatus( + status: NodeMetadata['component_status'], +): boolean { + return typeof status !== 'string'; +} + function existingNodeIds(state: State): Set { const ids = new Set(); for (const tree of state.ui_trees) { @@ -144,12 +148,12 @@ function createPageRoot(state: State): Node { name: '页面根节点', description: '', layout_status: 'NoProblem', - components_status: 'NoProblem', + component_status: 'NoProblem', allow_llm_edit_layout: true, allow_llm_edit_component: true, source: 'System', }, - components: [], + component: null, children_display_mode: 'Stack', children: [], }; @@ -175,12 +179,12 @@ function createHumanNode(state: State): Node { name: '新节点', description: '', layout_status: 'NoProblem', - components_status: 'NoProblem', + component_status: 'NoProblem', allow_llm_edit_layout: true, allow_llm_edit_component: true, source: 'Human', }, - components: [], + component: null, children_display_mode: 'Stack', children: [], }; @@ -283,9 +287,7 @@ function sameResource( function visitComponents(nodes: Node[], visit: (component: Component) => void) { for (const node of nodes) { - for (const component of node.components) { - visit(component); - } + if (node.component) visit(node.component); visitComponents(node.children, visit); } } @@ -524,6 +526,40 @@ function spriteResourceValidationError(sprite: SpriteAsset) { return border.ok ? null : border.message; } +/** + * Apply the SpriteAsset resource checks without acquiring the editor mutation + * guard. Workflow adapters use this while a State lock is already held so + * resource insertion and component backfill can be committed atomically. + */ +export function addSpriteAssetsToState( + current: State, + assets: readonly SpriteAsset[], +): UiEditorOperationResult { + 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); + } + return { ok: true, value: next }; +} + function fontResourceValidationError(font: FontAsset) { if (font.asset_id.trim().length === 0) return '缺少字体 ID'; if (font.path.trim().length === 0) return '缺少字体路径'; @@ -782,32 +818,9 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { 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); + const result = addSpriteAssetsToState(current, assets); + if (!result.ok) return result; + commit(result.value); return { ok: true, value: undefined }; }, [commit, guard], @@ -1110,15 +1123,15 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { [commit, guard], ); - const setNodeComponents = useCallback( + const setNodeComponent = useCallback( ( treeId: UIDesignImageId, nodeId: NodeId, - components: Component[], + component: Component | null, ): UiEditorOperationResult => { const blocked = guard(); if (blocked) return blocked; - if (!components.every(isValidComponent)) { + if (component && !isValidComponent(component)) { return { ok: false, reason: 'invalid' }; } const current = stateRef.current; @@ -1132,126 +1145,9 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { 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); + const nextNode = findNodeLocation(nextTree.root, nodeId)!.node; + nextNode.component = structuredClone(component); + nextNode.metadata.component_status = 'NoProblem'; commit(next); return { ok: true, value: undefined }; }, @@ -1279,13 +1175,20 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { .root, nodeId, )!.node; + if ( + patch.component_status !== undefined && + node.component === null && + isProblematicComponentStatus(patch.component_status) + ) { + return { ok: false, reason: 'invalid' }; + } 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.component_status !== undefined) + node.metadata.component_status = patch.component_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) @@ -1564,10 +1467,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { deleteNode, setNodeTransform, setNodeLayout, - setNodeComponents, - insertComponent, - deleteComponent, - moveComponent, + setNodeComponent, setNodeMetadata, setNodeChildrenDisplayMode, moveNode, 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 062ea6e42..15fdad3a4 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 @@ -5061,7 +5061,7 @@ export default function ProjectDevelopmentView({ resource.label, ...(result.asset.source.generationKind === 'ui-workflow.completed' ? { - initialStep: 'visual-binding', + initialStep: 'asset-separation', initialFurthestStepIndex: 2, } : {}), @@ -5099,7 +5099,7 @@ export default function ProjectDevelopmentView({ (asset) => asset.id === resource.manifestAssetId, )?.source.generationKind === 'ui-workflow.completed' ? { - initialStep: 'visual-binding' as const, + initialStep: 'asset-separation' as const, initialFurthestStepIndex: 2, } : {}), @@ -5133,7 +5133,7 @@ export default function ProjectDevelopmentView({ resourceLabel: completed.localPath.split(/[\\/]/u).filter(Boolean).pop() ?? 'UI 设计资源', - initialStep: 'visual-binding', + initialStep: 'asset-separation', initialFurthestStepIndex: 2, }); }, [advanceFocusGeneration, manifest.assets, uiEditorRoute]); diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/ImportOverview.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/ImportOverview.tsx index cd76e9fec..0823299bd 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/ImportOverview.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/ImportOverview.tsx @@ -6,7 +6,7 @@ import type { UITree } from '../../../features/ui-editor/types/UITree'; function countUiComponents(nodes: UiNode[]): number { return nodes.reduce( (total, node) => - total + node.components.length + countUiComponents(node.children), + total + (node.component ? 1 : 0) + countUiComponents(node.children), 0, ); } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx index 495db5bd9..b473accb2 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx @@ -62,12 +62,12 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) { name: 'UI Trees', description: '', layout_status: 'NoProblem', - components_status: 'NoProblem', + component_status: 'NoProblem', allow_llm_edit_layout: false, allow_llm_edit_component: false, source: 'System', }, - components: [], + component: null, children_display_mode: 'Stack', children: uiTrees.map((tree) => tree.root), }; diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentPanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentPanel.tsx index ab030488d..c1b9bcee7 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentPanel.tsx @@ -1,13 +1,7 @@ -import { - ArrowDown, - ArrowUp, - ChevronDown, - ChevronRight, - Plus, - Trash2, -} from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { ChevronDown, ChevronRight, Plus, Trash2 } from 'lucide-react'; +import { type ReactNode, useEffect, useRef, useState } from 'react'; +import { ThemedModal } from '../../../../../components/modal/ThemedModal'; import type { Component } from '../../../../../features/ui-editor/types/Component'; import type { UiEditorOperationResult } from '../../../../../features/ui-editor/useUiEditorState'; import { useInspectorReadOnly } from '../InspectorReadOnlyContext'; @@ -18,111 +12,60 @@ import { TextPanel } from './TextPanel'; import { createDefaultTextComponent } from './TextPanelDefaults'; export function ComponentPanel(props: ComponentPanelProps) { - const { - components, - readOnly: propReadOnly, - onSetComponents, - onInsertComponent, - onDeleteComponent, - onMoveComponent, - } = props; + const { component, readOnly: propReadOnly, onSetComponent } = props; const inspectorReadOnly = useInspectorReadOnly(); const readOnly = propReadOnly || inspectorReadOnly; const [addKind, setAddKind] = useState<'Image' | 'Text'>('Image'); - const [expandedIndexes, setExpandedIndexes] = useState>( - () => new Set(), - ); + const [expanded, setExpanded] = useState(Boolean(component)); const [error, setError] = useState(null); + const [replaceConfirmationOpen, setReplaceConfirmationOpen] = useState(false); + const [removeConfirmationOpen, setRemoveConfirmationOpen] = useState(false); + const previousComponentRef = useRef(component); useEffect(() => { - setExpandedIndexes((current) => { - const next = new Set( - [...current].filter((index) => index >= 0 && index < components.length), - ); - if (next.size === current.size) return current; - return next; - }); - }, [components.length]); + const previous = previousComponentRef.current; + previousComponentRef.current = component; + if (Boolean(component) !== Boolean(previous)) { + setExpanded(Boolean(component)); + } + }, [component]); - const updateComponent = (index: number, next: Component) => { + function setComponent(next: Component | null) { if (readOnly) return undefined; - const nextComponents = components.slice(); - nextComponents[index] = next; - const result = onSetComponents(nextComponents); - if (result && !result.ok) setError('组件字段无效,更新未应用。'); + const result = onSetComponent(next); + if (result && !result.ok) setError('组件更新失败。'); else setError(null); return result; - }; + } - function addComponent() { - if (readOnly) return; - let component: Component; - switch (addKind) { - case 'Image': - component = { Image: createDefaultImageComponent() }; - break; - case 'Text': - component = { Text: createDefaultTextComponent() }; - break; + function createComponent(): Component { + return addKind === 'Image' + ? { Image: createDefaultImageComponent() } + : { Text: createDefaultTextComponent() }; + } + + function replaceComponent() { + if (component) { + setReplaceConfirmationOpen(true); + return; } - const result = onInsertComponent(components.length, component); + applyReplacement(); + } + + function applyReplacement() { + const result = setComponent(createComponent()); if (result?.ok) { - setExpandedIndexes((current) => new Set(current).add(components.length)); - setError(null); - } else if (result) { - setError('组件新增失败。'); + setExpanded(true); + setReplaceConfirmationOpen(false); + } else if (result !== undefined) { + setReplaceConfirmationOpen(false); } } - function deleteComponentAt(index: number) { - if (readOnly) return; - const result = onDeleteComponent(index); - if (result?.ok) { - setExpandedIndexes((current) => { - const next = new Set(); - for (const expanded of current) { - if (expanded === index) continue; - if (expanded > index) next.add(expanded - 1); - else next.add(expanded); - } - return next; - }); - setError(null); - } else if (result) { - setError('组件删除失败。'); - } - } - - function moveComponent(index: number, direction: 'up' | 'down') { - if (readOnly) return; - // Components are rendered in array order. The last item therefore sits - // visually at the top of the stack. - let nextIndex: number; - switch (direction) { - case 'up': - nextIndex = index + 1; - break; - case 'down': - nextIndex = index - 1; - break; - } - if (nextIndex < 0 || nextIndex >= components.length) return; - const result = onMoveComponent(index, nextIndex); - if (result?.ok) { - setExpandedIndexes((current) => { - const next = new Set(current); - const wasCurrentExpanded = next.has(index); - const wasTargetExpanded = next.has(nextIndex); - next.delete(index); - next.delete(nextIndex); - if (wasCurrentExpanded) next.add(nextIndex); - if (wasTargetExpanded) next.add(index); - return next; - }); - setError(null); - } else if (result) { - setError('组件顺序更新失败。'); - } + function removeComponent() { + const result = setComponent(null); + if (result?.ok) setRemoveConfirmationOpen(false); + else if (result !== undefined) setRemoveConfirmationOpen(false); } return ( @@ -130,7 +73,7 @@ export function ComponentPanel(props: ComponentPanelProps) {

组件

- {components.length} 个 + {component ? componentKind(component) : '无'}
@@ -142,7 +85,7 @@ export function ComponentPanel(props: ComponentPanelProps) { onChange={(event) => setAddKind(event.target.value as 'Image' | 'Text') } - aria-label="新增组件类型" + aria-label="组件类型" > @@ -151,169 +94,166 @@ export function ComponentPanel(props: ComponentPanelProps) { type="button" className="flex h-8 items-center gap-1 rounded-lg bg-blue-600 px-3 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40" disabled={readOnly} - onClick={addComponent} - aria-label="新增组件" - title="新增组件" + onClick={replaceComponent} + aria-label={component ? '替换组件' : '新增组件'} + title={component ? '替换组件' : '新增组件'} > - {componentKindLabel(addKind)} + {component ? '替换' : '新增'} - {components.length > 0 && ( -
- {[...components].reverse().map((component, reverseIndex) => { - const index = components.length - reverseIndex - 1; - const expanded = expandedIndexes.has(index); - return ( -
-
- - - - -
- {expanded && ( -
- {renderComponentEditor( - component, - index, - props, - readOnly, - updateComponent, - )} -
- )} -
- ); - })} + {component ? ( +
+
+ + +
+ {expanded && ( +
+ {renderComponentEditor(component, props, readOnly, setComponent)} +
+ )}
- )} - {components.length === 0 && ( + ) : (

当前节点没有组件。

)} {error &&

{error}

} + setReplaceConfirmationOpen(false)} + onConfirm={applyReplacement} + /> + setRemoveConfirmationOpen(false)} + onConfirm={removeComponent} + /> ); } +function ConfirmationModal({ + open, + ariaLabel, + title, + description, + confirmLabel, + confirmClassName, + onClose, + onConfirm, +}: { + open: boolean; + ariaLabel: string; + title: string; + description: ReactNode; + confirmLabel: string; + confirmClassName: string; + onClose: () => void; + onConfirm: () => void; +}) { + return ( + +

{title}

+

{description}

+
+ + +
+
+ ); +} + function componentKind(component: Component): string { - switch (true) { - case 'Image' in component: - return '图片'; - case 'Text' in component: - return '文本'; - default: - return '未知'; - } -} - -function componentKindLabel(kind: 'Image' | 'Text'): string { - switch (kind) { - case 'Image': - return '图片'; - case 'Text': - return '文本'; - } -} - -function componentLayerLabel(index: number, count: number): string { - if (index === count - 1) return '顶部'; - return `层级 ${index + 1}`; -} - -function ExpandIcon({ expanded }: { expanded: boolean }) { - if (expanded) return ; - return ; + if ('Image' in component) return '图片'; + if ('Text' in component) return '文本'; + return '未知'; } function renderComponentEditor( component: Component, - index: number, props: ComponentPanelProps, readOnly: boolean, updateComponent: ( - index: number, - next: Component, + next: Component | null, ) => UiEditorOperationResult | undefined, ) { - switch (true) { - case 'Image' in component: - return ( - updateComponent(index, { Image: next })} - /> - ); - case 'Text' in component: - return ( - updateComponent(index, { Text: next })} - /> - ); - default: - return ( -

- 当前组件类型暂不支持编辑。 -

- ); + if ('Image' in component) { + return ( + updateComponent({ Image: next })} + /> + ); } + if ('Text' in component) { + return ( + updateComponent({ Text: next })} + /> + ); + } + return ( +

+ 当前组件类型暂不支持编辑。 +

+ ); } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/componentEditorTypes.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/componentEditorTypes.ts index 9d3adc06a..0319745b5 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/componentEditorTypes.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/componentEditorTypes.ts @@ -7,24 +7,15 @@ import type { UiEditorFontFaceState } from '../../../../../features/ui-editor/us import type { UiEditorOperationResult } from '../../../../../features/ui-editor/useUiEditorState'; export type ComponentPanelProps = { - components: Component[]; + component: Component | null; sprites: Record; previewUrls: Record; fonts: Record; fontFaces: Record; projectPath: string; readOnly: boolean; - onSetComponents: ( - components: Component[], - ) => UiEditorOperationResult | undefined; - onInsertComponent: ( - index: number, - component: Component, - ) => UiEditorOperationResult | undefined; - onDeleteComponent: (index: number) => UiEditorOperationResult | undefined; - onMoveComponent: ( - fromIndex: number, - toIndex: number, + onSetComponent: ( + component: Component | null, ) => UiEditorOperationResult | undefined; }; diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx index 7557c3727..15e77c8e7 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx @@ -115,10 +115,7 @@ export function InspectorSidebar({ fonts={inspector.fonts} fontFaces={inspector.fontFaces} projectPath={inspector.projectPath} - onSetComponents={inspector.setNodeComponents} - onInsertComponent={inspector.insertNodeComponent} - onDeleteComponent={inspector.deleteNodeComponent} - onMoveComponent={inspector.moveNodeComponent} + onSetComponent={inspector.setNodeComponent} onDeleteNode={() => inspector.deleteNode(view.node.id)} deleteDisabled={ inspector.isLocked || view.node.id === inspector.tree?.root.id @@ -293,10 +290,7 @@ function NodeInspector({ fonts, fontFaces, projectPath, - onSetComponents, - onInsertComponent, - onDeleteComponent, - onMoveComponent, + onSetComponent, onDeleteNode, deleteDisabled, }: { @@ -323,10 +317,7 @@ function NodeInspector({ fonts: UiEditorInspectorProjection['fonts']; fontFaces: UiEditorInspectorProjection['fontFaces']; projectPath: string; - onSetComponents: UiEditorInspectorProjection['setNodeComponents']; - onInsertComponent: UiEditorInspectorProjection['insertNodeComponent']; - onDeleteComponent: UiEditorInspectorProjection['deleteNodeComponent']; - onMoveComponent: UiEditorInspectorProjection['moveNodeComponent']; + onSetComponent: UiEditorInspectorProjection['setNodeComponent']; onDeleteNode: () => void; deleteDisabled: boolean; }) { @@ -414,7 +405,7 @@ function NodeInspector({ 来源:{node.metadata.source}
- 组件:{node.components.length} + 组件:{node.component ? 1 : 0}
@@ -434,18 +425,18 @@ function NodeInspector({ }} /> { - if (!isReadOnly) onMetadataChange({ components_status }); + onChange={(component_status) => { + if (!isReadOnly) onMetadataChange({ component_status }); }} />
@@ -498,17 +489,15 @@ function NodeInspector({ onChange={onLayoutChange} /> ; + fonts: Record; onFocusStatusNode: (treeId: UIDesignImageId, nodeId: NodeId) => void; }) { const overview = useMemo( - () => getBindingOverview(uiTrees, sprites), - [sprites, uiTrees], + () => getSeparationOverview(uiTrees, sprites, fonts), + [fonts, sprites, uiTrees], ); const attentionCycle = useUiTreeNodeCycle({ uiTrees, @@ -38,20 +41,20 @@ export function BindingOverview({ return (
Overview -

绑定概览

+

自动切分素材概览

- - + +
+ +

+ 发现未完成的自动切分素材 +

+

+ 上次自动切分素材留下了可恢复状态(已登记{' '} + {workflow.separationRecovery?.bound_node_count ?? 0}{' '} + 个节点)。请选择继续上次自动切分素材,或开始新的自动切分素材。 +

+
+ + + +
+
); } @@ -76,8 +115,8 @@ function getStepAction(workflow: UiEditorWorkflowProjection) { }; } return { - label: '绑定视觉素材', - runningLabel: '绑定中…', - action: workflow.bindComponents, + label: '自动切分素材', + runningLabel: '素材切分中…', + action: workflow.separateUi, }; } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowChecks.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowChecks.ts index 54b77ef1f..5df5d0d9b 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowChecks.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowChecks.ts @@ -1,11 +1,10 @@ import { type UiEditorPrerequisiteIssue, - validateAssetRecognitionPrerequisites, + validateAssetSeparationPrerequisites, + validateAssetSeparationResult, validateComponentRecognitionPrerequisites, - validateLayoutReviewPrerequisites, validateReferenceAnalysisResult, validateStructureRecognitionResult, - validateVisualBindingResult, } from '../../../features/ui-editor/requisites'; import type { State } from '../../../features/ui-editor/types/State'; import type { UiEditorStepId } from '../model'; @@ -21,8 +20,8 @@ export function prerequisiteIssuesForStep( return []; case 'structure-recognition': return validateComponentRecognitionPrerequisites(state); - case 'visual-binding': - return validateAssetRecognitionPrerequisites(state); + case 'asset-separation': + return validateAssetSeparationPrerequisites(state); } } @@ -35,8 +34,8 @@ export function postCheckIssuesForStep( return validateReferenceAnalysisResult(state); case 'structure-recognition': return validateStructureRecognitionResult(state); - case 'visual-binding': - return validateVisualBindingResult(state); + case 'asset-separation': + return validateAssetSeparationResult(state); } } @@ -46,7 +45,7 @@ export function postCheckIssuesForSave( return [ ...validateReferenceAnalysisResult(state), ...validateStructureRecognitionResult(state), - ...validateVisualBindingResult(state), + ...validateAssetSeparationResult(state), ]; } @@ -58,8 +57,8 @@ export function activeStepPrerequisiteIssues( case 'reference-analysis': return validateComponentRecognitionPrerequisites(state); case 'structure-recognition': - return validateAssetRecognitionPrerequisites(state); - case 'visual-binding': - return validateLayoutReviewPrerequisites(state); + return validateAssetSeparationPrerequisites(state); + case 'asset-separation': + return []; } } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx index fbda3f214..c96e8d670 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx @@ -23,7 +23,7 @@ export function WorkflowCompletionModal({ {stepLabel} {outcomeLabel} -

+

{notice.message}

diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index c923ca875..26203c35a 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx @@ -1,18 +1,20 @@ import { CANVAS_ZOOM_IN_FACTOR, CANVAS_ZOOM_OUT_FACTOR, + canvasDisplayScaleToViewportScale, type CanvasViewport, createPanDragState, type DragState, fitViewportToBounds, + formatCanvasDisplayScalePercent, moveViewportFromPan, resolveViewportFromWheel, scaleViewportFromScreenPoint, + viewportScaleToCanvasDisplayScale, } from '@genarrative/image-canvas-core'; import { CanvasViewport as SharedCanvasViewport, CanvasWorld, - ZoomControls, } from '@genarrative/image-canvas-react'; import { Image as ImageIcon, Minus, Plus } from 'lucide-react'; import { @@ -37,7 +39,6 @@ import { } from './previewZoomKeyboard'; import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer'; import { useNodeTransformInteraction } from './useNodeTransformInteraction'; -import { ZoomPercentageInput } from './ZoomPercentageInput'; export function PreviewWorkspace({ canvas, @@ -55,6 +56,7 @@ export function PreviewWorkspace({ viewportRef.current, ); const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 }); + const canvasSizeRef = useRef(canvasSize); const [spaceHeld, setSpaceHeld] = useState(false); const [renderMode, setRenderMode] = useState('editor-overlay'); @@ -143,10 +145,6 @@ export function PreviewWorkspace({ const fitToCanvas = useCallback(() => { if (!logicalSize) return; const element = viewportElementRef.current; - const size = { - width: element?.clientWidth || 900, - height: element?.clientHeight || 640, - }; setViewport( fitViewportToBounds({ bounds: { @@ -155,11 +153,19 @@ export function PreviewWorkspace({ width: logicalSize.width, height: logicalSize.height, }, - canvasSize: size, + canvasSize: { + width: element?.clientWidth || canvasSizeRef.current.width, + height: element?.clientHeight || canvasSizeRef.current.height, + }, }), ); }, [logicalSize, setViewport]); + useEffect(() => { + if (!activeImageId || !logicalSize) return; + fitToCanvas(); + }, [activeImageId, fitToCanvas, logicalSize]); + const scaleViewportFromCenter = useCallback( (nextScale: number) => { const element = viewportElementRef.current; @@ -188,14 +194,25 @@ export function PreviewWorkspace({ scaleViewportFromCenter(viewportRef.current.scale * CANVAS_ZOOM_OUT_FACTOR); }, [scaleViewportFromCenter]); + const displayPercent = formatCanvasDisplayScalePercent(viewport.scale); + + const zoomToDisplayScale = useCallback( + (displayScale: number) => { + scaleViewportFromCenter(canvasDisplayScaleToViewportScale(displayScale)); + }, + [scaleViewportFromCenter], + ); + useEffect(() => { const element = viewportElementRef.current; if (!element) return; const updateSize = () => { - setCanvasSize({ + const nextSize = { width: element.clientWidth || 900, height: element.clientHeight || 640, - }); + }; + canvasSizeRef.current = nextSize; + setCanvasSize(nextSize); }; updateSize(); const observer = new ResizeObserver(updateSize); @@ -203,12 +220,6 @@ export function PreviewWorkspace({ return () => observer.disconnect(); }, []); - useEffect(() => { - fitToCanvas(); - // This effect intentionally follows the active image, not every controller render. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeImageId, fitToCanvas]); - useEffect(() => { const request = canvas.focusRequest; if ( @@ -282,7 +293,6 @@ export function PreviewWorkspace({ usesMetaModifier, }, { - fit: fitToCanvas, resetToActualSize, zoomIn, zoomOut, @@ -301,7 +311,7 @@ export function PreviewWorkspace({ window.removeEventListener('keyup', onKeyUp); window.removeEventListener('blur', onWindowBlur); }; - }, [fitToCanvas, logicalSize, resetToActualSize, zoomIn, zoomOut]); + }, [logicalSize, resetToActualSize, zoomIn, zoomOut]); const handlePointerDown = (event: ReactPointerEvent) => { if (event.button === 0 && !isPreviewZoomInteractiveTarget(event.target)) { @@ -507,70 +517,56 @@ export function PreviewWorkspace({ onDelete={(nodeId) => canvas.deleteNode(nodeId)} /> ) : null} - { + if ( + event.target instanceof Element && + event.target.closest('button') + ) { + event.preventDefault(); + } + }} > - {(actions) => ( -
{ - if ( - event.target instanceof Element && - event.target.closest('button') - ) { - event.preventDefault(); - } - }} - > - - - actions.zoomToDisplayScale(Number(event.target.value) / 100) - } - /> - - actions.zoomToDisplayScale(percent / 100) - } - /> - - -
- )} -
+ + + zoomToDisplayScale(Number(event.target.value) / 100) + } + /> + + +
) : (
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx index b8873be1a..c4ca88a09 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx @@ -191,13 +191,9 @@ function RenderNode({ {node.metadata.name} ) : null} - {node.components.map((component, index) => ( - - ))} + {node.component ? ( + + ) : null} {renderMode === 'final-preview' && selectedNodeId === node.id ? ( void; -}) { - const [draft, setDraft] = useState(() => - displayPercentToValue(displayPercent), - ); - const draftRef = useRef(draft); - const isEditingRef = useRef(false); - - useEffect(() => { - if (!isEditingRef.current) { - setDraft(displayPercentToValue(displayPercent)); - draftRef.current = displayPercentToValue(displayPercent); - } - }, [displayPercent]); - - const commit = () => { - const currentPercent = Number.parseFloat( - displayPercentToValue(displayPercent), - ); - const parsed = Number.parseFloat(draftRef.current); - const nextPercent = Number.isFinite(parsed) - ? Math.min(MAX_ZOOM_PERCENT, Math.max(MIN_ZOOM_PERCENT, parsed)) - : currentPercent; - setDraft(String(nextPercent)); - draftRef.current = String(nextPercent); - isEditingRef.current = false; - if (nextPercent !== currentPercent) { - onCommit(nextPercent); - } - }; - - return ( - - ); -} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewZoomKeyboard.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewZoomKeyboard.ts index 85941ca1e..ce09b1495 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewZoomKeyboard.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewZoomKeyboard.ts @@ -1,8 +1,4 @@ -export type PreviewZoomShortcut = - | 'fit' - | 'actual-size' - | 'zoom-in' - | 'zoom-out'; +export type PreviewZoomShortcut = 'actual-size' | 'zoom-in' | 'zoom-out'; export type PreviewZoomKeyboardContext = { hasZoomableViewport: boolean; @@ -12,7 +8,6 @@ export type PreviewZoomKeyboardContext = { }; export type PreviewZoomKeyboardActions = { - fit: () => void; resetToActualSize: () => void; zoomIn: () => void; zoomOut: () => void; @@ -61,7 +56,6 @@ export function resolvePreviewZoomShortcut( event: KeyboardEvent, ): PreviewZoomShortcut | null { if (event.altKey) return null; - if (event.key === '0') return 'fit'; if (event.key === '1') return 'actual-size'; if (event.code === 'NumpadAdd' || event.key === '+' || event.key === '=') { return 'zoom-in'; @@ -92,8 +86,7 @@ export function handlePreviewZoomKeyDown( event.preventDefault(); event.stopPropagation(); - if (shortcut === 'fit') actions.fit(); - else if (shortcut === 'actual-size') actions.resetToActualSize(); + if (shortcut === 'actual-size') actions.resetToActualSize(); else if (shortcut === 'zoom-in') actions.zoomIn(); else actions.zoomOut(); return true; diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts index ff90b6dd5..02c5b317a 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts @@ -18,8 +18,8 @@ export function workflowStepLabel(step: UiEditorStepId): string { return '分析参考图'; case 'structure-recognition': return '识别界面结构'; - case 'visual-binding': - return '绑定视觉素材'; + case 'asset-separation': + return '自动切分素材'; } const exhaustiveCheck: never = step; return exhaustiveCheck; diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index e60169b1f..4696d76f9 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -7,13 +7,13 @@ import { type IUiDesignStateStore, uiDesignStateStore, } from '../../features/ui-editor/uiDesignStateStore'; -import { BindingOverview } from './components/BindingOverview'; import { EditorDialogs } from './components/EditorDialogs'; import { ImportOverview } from './components/ImportOverview'; import { InputSidebar } from './components/InputSidebar'; import { InspectorSidebar } from './components/Inspector/InspectorSidebar'; import { PreviewWorkspace } from './components/preview/PreviewWorkspace'; import { RecognitionOverview } from './components/RecognitionOverview'; +import { SeparationOverview } from './components/SeparationOverview'; import { ToolNavigation } from './components/ToolNavigation'; import { WorkflowActionCard } from './components/WorkflowActionCard'; import { WorkflowCompletionModal } from './components/WorkflowCompletionModal'; @@ -265,13 +265,14 @@ export default function UiEditorPage({ session.input.highlightStatusField(nodeId, 'layout_status'); }} /> - ) : session.input.activeStep === 'visual-binding' ? ( - { session.input.focusNode(treeId, nodeId); - session.input.highlightStatusField(nodeId, 'components_status'); + session.input.highlightStatusField(nodeId, 'component_status'); }} /> ) : ( diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/model.ts b/apps/ai-game-creator-shell/src/view/ui-editor/model.ts index 13ec54cc7..f03f6ef47 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/model.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/model.ts @@ -6,7 +6,7 @@ import type { RemovalImpact } from '../../features/ui-editor/useUiEditorState'; export type UiEditorStepId = | 'reference-analysis' | 'structure-recognition' - | 'visual-binding'; + | 'asset-separation'; export type UiEditorImportKind = 'design-image' | 'font' | 'sprite'; export type UiEditorNodeFocusRequest = { @@ -27,7 +27,7 @@ export const UI_EDITOR_STEPS: Array<{ }> = [ { id: 'reference-analysis', label: '分析参考图' }, { id: 'structure-recognition', label: '识别界面结构' }, - { id: 'visual-binding', label: '绑定视觉素材' }, + { id: 'asset-separation', label: '自动切分素材' }, ]; export const UI_DESIGN_IMAGE_ROLES: Array<{ diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index a580a652d..e2c690b06 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -2,7 +2,6 @@ import { invoke } from '@tauri-apps/api/core'; import { useCallback, useEffect, useMemo, useState } from 'react'; import type { ImportedAsset } from '../../components/AssetImporter'; -import { applyBindingResult } from '../../features/ui-editor/binding'; import { prepareDesignImageBatch, prepareFontAssetBatch, @@ -10,12 +9,15 @@ import { } from '../../features/ui-editor/importAdapter'; import { applyMergeResult } from '../../features/ui-editor/merge'; import { applyRecognitionResult } from '../../features/ui-editor/recognition'; +import { + applySeparationProblematicStatuses, + clearSeparationComponentStatus, +} from '../../features/ui-editor/separationStatus'; import { getStageStatusOverview, type StageStatusField, } from '../../features/ui-editor/stageStatusOverview'; import { collectUiNodeIds } from '../../features/ui-editor/treeUtils'; -import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO'; import type { ChildrenDisplayMode } from '../../features/ui-editor/types/ChildrenDisplayMode'; import type { Component } from '../../features/ui-editor/types/Component'; import type { FontAssetId } from '../../features/ui-editor/types/FontAssetId'; @@ -23,6 +25,8 @@ import type { MergeDTO } from '../../features/ui-editor/types/MergeDTO'; import type { Node as UiNode } from '../../features/ui-editor/types/Node'; import type { NodeId } from '../../features/ui-editor/types/NodeId'; import type { RecognitionDTO } from '../../features/ui-editor/types/RecognitionDTO'; +import type { SeparationDTO } from '../../features/ui-editor/types/SeparationDTO'; +import type { SeparationRecoveryDTO } from '../../features/ui-editor/types/SeparationRecoveryDTO'; import type { SpriteAssetId } from '../../features/ui-editor/types/SpriteAssetId'; import type { SpriteBorder } from '../../features/ui-editor/types/SpriteBorder'; import type { State } from '../../features/ui-editor/types/State'; @@ -37,6 +41,7 @@ import { } from '../../features/ui-editor/uiDesignStateStore'; import { applyUiDesignSuggestions } from '../../features/ui-editor/uiDesignSuggestions'; import { useUiEditorFontFaces } from '../../features/ui-editor/useUiEditorFontFaces'; +import { addSpriteAssetsToState } from '../../features/ui-editor/useUiEditorState'; import { EMPTY_UI_EDITOR_STATE, type NodeLayoutPatch, @@ -70,7 +75,15 @@ import { } from './model'; import { useUiEditorNodeFocus } from './useUiEditorNodeFocus'; -const ASSET_BATCH_SIZE = 5; +const SEPARATION_IMPORT_BATCH_SIZE = 100; + +type LocalImageImportResponse = { + assets: Array<{ id: string; localPath: string; assetKind?: string | null }>; +}; + +function normalizeProjectRelativePath(path: string): string { + return path.replaceAll('\\', '/').replace(/^\/+/, ''); +} type StatusFieldHighlight = { nodeId: NodeId; @@ -250,11 +263,13 @@ export function useUiEditorSession( ); const [isMerging, setIsMerging] = useState(false); const [mergeStatus, setMergeStatus] = useState(null); - const [isBinding, setIsBinding] = useState(false); - const [bindingStatus, setBindingStatus] = useState(null); + const [isSeparating, setIsSeparating] = useState(false); + const [separationStatus, setSeparationStatus] = useState(null); + const [separationRecovery, setSeparationRecovery] = + useState(null); const [hasSuggested, setHasSuggested] = useState(false); const [hasRecognized, setHasRecognized] = useState(false); - const [hasBound, setHasBound] = useState(false); + const [hasSeparated, setHasSeparated] = useState(false); const [completionNotice, setCompletionNotice] = useState(null); @@ -393,7 +408,8 @@ export function useUiEditorSession( image.metadata.role === 'Page' && !isSlaveToDescendant(images, id as UIDesignImageId, activeImageId), ); - const isAiRunning = isSuggesting || isRecognizing || isBinding || isMerging; + const isAiRunning = + isSuggesting || isRecognizing || isMerging || isSeparating; const isWorkflowBusy = isAiRunning || isSaving || isGenerating || isLoading || editor.isLocked; const stateSignature = JSON.stringify(editor.state); @@ -401,18 +417,18 @@ export function useUiEditorSession( resourceId !== undefined && savedStateSignature !== null && savedStateSignature !== stateSignature; - const nextStep: UiEditorStepId | null = - activeStep === 'reference-analysis' - ? 'structure-recognition' - : activeStep === 'structure-recognition' - ? 'visual-binding' - : null; + const nextStepByStep: Partial> = { + 'reference-analysis': 'structure-recognition', + 'structure-recognition': 'asset-separation', + }; + const nextStep = nextStepByStep[activeStep] ?? null; const spriteReferenceCounts = useMemo(() => { const counts: Record = {}; function visit(nodes: UiNode[]) { for (const node of nodes) { - for (const component of node.components) { + const component = node.component; + if (component) { if ('Image' in component && component.Image.target_graphic) { counts[component.Image.target_graphic] = (counts[component.Image.target_graphic] ?? 0) + 1; @@ -429,7 +445,8 @@ export function useUiEditorSession( const counts: Record = {}; function visit(nodes: UiNode[]) { for (const node of nodes) { - for (const component of node.components) { + const component = node.component; + if (component) { if ('Text' in component && typeof component.Text.font !== 'string') { const id = component.Text.font.Bound; counts[id] = (counts[id] ?? 0) + 1; @@ -787,7 +804,7 @@ export function useUiEditorSession( if ( result.ok && (patch.layout_status !== undefined || - patch.components_status !== undefined) + patch.component_status !== undefined) ) { setHighlightedStatusField(null); } @@ -826,45 +843,14 @@ export function useUiEditorSession( return result; } - function setNodeComponents(components: Component[]) { + function setNodeComponent(component: Component | null) { if (!activeImageId || !selectedNodeId) return; - const result = editor.setNodeComponents( + const result = editor.setNodeComponent( activeImageId, selectedNodeId, - components, - ); - if (!result.ok) setStatus('组件更新失败。'); - return result; - } - - function insertNodeComponent(index: number, component: Component) { - if (!activeImageId || !selectedNodeId) return; - const result = editor.insertComponent( - activeImageId, - selectedNodeId, - index, component, ); - if (!result.ok) setStatus('组件新增失败。'); - return result; - } - - function deleteNodeComponent(index: number) { - if (!activeImageId || !selectedNodeId) return; - const result = editor.deleteComponent(activeImageId, selectedNodeId, index); - if (!result.ok) setStatus('组件删除失败。'); - return result; - } - - function moveNodeComponent(fromIndex: number, toIndex: number) { - if (!activeImageId || !selectedNodeId) return; - const result = editor.moveComponent( - activeImageId, - selectedNodeId, - fromIndex, - toIndex, - ); - if (!result.ok) setStatus('组件顺序更新失败。'); + if (!result.ok) setStatus('组件更新失败。'); return result; } @@ -1061,63 +1047,325 @@ export function useUiEditorSession( } } - async function bindComponents() { - if (isBinding || isWorkflowBusy) return; + async function runSeparationWorkflow() { + if (!resourceId || isWorkflowBusy) return; + setIsSeparating(true); + setSeparationStatus(null); setCompletionNotice(null); - setBindingStatus(null); - setIsBinding(true); + let preparedSprites: Awaited> = + []; try { + let backfillErrors: string[] = []; + let separationResult: SeparationDTO | null = null; await editor.runWithStateLocked(async (snapshot) => { - const allSpriteIds = Object.keys(snapshot.sprite_assets); - const batches: string[][] = []; + const result = await invoke('separate_ui', { + projectPath, + assetId: resourceId, + state: snapshot, + }); + separationResult = result; + + const uniquePaths = [ + ...new Set( + result.bound_nodes.map((bound) => + normalizeProjectRelativePath(bound.cut_image_path), + ), + ), + ]; + const importedByPath = new Map< + string, + { id: string; localPath: string; assetKind: string | null } + >(); for ( let index = 0; - index < allSpriteIds.length; - index += ASSET_BATCH_SIZE + index < uniquePaths.length; + index += SEPARATION_IMPORT_BATCH_SIZE ) { - batches.push(allSpriteIds.slice(index, index + ASSET_BATCH_SIZE)); + const relativePaths = uniquePaths.slice( + index, + index + SEPARATION_IMPORT_BATCH_SIZE, + ); + const imported = await invoke( + 'import_local_project_image_assets', + { projectPath, relativePaths }, + ); + if (imported.assets.length !== relativePaths.length) { + throw new Error( + `本地资源登记结果数量不匹配:请求 ${relativePaths.length} 个,返回 ${imported.assets.length} 个`, + ); + } + for (const [assetIndex, asset] of imported.assets.entries()) { + const normalizedAsset = { + id: asset.id, + localPath: normalizeProjectRelativePath(asset.localPath), + assetKind: asset.assetKind ?? null, + }; + // The importer may copy a sidecar file into assets/uploads and + // therefore return a different localPath. Keep both identities: + // the cut path is the separation contract, while the returned + // path is the SpriteAsset resource path. + importedByPath.set(normalizedAsset.localPath, normalizedAsset); + const requestedPath = relativePaths[assetIndex]; + if (requestedPath) { + importedByPath.set( + normalizeProjectRelativePath(requestedPath), + normalizedAsset, + ); + } + } } - if (batches.length === 0) batches.push([]); - let current = snapshot; - for (const [index, spriteIds] of batches.entries()) { - setBindingStatus(`绑定组件中(${index + 1}/${batches.length})…`); - const result = await invoke('bind_components', { - projectPath, - state: current, - spriteIds, - }); - current = applyBindingResult(current, result); - editor.replaceState(current, { - history: index < batches.length - 1 ? 'skip' : 'record', - }); - } - reportWorkflowCompletion( - 'visual-binding', - 'success', - `视觉素材绑定完成:已处理 ${batches.length}/${batches.length} 个批次`, - setBindingStatus, + + const missingImports = uniquePaths.filter( + (path) => !importedByPath.has(path), ); - setHasBound(true); + backfillErrors = missingImports.map( + (path) => `未能登记自动切分素材图片:${path}`, + ); + const importedAssets: ImportedAsset[] = [ + ...new Map( + [...importedByPath.values()].map((asset) => [asset.id, asset]), + ).values(), + ]; + preparedSprites = await prepareSpriteAssetBatch( + projectPath, + importedAssets, + ); + const spriteById = new Map( + preparedSprites.map((item) => [ + item.resource.asset_id, + item.resource, + ]), + ); + const spriteByPath = new Map( + [...importedByPath.entries()].flatMap(([path, asset]) => { + const sprite = spriteById.get(asset.id); + return sprite ? [[path, sprite] as const] : []; + }), + ); + const added = addSpriteAssetsToState( + snapshot, + preparedSprites.map((item) => item.resource), + ); + if (!added.ok) { + throw new Error(uiEditorOperationError(added.reason)); + } + const next = added.value; + for (const bound of result.bound_nodes) { + const path = normalizeProjectRelativePath(bound.cut_image_path); + const sprite = spriteByPath.get(path); + if (!sprite) { + backfillErrors.push( + `节点 ${bound.node_id} 缺少已登记的自动切分素材图片:${path}`, + ); + continue; + } + const location = next.ui_trees + .map((tree) => findUiNodeLocation(tree.root, bound.node_id)) + .find((candidate) => candidate !== null); + if (!location) { + backfillErrors.push(`节点 ${bound.node_id} 已不存在,素材已保留`); + continue; + } + const imageComponent = location.node.component; + if (!imageComponent || !('Image' in imageComponent)) { + backfillErrors.push( + `节点 ${bound.node_id} 没有可回填的 Image 组件,素材已保留`, + ); + continue; + } + if (imageComponent.Image.target_graphic === sprite.asset_id) { + clearSeparationComponentStatus(location.node); + continue; + } + if (imageComponent.Image.target_graphic !== null) { + backfillErrors.push( + `节点 ${bound.node_id} 已绑定其他素材,自动切分素材已保留`, + ); + continue; + } + imageComponent.Image.target_graphic = sprite.asset_id; + clearSeparationComponentStatus(location.node); + } + backfillErrors.push( + ...applySeparationProblematicStatuses( + next.ui_trees, + result.problematic_nodes, + ), + ); + editor.replaceState(next); }); + + setPreviewUrls((current) => ({ + ...current, + ...Object.fromEntries( + preparedSprites.map((item) => [ + item.resource.asset_id, + item.previewUrl, + ]), + ), + })); + if (separationResult === null) + throw new Error('自动切分素材没有返回结果'); + const completedResult = separationResult as SeparationDTO; + if (!(await save({ allowDuringSeparation: true }))) { + throw new Error( + '自动切分素材结果已写入编辑器,但 State 保存失败;sidecar 已保留,可继续恢复。', + ); + } + if (backfillErrors.length > 0) { + const recovery = await invoke( + 'inspect_separation_recovery', + { projectPath, assetId: resourceId }, + ); + setSeparationRecovery(recovery); + reportWorkflowCompletion( + 'asset-separation', + 'failure', + `自动切分素材已完成,但有 ${backfillErrors.length} 项未能回填;已登记素材并保留恢复状态。\n${backfillErrors.join('\n')}`, + setSeparationStatus, + ); + return; + } + await invoke('finalize_separation', { + projectPath, + assetId: resourceId, + }); + setHasSeparated(true); + reportWorkflowCompletion( + 'asset-separation', + 'success', + `自动切分素材完成:${completedResult.bound_nodes.length} 个已切分并回填,${completedResult.problematic_nodes.length} 个待处理。`, + setSeparationStatus, + ); } catch (cause) { reportWorkflowCompletion( - 'visual-binding', + 'asset-separation', 'failure', cause instanceof Error ? cause.message : String(cause), - setBindingStatus, + setSeparationStatus, ); } finally { - setIsBinding(false); + setIsSeparating(false); } } - async function save() { + async function separateUi() { + if ( + isSeparating || + isWorkflowBusy || + separationRecovery !== null || + !resourceId + ) { + return; + } + try { + const recovery = await invoke( + 'inspect_separation_recovery', + { projectPath, assetId: resourceId }, + ); + if (recovery.exists) { + setSeparationRecovery(recovery); + return; + } + const prerequisiteIssues = prerequisiteIssuesForStep( + editor.state, + 'asset-separation', + ); + if (prerequisiteIssues.length > 0) { + reportWorkflowCompletion( + 'asset-separation', + 'failure', + prerequisiteIssues.map((issue) => issue.message).join(';'), + setSeparationStatus, + ); + return; + } + await runSeparationWorkflow(); + } catch (cause) { + reportWorkflowCompletion( + 'asset-separation', + 'failure', + cause instanceof Error ? cause.message : String(cause), + setSeparationStatus, + ); + } + } + + async function continueSeparation() { + if (!resourceId || isSeparating || isWorkflowBusy) return; + setSeparationRecovery(null); + try { + const recovery = await invoke( + 'inspect_separation_recovery', + { projectPath, assetId: resourceId }, + ); + if (!recovery.exists) { + throw new Error('自动切分恢复状态不存在,请重新开始。'); + } + const prerequisiteIssues = prerequisiteIssuesForStep( + editor.state, + 'asset-separation', + ); + if (prerequisiteIssues.length > 0) { + reportWorkflowCompletion( + 'asset-separation', + 'failure', + prerequisiteIssues.map((issue) => issue.message).join(';'), + setSeparationStatus, + ); + return; + } + await runSeparationWorkflow(); + } catch (cause) { + reportWorkflowCompletion( + 'asset-separation', + 'failure', + cause instanceof Error ? cause.message : String(cause), + setSeparationStatus, + ); + } + } + + async function restartSeparation() { + if (!resourceId || isSeparating || isWorkflowBusy) return; + setSeparationRecovery(null); + const prerequisiteIssues = prerequisiteIssuesForStep( + editor.state, + 'asset-separation', + ); + if (prerequisiteIssues.length > 0) { + reportWorkflowCompletion( + 'asset-separation', + 'failure', + prerequisiteIssues.map((issue) => issue.message).join(';'), + setSeparationStatus, + ); + return; + } + try { + await invoke('discard_separation_recovery', { + projectPath, + assetId: resourceId, + }); + await runSeparationWorkflow(); + } catch (cause) { + reportWorkflowCompletion( + 'asset-separation', + 'failure', + cause instanceof Error ? cause.message : String(cause), + setSeparationStatus, + ); + } + } + + async function save(options?: { allowDuringSeparation?: boolean }) { + const allowDuringSeparation = options?.allowDuringSeparation === true; if ( !resourceId || isSaving || isGenerating || isLoading || - isAiRunning || + (isAiRunning && !allowDuringSeparation) || loadError || persistedRevision === null || editor.isLocked @@ -1140,9 +1388,7 @@ export function useUiEditorSession( return false; } setPersistedRevision(result.revision); - if (JSON.stringify(editor.state) === snapshotSignature) { - setSavedStateSignature(snapshotSignature); - } + setSavedStateSignature(snapshotSignature); return true; }); } catch { @@ -1211,9 +1457,7 @@ export function useUiEditorSession( return null; } setPersistedRevision(saved.revision); - if (JSON.stringify(editor.state) === snapshotSignature) { - setSavedStateSignature(snapshotSignature); - } + setSavedStateSignature(snapshotSignature); return await stateStore.generateCode(resourceId); }); } catch (cause) { @@ -1244,17 +1488,15 @@ export function useUiEditorSession( selectedNodeId, focusRequest, operations: { - isBinding, isMerging, isRecognizing, isSuggesting, - bindingStatus, mergeStatus, recognitionStatus, suggestionStatus, }, checkPrerequisites, - bindComponents, + separateUi, mergeUi, recognizeUi, suggestUiDesignSemantics, @@ -1336,10 +1578,7 @@ export function useUiEditorSession( setNodeMetadata, setNodeTransform, setNodeLayout, - setNodeComponents, - insertNodeComponent, - deleteNodeComponent, - moveNodeComponent, + setNodeComponent, deleteNode, setSpriteName, setSpriteAssetType, @@ -1367,11 +1606,15 @@ export function useUiEditorSession( hasRecognized, recognitionStatus, recognizeUi, - isBinding, - hasBound, - bindingStatus, + hasSeparated, completionNotice, - bindComponents, + separateUi, + isSeparating, + separationStatus, + separationRecovery, + continueSeparation, + restartSeparation, + cancelSeparationRecovery: () => setSeparationRecovery(null), requestStepChange, continueToNextStep: () => { if (nextStep) requestStepChange(nextStep); diff --git a/apps/ai-game-creator-shell/tests/ZoomPercentageInput.test.tsx b/apps/ai-game-creator-shell/tests/ZoomPercentageInput.test.tsx deleted file mode 100644 index f02a71e3d..000000000 --- a/apps/ai-game-creator-shell/tests/ZoomPercentageInput.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -// @vitest-environment jsdom - -import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - -import { ZoomPercentageInput } from '../src/view/ui-editor/components/preview/ZoomPercentageInput'; - -describe('ZoomPercentageInput', () => { - it('edits the displayed percentage and commits on blur without fitting', () => { - const onCommit = vi.fn(); - render(); - - const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); - expect((input as HTMLInputElement).value).toBe('50'); - - fireEvent.focus(input); - fireEvent.change(input, { target: { value: '125' } }); - expect(onCommit).not.toHaveBeenCalled(); - fireEvent.blur(input); - - expect(onCommit).toHaveBeenCalledWith(125); - expect((input as HTMLInputElement).value).toBe('125'); - }); - - it.each([ - { value: '0', expected: 25 }, - { value: '999', expected: 200 }, - ])('clamps $value to $expected on blur', ({ value, expected }) => { - const onCommit = vi.fn(); - render(); - - const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); - fireEvent.focus(input); - fireEvent.change(input, { target: { value } }); - fireEvent.blur(input); - - expect(onCommit).toHaveBeenCalledWith(expected); - expect((input as HTMLInputElement).value).toBe(String(expected)); - }); - - it('restores the current percentage when the draft is invalid', () => { - const onCommit = vi.fn(); - render(); - - const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); - fireEvent.focus(input); - fireEvent.change(input, { target: { value: '' } }); - fireEvent.blur(input); - - expect(onCommit).not.toHaveBeenCalled(); - expect((input as HTMLInputElement).value).toBe('80'); - }); - - it('tracks viewport updates while not editing', () => { - const onCommit = vi.fn(); - const view = render( - , - ); - const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); - - view.rerender( - , - ); - - expect((input as HTMLInputElement).value).toBe('140'); - }); -}); diff --git a/apps/ai-game-creator-shell/tests/nodeTransformGeometry.test.ts b/apps/ai-game-creator-shell/tests/nodeTransformGeometry.test.ts index cb414da56..e0ad8356b 100644 --- a/apps/ai-game-creator-shell/tests/nodeTransformGeometry.test.ts +++ b/apps/ai-game-creator-shell/tests/nodeTransformGeometry.test.ts @@ -23,7 +23,7 @@ function node(id: string, transform: Transform, children: Node[] = []): Node { id, layout: { transform } as Node['layout'], metadata: {} as Node['metadata'], - components: [], + component: null, children_display_mode: 'Stack', children, }; diff --git a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx index 50a89c1e2..92abe5778 100644 --- a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx +++ b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx @@ -33,12 +33,12 @@ const root: UiNode = { name: '根节点', description: '', layout_status: 'NoProblem', - components_status: 'NoProblem', + component_status: 'NoProblem', allow_llm_edit_layout: true, allow_llm_edit_component: true, source: 'System', }, - components: [], + component: null, children_display_mode: 'Stack', children: [], }; @@ -166,10 +166,9 @@ describe('PreviewWorkspace quick zoom', () => { ); }); - it('keeps actual-size and fit shortcuts within the preview scope', () => { + it('keeps the actual-size shortcut within the preview scope', () => { const rendered = render(); const preview = screen.getByRole('region', { name: 'UI 预览画布' }); - const fitted = logicalViewportScale(rendered.container); fireEvent.focus(preview); fireEvent.keyDown(window, { @@ -178,13 +177,21 @@ describe('PreviewWorkspace quick zoom', () => { cancelable: true, }); expect(logicalViewportScale(rendered.container)).toBe(1); + }); - fireEvent.keyDown(window, { - key: '0', - ctrlKey: true, - cancelable: true, - }); - expect(logicalViewportScale(rendered.container)).toBe(fitted); + it('refits the preview when the zoom percentage button is clicked', () => { + const rendered = render(); + const fitButton = screen.getByRole('button', { name: '适配画布' }); + const zoomInButton = screen.getByRole('button', { name: '放大画布' }); + const initial = logicalViewportScale(rendered.container); + + fireEvent.click(zoomInButton); + expect(logicalViewportScale(rendered.container)).toBeCloseTo( + initial * 1.16, + ); + + fireEvent.click(fitButton); + expect(logicalViewportScale(rendered.container)).toBeCloseTo(initial); }); it('leaves browser zoom untouched when the preview has no content', () => { diff --git a/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts b/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts index 5ef72b7db..519c3247f 100644 --- a/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts +++ b/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts @@ -12,7 +12,6 @@ import { function createActions(): PreviewZoomKeyboardActions { return { - fit: vi.fn(), resetToActualSize: vi.fn(), zoomIn: vi.fn(), zoomOut: vi.fn(), @@ -96,15 +95,12 @@ describe('preview zoom keyboard shortcuts', () => { expect(keyboardEvent?.defaultPrevented).toBe(false); }); - it.each([ - { key: '0', action: 'fit' as const }, - { key: '1', action: 'resetToActualSize' as const }, - ])('keeps the existing $key shortcut', ({ key, action }) => { + it('keeps the existing actual-size shortcut', () => { const { actions } = dispatchShortcut({ - event: { key, ctrlKey: true, cancelable: true }, + event: { key: '1', ctrlKey: true, cancelable: true }, }); - expect(actions[action]).toHaveBeenCalledTimes(1); + expect(actions.resetToActualSize).toHaveBeenCalledTimes(1); }); it('uses Cmd on Apple platforms and Ctrl elsewhere', () => { diff --git a/apps/ai-game-creator-shell/tests/projectAssetPickerDialogShellStyle.test.tsx b/apps/ai-game-creator-shell/tests/projectAssetPickerDialogShellStyle.test.tsx index c5d833cf5..6c37ac024 100644 --- a/apps/ai-game-creator-shell/tests/projectAssetPickerDialogShellStyle.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectAssetPickerDialogShellStyle.test.tsx @@ -99,11 +99,11 @@ function resolveStyleSpecifier(specifier: string, fromFile: string): string { /** 从 AGC 入口(`src/main.tsx`)出发,按 import / @import 关系收集它加载的样式表。 */ function collectAgcLoadedStyleSheets(): string[] { const entryFile = resolve(AGC_ROOT, 'src/main.tsx'); - const pending = [...readFileSync(entryFile, 'utf8').matchAll( - /import\s+'(?[^']+\.css)'/gu, - )].map((match) => - resolveStyleSpecifier(match.groups!.specifier!, entryFile), - ); + const pending = [ + ...readFileSync(entryFile, 'utf8').matchAll( + /import\s+'(?[^']+\.css)'/gu, + ), + ].map((match) => resolveStyleSpecifier(match.groups!.specifier!, entryFile)); const loaded: string[] = []; while (pending.length > 0) { @@ -160,7 +160,10 @@ function parseDeclarations(body: string): Map { continue; } const property = chunk.slice(0, separator).trim(); - const value = chunk.slice(separator + 1).trim().replace(/\s+/gu, ' '); + const value = chunk + .slice(separator + 1) + .trim() + .replace(/\s+/gu, ' '); if (property) { declarations.set(property, value); } @@ -229,8 +232,9 @@ function readRules(file: string): CssRule[] { /** 找"某个类自己就是一条独立选择器"的规则(不带后代/伪类限定)。 */ function findClassRule(rules: CssRule[], className: string): CssRule | null { return ( - rules.find((rule) => splitSelectorList(rule.selector).includes(className)) ?? - null + rules.find((rule) => + splitSelectorList(rule.selector).includes(className), + ) ?? null ); } @@ -254,9 +258,7 @@ describe('「选择替换素材」弹窗在 AGC 的面板底色', () => { it('AGC 真的加载到了共享样式表与主题表(清单本身可信)', () => { expect(loadedLabels).toContain(SHARED_STYLE_SHEET); expect(loadedLabels).toContain('packages/shared/src/theme.css'); - expect(loadedLabels).toContain( - 'apps/ai-game-creator-shell/src/styles.css', - ); + expect(loadedLabels).toContain('apps/ai-game-creator-shell/src/styles.css'); // 整站样式表不在 AGC 的加载清单里——这正是这个类原先"有类名没样式"的原因。 expect(loadedLabels).not.toContain('src/index.css'); // 反向对照:清单里确实有别的共享类规则,说明解析没有落空。 diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts b/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts index 88e13dfaa..034388a99 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts @@ -169,7 +169,9 @@ describe('project resource live update model', () => { it('still fails closed when the protected assets change under the same revision', () => { // 对照钉子:判据面收窄到「资源 + 版本」不等于放过真正的同版本号内容漂移。 - const held = createProjectManifestMergeState(snapshot(4, 'initial', ['art'])); + const held = createProjectManifestMergeState( + snapshot(4, 'initial', ['art']), + ); const divergent = snapshot(4, 'supervisor', ['art', 'smuggled-art']); expect(mergeProjectManifestSnapshot(held, divergent).decision).toBe( 'revision-conflict', diff --git a/apps/ai-game-creator-shell/tests/resourceBookController.test.ts b/apps/ai-game-creator-shell/tests/resourceBookController.test.ts index adc8d6366..6c467d2c5 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookController.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookController.test.ts @@ -373,12 +373,12 @@ describe('resource book pile anchors', () => { now.mockReturnValue(1_300); const visual: MotionRect = { left: 650, top: 470, width: 140, height: 100 }; const clean: MotionRect = { left: 720, top: 520, width: 220, height: 160 }; - vi.mocked(boxes.get('art-4')!.getBoundingClientRect).mockImplementation(() => - toDomRect(visual), + vi.mocked(boxes.get('art-4')!.getBoundingClientRect).mockImplementation( + () => toDomRect(visual), ); flying.cancel.mockImplementation(() => { - vi.mocked(boxes.get('art-4')!.getBoundingClientRect).mockImplementation(() => - toDomRect(clean), + vi.mocked(boxes.get('art-4')!.getBoundingClientRect).mockImplementation( + () => toDomRect(clean), ); flying.reject(); }); diff --git a/apps/ai-game-creator-shell/tests/bindingOverview.test.ts b/apps/ai-game-creator-shell/tests/separationOverview.test.ts similarity index 55% rename from apps/ai-game-creator-shell/tests/bindingOverview.test.ts rename to apps/ai-game-creator-shell/tests/separationOverview.test.ts index 314d95655..4761832ad 100644 --- a/apps/ai-game-creator-shell/tests/bindingOverview.test.ts +++ b/apps/ai-game-creator-shell/tests/separationOverview.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from 'vitest'; import { - getBindingOverview, + getSeparationOverview, nodeHasBlockedComponents, - nodeHasPendingBinding, + nodeHasPendingSeparation, nodeNeedsComponentReview, -} from '../src/features/ui-editor/bindingOverview'; +} from '../src/features/ui-editor/separationOverview'; +import { + applySeparationProblematicStatuses, + clearSeparationComponentStatus, +} from '../src/features/ui-editor/separationStatus'; import { getNextMatchingUiTreeNodeTarget } from '../src/features/ui-editor/stageStatusOverview'; import type { Component } from '../src/features/ui-editor/types/Component'; import type { Node } from '../src/features/ui-editor/types/Node'; @@ -39,8 +43,8 @@ function text(font: 'SystemFont' | { Bound: string }): Component { function node( id: string, - components: Component[], - componentsStatus: Node['metadata']['components_status'] = 'NoProblem', + component: Component | null, + componentStatus: Node['metadata']['component_status'] = 'NoProblem', children: Node[] = [], ): Node { return { @@ -62,12 +66,12 @@ function node( name: id, description: '', layout_status: 'NoProblem', - components_status: componentsStatus, + component_status: componentStatus, allow_llm_edit_layout: true, allow_llm_edit_component: true, source: 'System', }, - components, + component, children, }; } @@ -90,24 +94,19 @@ const sprites = { const trees: UITree[] = [ { src_ui_design: 'page-a', - root: node( - 'root', - [image('validSprite'), text({ Bound: 'font-id' })], - 'NoProblem', - [ - node('review', [image(null)], { NeedReview: '确认素材' }), - node('blocked', [text('SystemFont')], { Blocked: '素材绑定失败' }), - ], - ), + root: node('root', image('validSprite'), 'NoProblem', [ + node('review', image(null), { NeedReview: '确认素材' }), + node('blocked', text('SystemFont'), { Blocked: '素材绑定失败' }), + ]), }, ]; -describe('getBindingOverview', () => { +describe('getSeparationOverview', () => { it('uses per-component helpers to include both image and text slots', () => { - expect(getBindingOverview(trees, sprites)).toEqual({ - componentsNeedingAssets: 3, - assetSlots: 3, - boundSlots: 2, + expect(getSeparationOverview(trees, sprites)).toEqual({ + componentsNeedingAssets: 2, + assetSlots: 2, + boundSlots: 1, pendingSlots: 1, needsAttention: 2, blocked: 1, @@ -115,15 +114,15 @@ describe('getBindingOverview', () => { }); }); - it('reuses the common preorder next-target search for every binding queue', () => { + it('reuses the common preorder next-target search for every separation queue', () => { expect( getNextMatchingUiTreeNodeTarget(trees, null, (target) => - nodeHasPendingBinding(target), + nodeHasPendingSeparation(target), )?.node.id, ).toBe('review'); expect( getNextMatchingUiTreeNodeTarget(trees, 'review', (target) => - nodeHasPendingBinding(target), + nodeHasPendingSeparation(target), )?.node.id, ).toBe('review'); expect( @@ -136,3 +135,41 @@ describe('getBindingOverview', () => { ).toBe('blocked'); }); }); + +describe('separation status writeback', () => { + it('writes problematic history into NeedReview and overwrites Blocked', () => { + const problematicTree: UITree[] = [ + { + src_ui_design: 'page-a', + root: node('root', null, 'NoProblem', [ + node('problematic', image(null), { Blocked: '旧阻塞' }), + ]), + }, + ]; + expect( + applySeparationProblematicStatuses(problematicTree, [ + { + node_id: 'problematic', + problem_description: '当前建议', + problem_history: ['第一次建议', '最后建议'], + rework_count: 3, + }, + ]), + ).toEqual([]); + expect( + problematicTree[0].root.children[0]?.metadata.component_status, + ).toEqual({ + NeedReview: '自动切分重试已达上限(3 次)\n第一次建议\n最后建议', + }); + expect(getSeparationOverview(problematicTree, {})).toMatchObject({ + needsAttention: 1, + blocked: 0, + }); + }); + + it('clears the old status after a successful bound writeback', () => { + const boundNode = node('bound', image(null), { Blocked: '旧阻塞' }); + clearSeparationComponentStatus(boundNode); + expect(boundNode.metadata.component_status).toBe('NoProblem'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts b/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts index 9c4122367..8e7389b02 100644 --- a/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts +++ b/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts @@ -32,12 +32,12 @@ function node(id: string, status: StageStatus, children: Node[] = []): Node { name: id, description: '', layout_status: status, - components_status: 'NoProblem', + component_status: 'NoProblem', allow_llm_edit_layout: true, allow_llm_edit_component: true, source: 'System', }, - components: [], + component: null, children_display_mode: 'Stack', children, }; diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index 2c4206167..f9615a790 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -72,12 +72,12 @@ function node(id: string, children: UiNode[] = []): UiNode { name: id, description: '', layout_status: 'NoProblem', - components_status: 'NoProblem', + component_status: 'NoProblem', allow_llm_edit_layout: true, allow_llm_edit_component: true, source: 'System', }, - components: [], + component: null, children_display_mode: undefined, children, }; @@ -347,12 +347,14 @@ describe('UiEditorPage', () => { fireEvent.click(screen.getByRole('button', { name: '仍然继续' })); expect(screen.getByRole('heading', { name: '识别概览' })).toBeTruthy(); - fireEvent.click(screen.getByRole('button', { name: /绑定视觉素材/ })); + fireEvent.click(screen.getByRole('button', { name: /自动切分素材/ })); fireEvent.click(screen.getByRole('button', { name: '仍然继续' })); - expect(screen.getByRole('heading', { name: '绑定概览' })).toBeTruthy(); + expect( + screen.getByRole('heading', { name: '自动切分素材概览' }), + ).toBeTruthy(); }); - it('opens a completed workflow directly at the visual binding review stage', async () => { + it('opens a completed workflow directly at the asset separation review stage', async () => { const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue({ revision: 3, @@ -367,25 +369,25 @@ describe('UiEditorPage', () => { projectPath: '/tmp/ui-editor-final-review', resourceId: 'ui-resource', stateStore, - initialStep: 'visual-binding', + initialStep: 'asset-separation', initialFurthestStepIndex: 2, }), ); expect( - await screen.findByRole('heading', { name: '绑定概览' }), + await screen.findByRole('heading', { name: '自动切分素材概览' }), ).toBeTruthy(); expect( screen .getByRole('navigation', { name: 'UI 编辑流程' }) .querySelector('button[aria-current="step"]')?.textContent, - ).toContain('绑定视觉素材'); + ).toContain('自动切分素材'); }); it('keeps the pending binding count informational instead of navigable', () => { render(createElement(UiEditorPage, { projectPath: '/tmp/ui-editor' })); - fireEvent.click(screen.getByRole('button', { name: /绑定视觉素材/ })); + fireEvent.click(screen.getByRole('button', { name: /自动切分素材/ })); fireEvent.click(screen.getByRole('button', { name: '仍然继续' })); expect( @@ -396,7 +398,7 @@ describe('UiEditorPage', () => { it('switches tools freely without inventing completed workflow state', () => { render(createElement(UiEditorPage, { projectPath: '/tmp/ui-editor' })); - fireEvent.click(screen.getByRole('button', { name: /绑定视觉素材/ })); + fireEvent.click(screen.getByRole('button', { name: /自动切分素材/ })); expect(screen.getByRole('heading', { name: '检查发现问题' })).toBeTruthy(); }); @@ -572,10 +574,10 @@ describe('UiEditorPage', () => { act(() => { result.current.input.highlightStatusField( otherNodeId!, - 'components_status', + 'component_status', ); result.current.inspector.setNodeMetadata({ - components_status: 'NoProblem', + component_status: 'NoProblem', }); }); expect(result.current.inspector.highlightedStatusField).toBeNull(); diff --git a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx index 6f79be210..487ea4fa4 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx +++ b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx @@ -33,12 +33,12 @@ function node(id: string, children: UiNode[] = []): UiNode { name: id, description: '', layout_status: 'NoProblem', - components_status: 'NoProblem', + component_status: 'NoProblem', allow_llm_edit_layout: true, allow_llm_edit_component: true, source: 'System', }, - components: [], + component: null, children_display_mode: 'Stack', children, }; diff --git a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts index 89c4cdf3e..248ea7335 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts @@ -70,19 +70,17 @@ function nodeWithSprite(id: string): Node { name: 'Image', description: '', layout_status: 'NoProblem', - components_status: 'NoProblem', + component_status: 'NoProblem', allow_llm_edit_layout: true, allow_llm_edit_component: true, source: 'Llm', }, - components: [ - { - Image: { - target_graphic: id, - image_type: { Simple: { preserve_aspect: false } }, - }, + component: { + Image: { + target_graphic: id, + image_type: { Simple: { preserve_aspect: false } }, }, - ], + }, children: [], }; } @@ -107,12 +105,12 @@ function pageRoot(id: string, children: Node[] = []): Node { name: id, description: '', layout_status: 'NoProblem', - components_status: 'NoProblem', + component_status: 'NoProblem', allow_llm_edit_layout: true, allow_llm_edit_component: true, source: 'System', }, - components: [], + component: null, children_display_mode: 'Stack', children, }; @@ -251,7 +249,7 @@ describe('useUiEditorState', () => { root: expect.objectContaining({ metadata: expect.objectContaining({ layout_status: 'NoProblem', - components_status: 'NoProblem', + component_status: 'NoProblem', }), }), }), @@ -280,7 +278,7 @@ describe('useUiEditorState', () => { result.current.state.ui_trees[0]!.root.children[0]?.metadata, ).toMatchObject({ layout_status: 'NoProblem', - components_status: 'NoProblem', + component_status: 'NoProblem', }); }); @@ -428,12 +426,12 @@ describe('useUiEditorState', () => { name: '页面根节点', description: '', layout_status: 'NoProblem', - components_status: 'NoProblem', + component_status: 'NoProblem', allow_llm_edit_layout: true, allow_llm_edit_component: true, source: 'System', }, - components: [], + component: null, children_display_mode: 'Stack', children: [nodeWithSprite('panel')], }, @@ -521,21 +519,19 @@ describe('useUiEditorState', () => { root: { ...nodeWithSprite('unused'), id: 'text-root', - components: [ - { - Text: { - content: '你好', - font: { Bound: 'body' }, - font_style: 'Normal', - font_sizing: { Fixed: 14 }, - color: [255, 255, 255, 255], - alignment: 'UpperLeft', - horizontal_overflow: 'Wrap', - vertical_overflow: 'Truncate', - line_spacing: 1, - }, + component: { + Text: { + content: '你好', + font: { Bound: 'body' }, + font_style: 'Normal', + font_sizing: { Fixed: 14 }, + color: [255, 255, 255, 255], + alignment: 'UpperLeft', + horizontal_overflow: 'Wrap', + vertical_overflow: 'Truncate', + line_spacing: 1, }, - ], + }, }, }, ], @@ -544,14 +540,14 @@ describe('useUiEditorState', () => { act(() => { expect( - result.current.setNodeComponents('page', 'text-root', [ - { - Text: { - ...initial.ui_trees[0]!.root.components[0]!.Text!, - font: { Bound: 'body' }, - }, + result.current.setNodeComponent('page', 'text-root', { + Text: { + ...('Text' in initial.ui_trees[0]!.root.component! + ? initial.ui_trees[0]!.root.component!.Text + : {}), + font: { Bound: 'body' }, }, - ]), + }), ).toEqual({ ok: true, value: undefined }); expect(result.current.removeFontAsset('body', { dryRun: true })).toEqual({ ok: true, @@ -566,7 +562,7 @@ describe('useUiEditorState', () => { result.current.removeFontAsset('body', { dryRun: false }); }); expect(result.current.state.font_assets.body).toBeUndefined(); - expect(result.current.state.ui_trees[0]?.root.components[0]).toMatchObject({ + expect(result.current.state.ui_trees[0]?.root.component).toMatchObject({ Text: { font: 'SystemFont' }, }); }); diff --git a/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts b/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts index 196077400..2795e71f1 100644 --- a/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts +++ b/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts @@ -11,7 +11,7 @@ function node(id: string, children: Node[] = []): Node { id, layout: {} as Node['layout'], metadata: {} as Node['metadata'], - components: [], + component: null, children_display_mode: 'Stack', children, }; diff --git a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx index 7434c355e..e2f8339f9 100644 --- a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx +++ b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx @@ -28,12 +28,12 @@ function node(id: string, children: UiNode[] = []): UiNode { name: id, description: '', layout_status: 'NoProblem', - components_status: 'NoProblem', + component_status: 'NoProblem', allow_llm_edit_layout: true, allow_llm_edit_component: true, source: 'System', }, - components: [], + component: null, children, }; } diff --git a/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts b/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts index 01016c4ae..af3f2d1cc 100644 --- a/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts +++ b/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts @@ -15,6 +15,6 @@ describe('workflow completion notice helpers', () => { it('maps every workflow step to a user-facing label', () => { expect(workflowStepLabel('reference-analysis')).toBe('分析参考图'); expect(workflowStepLabel('structure-recognition')).toBe('识别界面结构'); - expect(workflowStepLabel('visual-binding')).toBe('绑定视觉素材'); + expect(workflowStepLabel('asset-separation')).toBe('自动切分素材'); }); }); diff --git a/docs/README.md b/docs/README.md index b50d9c401..55a6fdd82 100644 --- a/docs/README.md +++ b/docs/README.md @@ -58,6 +58,8 @@ ## 图片画布与媒体 - [共享基础组件库与展示页](./technical/【前端架构】共享基础组件库与展示页-2026-08-26.md):网站与客户端复用的无业务 UI chrome、样式边界和 `/components` 展示页。 +- [Raw GPT Image 2 图片编辑代理](./technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md):主站客户端调用的同步图片编辑代理、multipart 输入、预检查与计费边界。 +- [UI 编辑器自动切分素材工作流](./technical/【技术方案】UI编辑器自动切分素材工作流-2026-09-08.md):UI 设计图素材切分、Raw GPT Image 2 调用与结果持久化边界。 - [图片画布结构化持久化与迁移回滚](./【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md) - [编辑器生成结果原子提交与幂等重放](./technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md) - [画板音乐生成入口](./【编辑器】画板音乐生成入口设计-2026-06-18.md):BGM/SFX 共享视图、独立业务规则和当前发布门禁。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 60d4694c4..97f0d6d93 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -12,7 +12,7 @@ - 不变量:片并集必须等于 `--list` 的全集且互斥,数量或成员不符立即失败(`assertShardsCoverEveryTest`);该校验与「只跑一片」无关,因此在每个分片 job 上都会执行,防止分片规则改动后静默漏跑门禁。 - 配套拆分:`npm run ai-game-creator-shell:check:rust` 拆成 `:rust:crates`(`agent-runtime-core`、`agent-runtime-orchestration`、`platform-llm`、`shared-contracts`)与 `:rust:shell`(分片运行器),聚合脚本保持同序,因而 `ai-game-creator-shell:check` 与本地 `npm run check:native-shells` 语义不变。AGC 相关门禁在 CI 里变成 6 个 job:`AI game creator shell Rust shard 1/4` ~ `4/4`、`AI game creator shell Rust smoke`、`AI game creator shell Rust crates`。 - 前置瘦身:AGC 壳有独立 `Cargo.lock`,其 path 依赖已包含 `platform-llm` / `platform-agent` / `agent-runtime-core` / `shared-contracts`,所以 4 个分片 job 与 smoke job 只需预热 AGC 壳这一份 manifest;这些 job 只用 cargo 与 node 内建模块,因此 **5 个壳 job 与 crates job 都不再执行 `npm ci`**(每个省 1~3 分钟)。 -- 影响范围:`.gitea/workflows/project-ci.yml`(十一个 job)、`scripts/check-native-shells.mjs`(分组由五个到十个:新增 `agc-rust-crates`、`agc-rust-shard-1..4`、`agc-rust-smoke`,移除 `agc-rust` 与随后的 `agc-rust-shell`)、根 `package.json`、`scripts/project-ci-workflow.test.ts`(新增纯 cargo job 免 `npm ci`、分片运行器覆盖校验、crate 级 job 预热顺序断言)、开发运维文档与共享记忆。Gitea `master` 分支保护的 required context 是追加式的,需补上 6 个新 context(共十一个)。 +- 影响范围:`.gitea/workflows/project-ci.yml`(十一个 job)、`scripts/check-native-shells.mjs`(分组由五个到十个:新增 `agc-rust-crates`、`agc-rust-shard-1..4`、`agc-rust-smoke`,移除 `agc-rust` 与随后的 `agc-rust-shell`)、根 `package.json`、`scripts/project-ci-workflow.test.ts`(新增纯 cargo job 免 `npm ci`、分片运行器覆盖校验、crate 级 job 预热顺序断言)、开发运维文档与共享记忆。本仓库不把 Project CI 的 context 配成 `master` 分支保护的合并必需检查(2026-09-14 复核),合并前由人工确认结果,因此 job 拆分/改名不需要同步分支保护设置。 - 验证方式:`npx vitest run scripts/project-ci-workflow.test.ts`;分片运行器本地以 `agent-runtime-core`(7 条 → 2/2/2/1)与 `platform-llm`(146 条 → 49/49/48)验证分片、`--exact` 与片 TMPDIR 隔离,负例 `--shard-index=5` 立即失败;`node scripts/check-native-shells.mjs --groups=contract` 回归。预期每个分片 job 收敛到 5 分钟以内(前置约 1 分 30 秒 + 编译约 1 分 39 秒 + 约 617 条用例)。 - 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)、[踩坑记录](pitfalls.md)。 @@ -8124,7 +8124,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-08-24 AGC UI 原型桥接与自主 UI workflow - 决策:`ui-prototype` 图片与 `UI` JSON 编辑资源保持两种正式类型。Agent 通过受控 `ui.workflow.run` 按 `prepare -> recognize -> status -> finalize` 创建页面资源、关联源图、持久化 UI State 和 manifest 阶段;`recognize` 直接复用 UI Editor 的 provider-backed 结构识别、多树合并与组件绑定命令,按 `reference-ready -> structure-ready -> merge-ready -> binding-ready` 逐阶段写入并推进项目 revision。页面可显式关联已登记图片/图标和字体,图片/图标按 5 项一批绑定,字体安全元数据进入绑定上下文且未知引用失败关闭。Runtime 回执携带 `revisionAdvanceCount`;Provider 未配置、请求失败、工具调用缺失、结果不匹配、未产出可渲染组件或仍有待审节点时保留最近真实阶段,禁止用 deterministic seed 冒充语义处理完成。 -- 客户端:画布点击 `ui-prototype` 先幂等桥接到 `UI` JSON,并立即刷新 manifest;关联查找按 canonical resource identity 且优先已完成 workflow 资源。全部页面完成后,工作台自动打开首个页面的 UI 编辑器 `visual-binding` 最终阶段。 +- 客户端:画布点击 `ui-prototype` 先幂等桥接到 `UI` JSON,并立即刷新 manifest;关联查找按 canonical resource identity 且优先已完成 workflow 资源。全部页面完成后,工作台自动打开首个页面的 UI 编辑器 `asset-separation` 最终阶段。 - 完成门:`finalize` 必须为每个页面提供 `game/` 下真实 UTF-8 应用文件并安装当前 UI State revision 标记;缺少结构、组件、页面或标记时拒绝完成。详细输入、阶段与恢复契约见 [`docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`](../../【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md)。 - 验证:前端 bridge 6/6、资源实时集成 19/19、AppSurface 410/410、AGC typecheck、Rust workflow 定向测试覆盖 provider 前的 reference 阶段与真实调用失败关闭、Rust bridge 1/1、编码、格式和 diff 门禁通过;认证登录与真实 Provider 生成的桌面端 E2E 尚未具备可用会话,保持未验证。 @@ -8294,6 +8294,16 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - AGC LLM 对话入口在解析 Router 凭据和访问上游前先读取用户 `wallet_balance`。余额为 `0` 时直接返回 `409 MUD_POINTS_INSUFFICIENT`,客户端显示“泥点余额不足”;不创建、续期或使用 Router 账号。余额读取失败同样失败关闭,返回“泥点余额暂时不可用”。 - 余额大于 `0` 的请求继续走 Router,成功后仍按 best-effort 后置结算;退款占用、冻结或扣费时余额不足的处理继续由钱包事务和既有结算规则负责。 +## 2026-09-09 UI Editor 结构化请求 repair history + +- UI Editor 的结构化 LLM repair 由 `run_with_repair_history` 统一维护 append-only `LlmMessage` history;调用方只构造初始 prompt 并提供 `requester(history) -> Result`。 +- `validater(&T) -> Result<(), String>` 只负责业务校验。网络、模型、tool 缺失、JSON 或反序列化错误只按原 history 重试;只有业务校验失败才把序列化后的响应和校验错误合并为一条 system message 追加到 history。 +- history 仅存在本次请求内存中,不重复图片、不截断、不扩展 `platform-llm` 消息协议;重试次数参数统一使用 `max_retries`。 + +## 2026-09-11 UI 编辑器素材切分边界使用固定像素上限 + +- UI 编辑器自动切分在 cut 前对视觉模型返回的 `BindingArea` 做像素边界归一化时,每条边相对原始区域最多移动 `32px`,不再按原始区域宽高的百分比计算;Rust 常量为 `MAX_BINDING_AREA_EDGE_ADJUSTMENT_PX`,技术方案同步记录该固定上限。 + ## 2026-08-29 DirectProject 受控联网搜索默认与边界 - 正式产品本次只覆盖 `DirectProject` 单 Codex Agent。`Provider`、`ToolHost`、`DirectHome` 不是 Agent,也不是本次联网主链路;不新增全路由联网或工具桥。唯一受控联网工具为 `agc_tools.agc_web_search`,链路固定为 Codex MCP 工具目录 -> 客户端 loopback `DirectToolBridge` -> 有界 Bing RSS HTTPS -> 过滤 / 脱敏 -> MCP 结果回传。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 339290853..ac4fe97a3 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -16,6 +16,7 @@ - **反面实验(run 2102,勿重做)**:起先把 4 片放进**同一个 job** 内的 4 个进程并行,结果门禁步骤跑满 18 分钟仍未结束,比整套串行的 507 秒还慢——同一容器内这几片共享 `HOME`、target 目录与固定临时路径,会互相拖慢。因此 `--shard-index` 是 CI 的唯一入口;不带 `--shard-index` 的「单命令内多片并行」只留给本地全量自测。 - **易错点**:① 分片规则必须自校验「片并集等于 `--list` 全集且互斥」,否则改分片方式会静默漏跑门禁;② 每片要拿独立 `TMPDIR`,`tempfile::tempdir()` 默认落在它下面(测试里的硬编码 `/tmp/...` 多是「必须拒绝」的负向断言,不是真实读写);③ 不要给分片 job 装 `npm ci`——AGC 壳 Rust 门禁与 `agent-run` smoke 只用 cargo 与 node 内建模块,那些 `npm ci` 正是达标 7 分钟的主要障碍;④ 片 job 只需预热 AGC 壳自己的 manifest(其 `Cargo.lock` 的 path 依赖已覆盖 `platform-llm` / `platform-agent` / `agent-runtime-core` / `shared-contracts`),`server-rs` 那份预热属于 crate 级 job;⑤ 分片后 `--test-threads=1` 不再出现在 workflow 里,但它是分片运行器的片内参数,别再往 workflow 里补整套串行命令。 - **不要做的事**:不要退回「整套 `--test-threads=1`」(507 秒长尾回来了),不要放开成整套并行(同进程内后台锁与异步终态会再互相干扰),也不要在单个 job 内多进程并行多个片(实测比串行还慢)。 +- **分支保护口径**(2026-09-14 复核):本仓库不把 Project CI 的 context 配成 `master` 分支保护的合并必需检查,合并前由人工确认最近一次结果;因此 job 拆分或改名不需要同步分支保护设置,代价是门禁红了不会自动阻止合并。 - **关联**:`apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs`、`.gitea/workflows/project-ci.yml`、`scripts/check-native-shells.mjs`(`agc-rust-shard-1..4` / `agc-rust-smoke` / `agc-rust-crates` 分组)、`package.json`。 ## 2026-09-14 根门禁的 `[check:native-shells]