From 8a6264333602bf41ab29d13ad7fcd4cc2a8ebd34 Mon Sep 17 00:00:00 2001 From: Git Hooks Test Date: Tue, 15 Sep 2026 19:59:03 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=9B=BE=E9=9B=86?= =?UTF-8?q?=E8=BF=9E=E9=80=9A=E5=9F=9F=E4=B8=8E=E5=8F=AF=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=BD=91=E6=A0=BC=E5=88=87=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加 connected-components 与 grid 切分模式 支持 gridX/gridY 并同步 API、MCP、Skill、AGC 客户端 移除固定 2x2 图集切分契约与文档 --- .../genarrative-external-editor-api/SKILL.md | 1 + .../references/api-operations.md | 4 +- .../references/capability-routing.md | 4 +- .../scripts/genarrative_external_api.py | 2 + .../agc-skills/taonier-art-assets/SKILL.md | 6 + .../references/platform-art-contract.md | 1 + .../src-tauri/src/agent/direct_codex_audit.rs | 1 + .../src-tauri/src/agent/direct_runtime.rs | 3 + .../src-tauri/src/agent/direct_tool_bridge.rs | 44 ++++ .../src-tauri/src/agent/direct_tools_mcp.rs | 26 +++ .../src/agent/generation/canvas_generation.rs | 67 ++++-- .../src/agent/runtime_tools/media.rs | 41 ++++ .../src-tauri/src/commands.rs | 3 + .../src-tauri/src/tests/mod.rs | 4 +- .../src-tauri/src/tests/project.rs | 6 + .../genarrative-external-v1.openapi.json | 47 ++++- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 1 + .../api-server/src/editor_agent/tool.rs | 4 +- .../crates/api-server/src/editor_project.rs | 42 ++-- .../api-server/src/editor_project_icon.rs | 193 +++++++++++++++--- .../api-server/src/external_editor_api.rs | 8 + .../src/external_generation_worker.rs | 16 +- .../crates/api-server/src/external_mcp.rs | 2 +- .../crates/api-server/src/prompt/icon_spec.rs | 8 +- .../src/generated_asset_sheets/mod.rs | 2 +- .../src/generated_asset_sheets/sheet.rs | 31 +-- .../image-editor/editorProjectClient.test.ts | 5 +- .../image-editor/editorProjectClient.ts | 9 + 28 files changed, 475 insertions(+), 106 deletions(-) diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md index e6a9f55e7..e4bbafbc2 100644 --- a/.codex/skills/genarrative-external-editor-api/SKILL.md +++ b/.codex/skills/genarrative-external-editor-api/SKILL.md @@ -32,6 +32,7 @@ Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses on - Use stable references such as `objectKey`, project resource ID, or asset ID where each operation permits them. Image edit/redraw is stricter: `sourceReferenceId` accepts only a registered project resource ID or asset ID; upload confirmation alone is not enough. Use `/assets/read-url` only for temporary preview/download access. - Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either. - Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent. +- Icon spritesheet generation accepts `sliceMode="connected-components"` (default alpha-connectivity detection) or `sliceMode="grid"`. Grid mode requires `gridX` and `gridY` (1-32); use `sliceCount` only to constrain connected-component output. - For successful `style="pixelArt"`, treat completed-result and nested resource/asset dimensions as the final logical-grid PNG dimensions. They may differ from `size`, `imageSize`, the provider image, and `canvasCompletion.placeholder`; do not rescale or reject the artifact to match those inputs. - Keep generated artifacts in the canvas and asset library together. Character animation accepts `assetFolderId` and `assetLabel`; its completed result directly returns the final `assetKind="character-animation"` resource and asset with formal sequence fields. Do not create a duplicate first-frame record. diff --git a/.codex/skills/genarrative-external-editor-api/references/api-operations.md b/.codex/skills/genarrative-external-editor-api/references/api-operations.md index 909174e47..a744d8589 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-operations.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md @@ -52,7 +52,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP | Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | | Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` | | Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | -| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `sliceLayout`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | +| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `sliceMode`, `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | | UI asset extraction | `/api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` | | Character animation | `/api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | | Video generation | `/api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | @@ -94,7 +94,7 @@ For image edit/redraw, confirming an upload is not sufficient: create a project The icon-spritesheet primary `referenceId` is intentionally stricter than ordinary image references: it accepts only a current-owner project resource ID or asset ID whose authoritative `assetKind` is `icon-spec`. It does not accept an `objectKey`, URL, Data URL, or Blob URL. -`sliceLayout: "grid-2x2"` is an opt-in contract for four fixed game-runtime assets. The provider prompt and server persistence both preserve the ordered slots left-top, right-top, left-bottom, right-bottom. Omit it to retain the default connected-component slicing behaviour for ordinary free-form icon sheets. +`sliceMode` controls atlas splitting. Use `"connected-components"` (default) to detect independent opaque regions by alpha connectivity, or `"grid"` with positive `gridX` and `gridY` values (maximum 32 each). `sliceCount` optionally constrains the connected-component result. ## Common Values diff --git a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md index a5b4fe886..af0ee338a 100644 --- a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md +++ b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md @@ -79,9 +79,9 @@ Keep the existing autonomous-build task graph. Do not add a parallel task system 1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context. 2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`. -3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. For the four-category game contract it must also send `sliceLayout: "grid-2x2"`; this is an explicit fixed-slot contract, not a client-side guessed crop. +3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. For a fixed four-category game contract it may send `sliceMode: "grid"`; for free-form assets use `sliceMode: "connected-components"` (the default). -For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. For the four-category game-chat contract, require response `sliceLayout: "grid-2x2"` and exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet ``, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation. +For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. When using the fixed four-category contract, require response `sliceMode: "grid"` and exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet ``, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation. Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG. diff --git a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py index cc5a4a4b5..c7c0ab33b 100644 --- a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py +++ b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py @@ -881,12 +881,14 @@ def _self_test() -> None: ["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"], canvasSession=session, assetLabel="贪吃蛇透明图集", + sliceMode="connected-components", referenceId="must-not-override-explicit-reference", iconDescriptions=["不得覆盖显式图标描述"], ) assert calls[0]["path"] == "/api/external/v1/editor/icon-spritesheets/generations" assert calls[0]["body"]["referenceId"] == "editor-resource-spec" assert calls[0]["body"]["screenColor"] == "auto" + assert calls[0]["body"]["sliceMode"] == "connected-components" assert calls[0]["body"]["iconDescriptions"][0] == "蛇头向上" assert calls[1]["path"] == "/api/external/v1/generations/task-operation-demo" print("self-test ok") 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 8d63beb92..d652b4436 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 @@ -19,6 +19,12 @@ 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 the complete game-art package and its canonical slices. +When `agc_generate_image` is used with `kind="art-spritesheet"`, pass +`sliceMode="connected-components"` (the default alpha-connectivity splitter) +or `sliceMode="grid"` with `gridX` and `gridY` (1-32 each). The selected mode is carried +through the client request and returned result; do not infer it from the number +of slices. + ## Authorization boundary `agc_tools` is an AGC client-owned bridge to the AGC backend. In the normal client build it uses the current client login session and account routes; the user and model never need to provide, configure, paste, create, or rotate an API Key, Token, Cookie, URL, or `.env` value. If the tool returns `401` or `403`, report only that the AGC client login or permission state is unavailable, stop the operation, and do not ask the user for credentials or expose an internal URL. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md index 9a04e1256..bf0b74481 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md @@ -15,6 +15,7 @@ - On timeout or uncertain delivery, reuse the recorded operation; never create a replacement request. - `postprocess-failed-source-preserved` means the complete provider source remains usable, but the requested transparent derivative is absent. - `sliceWarning` means the complete transparent sheet remains usable, but individual slices are absent. +- For direct `agc_generate_image` spritesheet requests, `sliceMode="connected-components"` selects alpha-connectivity detection and `sliceMode="grid"` uses the caller-provided `gridX` and `gridY` (1-32 each). The client preserves the selected mode and grid dimensions in the request identity and result metadata. - General and slice warnings can coexist. The tool returns them separately through `warnings` and `sliceWarnings`; callers must preserve every entry and must not downgrade a slice warning into a successful independent-asset claim. - `assetPaths` contains the complete package paths. `slicePaths` contains only slices that the client downloaded, validated, and registered with their platform source identities. - `resources` contains only safe registered identity fields: local asset/path/kind/media type, Canvas project/resource/asset/task IDs, and reference resource IDs. It never exposes prompts, models, provider routes, absolute paths, URLs, tokens, cookies, or API keys. diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs index 43ab7c7ba..6f8557a32 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs @@ -549,6 +549,7 @@ fn extract_mcp_arguments(root: &Path, tool: &str, arguments: &Value) -> Value { } "agc_generate_image" => { copy_string(object, "kind", &mut out); + copy_string(object, "sliceMode", &mut out); copy_string(object, "aspectRatio", &mut out); copy_string(object, "imageSize", &mut out); copy_string(object, "assetName", &mut out); 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 2e4cdad61..71cf43b44 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 @@ -2783,6 +2783,9 @@ async fn generate_direct_taonier_art_asset_at( asset_label: asset_label.to_string(), replace_existing: root.join(output_path).is_file(), slice_count: None, + slice_mode: None, + grid_x: None, + grid_y: None, }; let runtime_context = direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index e185c8cac..6c94bfe6a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -2104,6 +2104,9 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) "imageSize", "assetName", "outputPath", + "sliceMode", + "gridX", + "gridY", ], )?; enforce_project_permission_policy(&state.root, "canvas.asset_generate")?; @@ -2145,6 +2148,44 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) .transpose()? .unwrap_or_else(|| "AI 生成图片".to_string()); let output_path = bridge_optional_bounded_string(arguments, "outputPath", 512)?; + let slice_mode = arguments + .get("sliceMode") + .map(|_| bridge_bounded_string(arguments, "sliceMode", 32)) + .transpose()?; + if slice_mode + .as_deref() + .is_some_and(|mode| !matches!(mode, "connected-components" | "grid")) + { + return Err("工具参数 sliceMode 只允许 connected-components 或 grid".to_string()); + } + let grid_x = arguments + .get("gridX") + .map(|_| { + arguments + .get("gridX") + .and_then(Value::as_u64) + .map(|value| value as u32) + .ok_or_else(|| "工具参数 gridX 必须是整数".to_string()) + }) + .transpose()?; + let grid_y = arguments + .get("gridY") + .map(|_| { + arguments + .get("gridY") + .and_then(Value::as_u64) + .map(|value| value as u32) + .ok_or_else(|| "工具参数 gridY 必须是整数".to_string()) + }) + .transpose()?; + if slice_mode.as_deref() == Some("grid") && (grid_x.is_none() || grid_y.is_none()) { + return Err("grid 模式必须同时提供 gridX 与 gridY".to_string()); + } + if grid_x.is_some_and(|value| !(1..=32).contains(&value)) + || grid_y.is_some_and(|value| !(1..=32).contains(&value)) + { + return Err("工具参数 gridX/gridY 必须在 1 到 32 之间".to_string()); + } let options = PlatformArtAssetGenerationOptions { output_path, aspect_ratio, @@ -2153,6 +2194,9 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) asset_label: asset_name.clone(), replace_existing: false, slice_count: None, + slice_mode, + grid_x, + grid_y, }; let _generation_guard = state.image_generation_gate.lock().await; let generated = with_direct_editor_api_credentials( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 643abd3db..294b44c25 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -244,6 +244,24 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "type": "string", "maxLength": 512, "description": "可选项目相对输出路径,必须位于 assets/ 且不能覆盖已有文件" + }, + "sliceMode": { + "type": "string", + "enum": ["connected-components", "grid"], + "default": "connected-components", + "description": "仅 kind=art-spritesheet 生效:connected-components 按透明像素连通域切分,grid 按 gridX×gridY 网格切分" + }, + "gridX": { + "type": "integer", + "minimum": 1, + "maximum": 32, + "description": "grid 模式横向网格数量" + }, + "gridY": { + "type": "integer", + "minimum": 1, + "maximum": 32, + "description": "grid 模式纵向网格数量" } }, "required": ["prompt"], @@ -1014,6 +1032,9 @@ async fn call_agc_generate_image(arguments: &Value) -> Value { "imageSize", "assetName", "outputPath", + "sliceMode", + "gridX", + "gridY", ], ) { return mcp_tool_result(error, Vec::new(), true); @@ -1041,6 +1062,7 @@ async fn call_agc_generate_image(arguments: &Value) -> Value { ("imageSize", 4), ("assetName", DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS), ("outputPath", 512), + ("sliceMode", 32), ] { if arguments.get(field).is_some() { if let Err(error) = bounded_tool_string(arguments, field, max_chars) { @@ -2226,6 +2248,10 @@ mod tests { assert!(image_tool["description"] .as_str() .is_some_and(|description| description.contains("不是本工具的限制"))); + assert_eq!( + image_tool["inputSchema"]["properties"]["sliceMode"]["enum"], + json!(["connected-components", "grid"]) + ); let edit_tool = specs["tools"] .as_array() .expect("tool array") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 2bd540024..f9bbd95a8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -413,6 +413,9 @@ pub(crate) struct PlatformArtAssetGenerationOptions { pub(crate) asset_label: String, pub(crate) replace_existing: bool, pub(crate) slice_count: Option, + pub(crate) slice_mode: Option, + pub(crate) grid_x: Option, + pub(crate) grid_y: Option, } impl Default for PlatformArtAssetGenerationOptions { @@ -425,6 +428,9 @@ impl Default for PlatformArtAssetGenerationOptions { asset_label: "AI 游戏首版美术素材".to_string(), replace_existing: false, slice_count: None, + slice_mode: None, + grid_x: None, + grid_y: None, } } } @@ -1574,7 +1580,7 @@ pub(in crate::agent) struct PreparedPlatformArtAssetGeneration { warning: Option, slice_warning: Option, slices: Vec, - spritesheet_slice_layout: Option, + spritesheet_slice_mode: Option, generation_route: String, generation_kind: String, reference_resource_ids: Vec, @@ -2213,11 +2219,8 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at( /// 而任何输入不同(提示词、输出路径、比例、尺寸、类型、标签、严格切片)都是另一个 /// 动作,必须各自独立成槽,才能在同一项目里同时在途。 /// -/// **字段集合与取值方式必须与升级前逐字节一致**:升级前遗留账本里持久化的 -/// `actionFingerprint` 就是这个材料的历史哈希,改动材料会让旧账本无法按精确动作被 -/// 识别与迁移(见 `adopt_legacy_standalone_platform_art_generation_runtime_state_at`)。 -/// 已知边界:`slice_count` 不进身份(与升级前一致),仅切片数不同的两条图集请求仍落到 -/// 同一槽,第二条在账本请求正文校验处失败关闭,不会二次 POST。 +/// 升级前遗留账本仍由旧材料函数定位;新请求把显式切分模式纳入身份,避免同一图集 +/// 请求在网格与连通域之间误复用。`slice_count` 继续保持历史兼容语义,不进身份。 #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct StandalonePlatformArtGenerationFingerprintMaterial<'a> { @@ -2229,6 +2232,9 @@ struct StandalonePlatformArtGenerationFingerprintMaterial<'a> { asset_label: &'a str, replace_existing: bool, require_slices: bool, + slice_mode: Option<&'a str>, + grid_x: Option, + grid_y: Option, } /// 把输出路径收口成稳定的旧槽材料:空路径与未指定路径都落到 `(automatic-output)`, @@ -2265,6 +2271,9 @@ fn standalone_platform_art_generation_runtime_context( asset_label: &options.asset_label, replace_existing: options.replace_existing, require_slices, + slice_mode: options.slice_mode.as_deref(), + grid_x: options.grid_x, + grid_y: options.grid_y, }) .map_err(|error| format!("序列化 standalone 图片生成动作身份失败:{error}"))?; let action_fingerprint = format!("{:x}", Sha256::digest(&identity_bytes)); @@ -2811,6 +2820,9 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "referenceId": reference_id, "iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt), "sliceCount": options.slice_count, + "sliceMode": options.slice_mode, + "gridX": options.grid_x, + "gridY": options.grid_y, "screenColor": "auto", "aspectRatio": options.aspect_ratio, "imageSize": options.image_size, @@ -3106,8 +3118,8 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at Vec::new() }; let warning = platform_art_generation_warning(generated); - let spritesheet_slice_layout = if is_canonical_art_spritesheet { - json_string_field(generated, "sliceLayout") + let spritesheet_slice_mode = if is_canonical_art_spritesheet { + json_string_field(generated, "sliceMode") } else { None }; @@ -3168,7 +3180,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at warning, slice_warning, slices, - spritesheet_slice_layout, + spritesheet_slice_mode, generation_route, generation_kind, reference_resource_ids, @@ -6508,7 +6520,7 @@ fn validate_strict_platform_art_spritesheet_contract( task_id: Option<&str>, generation_route: &str, generation_kind: &str, - spritesheet_slice_layout: Option<&str>, + spritesheet_slice_mode: Option<&str>, reference_resource_ids: &[String], has_transparent_pixels: bool, has_visible_pixels: bool, @@ -6545,7 +6557,7 @@ fn validate_strict_platform_art_spritesheet_contract( { return Err("strict spritesheet 图集生成 route/kind 与严格图集合同不一致".to_string()); } - let _requested_slice_layout = spritesheet_slice_layout; + let _requested_slice_mode = spritesheet_slice_mode; if reference_resource_ids.len() != 1 || reference_resource_ids[0].trim().is_empty() || reference_resource_ids[0].trim() == resource_id @@ -7307,7 +7319,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( warning, mut slice_warning, slices, - spritesheet_slice_layout, + spritesheet_slice_mode, generation_route, generation_kind, reference_resource_ids, @@ -7326,7 +7338,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( task_id.as_deref(), &generation_route, &generation_kind, - spritesheet_slice_layout.as_deref(), + spritesheet_slice_mode.as_deref(), &reference_resource_ids, spritesheet_has_transparent_pixels, spritesheet_has_visible_pixels, @@ -7901,8 +7913,8 @@ mod canvas_generation_tests { let body = serde_json::json!({ "error": { "code": "invalid-request", - "field": "sliceLayout", - "message": "只支持 grid-2x2;operationId=private-operation-id;api_key=private-key", + "field": "sliceMode", + "message": "只支持 grid;operationId=private-operation-id;api_key=private-key", }, "details": { "path": "C:\\Users\\private\\secret.json", @@ -7911,8 +7923,8 @@ mod canvas_generation_tests { .to_string(); let summary = summarize_external_http_error_body(&body).expect("summary"); assert!(summary.contains("code=invalid-request"), "{summary}"); - assert!(summary.contains("field=sliceLayout"), "{summary}"); - assert!(summary.contains("只支持 grid-2x2"), "{summary}"); + assert!(summary.contains("field=sliceMode"), "{summary}"); + assert!(summary.contains("只支持 grid"), "{summary}"); assert!(!summary.contains("private-operation-id"), "{summary}"); assert!(!summary.contains("private-key"), "{summary}"); assert!(!summary.contains("C:\\Users\\private"), "{summary}"); @@ -8312,6 +8324,9 @@ mod canvas_generation_tests { asset_label: "手工背景".to_string(), replace_existing: true, slice_count: None, + slice_mode: None, + grid_x: None, + grid_y: None, }; let ordinary = standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) @@ -9826,7 +9841,7 @@ mod canvas_generation_tests { Some("spritesheet-task"), "/api/external/v1/editor/icon-spritesheets/generations", "icon-spritesheet", - Some("grid-2x2"), + Some("grid"), &["art-spec-resource".to_string()], true, true, @@ -10325,6 +10340,9 @@ mod canvas_generation_tests { asset_label: "整包规范图".to_string(), replace_existing: false, slice_count: None, + slice_mode: None, + grid_x: None, + grid_y: None, }; let prompt = "生成同一套整包美术"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -11220,6 +11238,9 @@ mod canvas_generation_tests { asset_label: "整包背景图".to_string(), replace_existing: false, slice_count: None, + slice_mode: None, + grid_x: None, + grid_y: None, }; let prompt = "保持同一个生成提示词"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -11681,6 +11702,9 @@ mod canvas_generation_tests { asset_label: "游戏统一视觉规范图".to_string(), replace_existing: false, slice_count: None, + slice_mode: None, + grid_x: None, + grid_y: None, }; let prompt = "恢复已受理视觉规范图"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -12287,6 +12311,9 @@ mod canvas_generation_tests { asset_label: "游戏首版核心美术素材".to_string(), replace_existing: true, slice_count: None, + slice_mode: None, + grid_x: None, + grid_y: None, } } @@ -12317,7 +12344,7 @@ mod canvas_generation_tests { warning: None, slice_warning: None, slices: Vec::new(), - spritesheet_slice_layout: Some("grid-2x2".to_string()), + spritesheet_slice_mode: Some("grid".to_string()), generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(), generation_kind: "icon-spritesheet".to_string(), reference_resource_ids: vec!["art-spec-resource".to_string()], @@ -12636,7 +12663,7 @@ mod canvas_generation_tests { warning: None, slice_warning: None, slices, - spritesheet_slice_layout: Some("grid-2x2".to_string()), + spritesheet_slice_mode: Some("grid".to_string()), generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(), generation_kind: "icon-spritesheet".to_string(), reference_resource_ids: vec!["art-spec-resource".to_string()], diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index 132c71c59..6f7aa3441 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -555,6 +555,17 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio .or_else(|| input.get("slice_count")) .and_then(serde_json::Value::as_u64) .map(|value| value as usize); + let slice_mode = agent_runtime_tool_input_text(input, &["sliceMode", "slice_mode"]); + let grid_x = input + .get("gridX") + .or_else(|| input.get("grid_x")) + .and_then(serde_json::Value::as_u64) + .map(|value| value as u32); + let grid_y = input + .get("gridY") + .or_else(|| input.get("grid_y")) + .and_then(serde_json::Value::as_u64) + .map(|value| value as u32); let mut requested_options = PlatformArtAssetGenerationOptions { output_path: (!output_path.trim().is_empty()).then_some(output_path), aspect_ratio, @@ -563,6 +574,9 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio asset_label, replace_existing, slice_count, + slice_mode: (!slice_mode.trim().is_empty()).then_some(slice_mode.clone()), + grid_x, + grid_y, }; if let Some(pending) = pending_action { match recover_persisted_visual_generation_options( @@ -609,6 +623,11 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio }, replace_existing, slice_count, + slice_mode: requested_options + .slice_mode + .or_else(|| (!slice_mode.trim().is_empty()).then_some(slice_mode)), + grid_x, + grid_y, } }; options.replace_existing = replace_existing; @@ -654,6 +673,28 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio detail: None, }; } + if options + .slice_mode + .as_deref() + .is_some_and(|slice_mode| !matches!(slice_mode, "connected-components" | "grid")) + { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: "failed".to_string(), + summary: "图片生成 sliceMode 不受支持".to_string(), + detail: None, + }; + } + if options.slice_mode.as_deref() == Some("grid") + && (options.grid_x.is_none() || options.grid_y.is_none()) + { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: "failed".to_string(), + summary: "grid 模式必须同时提供 gridX 与 gridY".to_string(), + detail: None, + }; + } if !agent_runtime_canvas_asset_kind_is_supported(&options.asset_kind) { return AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index b3cc58f28..828048813 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -4594,6 +4594,9 @@ pub(crate) fn prepare_local_project_asset_generation( .unwrap_or_else(|| LOCAL_PROJECT_ASSET_DEFAULT_ASSET_NAME.to_string()), replace_existing: false, slice_count: None, + slice_mode: None, + grid_x: None, + grid_y: None, }, }) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 28b4dcbc1..f12416e7d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -3192,7 +3192,9 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( "spritesheetImageSrc": "/generated/canvas/spritesheet.png", "spritesheetWidth": 2, "spritesheetHeight": 1, - "sliceLayout": "grid-2x2", + "sliceMode": "grid", + "gridX": 2, + "gridY": 2, "iconImageSrcs": icon_image_srcs, "sliceWarning": null, "prompt": "原创游戏素材图集", diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 4fa58e0e2..2270ca448 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1076,6 +1076,9 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { asset_label: "游戏横屏界面原型图".to_string(), replace_existing: false, slice_count: None, + slice_mode: None, + grid_x: None, + grid_y: None, }, ) .await; @@ -5493,6 +5496,9 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { asset_label: "游戏横屏界面原型图".to_string(), replace_existing: false, slice_count: None, + slice_mode: None, + grid_x: None, + grid_y: None, }; let prompt = build_platform_art_asset_prompt( "原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开", diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 507747e57..0a28b1ab6 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3370,16 +3370,32 @@ "maxLength": 200 } }, - "sliceLayout": { + "sliceMode": { "type": "string", - "deprecated": true, - "description": "历史兼容字段,新的调用请使用 sliceCount。" + "enum": [ + "connected-components", + "grid" + ], + "default": "connected-components", + "description": "图集切分模式。connected-components 按透明像素 alpha 连通域识别独立素材;grid 按用户提供的 gridX/gridY 划分网格槽。省略时使用 connected-components。" + }, + "gridX": { + "type": "integer", + "minimum": 1, + "maximum": 32, + "description": "grid 模式的横向网格数量。" + }, + "gridY": { + "type": "integer", + "minimum": 1, + "maximum": 32, + "description": "grid 模式的纵向网格数量。" }, "sliceCount": { "type": "integer", "minimum": 1, "maximum": 100, - "description": "可选的目标切片数量;省略时按图像内容自动识别。" + "description": "connected-components 模式下可选的目标切片数量;省略时按图像内容自动识别。grid 模式的切片数量由 gridX×gridY 决定。" }, "screenColor": { "type": ["string", "null"], @@ -3603,15 +3619,28 @@ }, "iconImageSrcs": { "type": "array", - "description": "识别图集中有效 alpha 连通域并持久化的独立素材,按视觉阅读顺序命名为“素材 N”;可通过 sliceCount 指定目标数量。", + "description": "按 sliceMode 识别或裁切并持久化的独立素材,按视觉阅读顺序命名为“素材 N”;connected-components 模式可通过 sliceCount 指定目标数量。", "items": { "$ref": "#/components/schemas/EditorIconSpritesheetIconResult" } }, - "sliceLayout": { + "sliceMode": { "type": "string", - "deprecated": true, - "description": "历史兼容字段。" + "enum": [ + "connected-components", + "grid" + ], + "description": "实际采用的图集切分模式。" + }, + "gridX": { + "type": "integer", + "minimum": 1, + "maximum": 32 + }, + "gridY": { + "type": "integer", + "minimum": 1, + "maximum": 32 }, "sliceCount": { "type": "integer", @@ -3628,7 +3657,7 @@ "type": "null" } ], - "description": "可信透明图集已成功持久化,但全连通域自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。原始连通域、输出数量或 CPU 预算超限不会产生切片 PUT、资源或画布切片。透明处理、Alpha/尺寸恢复、provider 原图修复性回读或透明图完整解码失败时走 provider 原图 source-only,sliceWarning 为 null。" + "description": "可信透明图集已成功持久化,但所选 sliceMode 的自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。原始连通域、输出数量、网格裁切或 CPU 预算超限不会产生切片 PUT、资源或画布切片。透明处理、Alpha/尺寸恢复、provider 原图修复性回读或透明图完整解码失败时走 provider 原图 source-only,sliceWarning 为 null。" }, "prompt": { "type": "string" diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 895286d30..ae769d8b3 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -40,6 +40,7 @@ ## 生成契约 - 前端提交到 `POST /api/editor/icon-spritesheets/generations`。 +- 图集拆分通过 `sliceMode` 显式选择:`connected-components` 按透明像素连通域切分(默认),`grid` 按用户提供的 `gridX × gridY` 网格切分。 - 图标规范生成在 inline 模式下也必须先建立带稳定请求指纹的 generation operation,并由编辑器生成 durable billing 边界包住共享执行器;不得在 `operation=None` 时调用 provider 后再进入原子结果持久化。 - 图标 spritesheet 的入队与实际执行路径都必须在引用解析、generation input 重建、定价和 provider / OSS 副作用之前预检 owner、项目和最终素材目录,并将返回的 canonical `projectId + assetFolderId` 回写到后续流程;请求省略目录时按实际写入的 owner 默认目录预检,worker 不得只信任入队时的旧校验结果。 - queued 图标规范生成由共享原子结果持久化使用 worker caller 中的 lease 一并完成任务并清理 lease;共享执行器返回成功后 worker 只能返回 `Ok(())`,不得再次调用 job completion。 diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index 949fadf99..0805fb4d4 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -864,7 +864,9 @@ impl EditorAgentTool for GenerateIconSpritesheetTool { reference_image_srcs: Some(reference_image_srcs), icon_descriptions: args.icon_descriptions, slice_count: None, - slice_layout: None, + slice_mode: None, + grid_x: None, + grid_y: None, style: None, model: Some(args.model), screen_color: Some("auto".to_string()), diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index b6b2b1477..d740cdfea 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -93,7 +93,8 @@ use crate::{ }, editor_project_icon::{ EditorIconSpritesheetGenerationResponse, EditorIconSpritesheetIconResponse, - PersistEditorSpritesheetSlicesInput, editor_icon_spritesheet_slice_warning_from_error, + EditorIconSpritesheetSliceMode, PersistEditorSpritesheetSlicesInput, + editor_icon_spritesheet_slice_warning_from_error, editor_icon_spritesheet_warning_after_persist_error, prepare_editor_spritesheet_slices_for_generation, slice_editor_icon_spritesheet_all, }, @@ -1267,7 +1268,7 @@ fn compact_external_api_generation_result(result: Value) -> Value { | "spritesheetWidth" | "spritesheetHeight" | "iconImageSrcs" - | "sliceLayout" + | "sliceMode" | "frames" | "frameCount" | "frameWidth" @@ -8578,7 +8579,9 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( spritesheet_width: source_width, spritesheet_height: source_height, icon_image_srcs: Vec::new(), - slice_layout: None, + slice_mode: None, + grid_x: None, + grid_y: None, slice_count: None, slice_warning: None, prompt, @@ -8645,7 +8648,9 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( spritesheet_width: source_width, spritesheet_height: source_height, icon_image_srcs: Vec::new(), - slice_layout: None, + slice_mode: None, + grid_x: None, + grid_y: None, slice_count: None, slice_warning: None, prompt, @@ -8730,8 +8735,10 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( let (mut icon_image_srcs, slice_items, slice_warning) = match slice_editor_icon_spritesheet_all( slice_source, request_context.external_call_deadline(), + EditorIconSpritesheetSliceMode::ConnectedComponents, None, - None, + 0, + 0, ) .await { @@ -8893,7 +8900,9 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( spritesheet_width, spritesheet_height, icon_image_srcs, - slice_layout: None, + slice_mode: None, + grid_x: None, + grid_y: None, slice_count: None, slice_warning, prompt, @@ -19063,10 +19072,17 @@ mod tests { .checked_sub(Duration::from_millis(1)) .expect("expired deadline should be representable"); - let error = slice_editor_icon_spritesheet_all(source, Some(expired), None, None) - .await - .err() - .expect("expired CPU budget must fail before decoding"); + let error = slice_editor_icon_spritesheet_all( + source, + Some(expired), + EditorIconSpritesheetSliceMode::ConnectedComponents, + None, + 0, + 0, + ) + .await + .err() + .expect("expired CPU budget must fail before decoding"); assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT); assert_eq!( @@ -19751,7 +19767,9 @@ mod tests { spritesheet_width: 512, spritesheet_height: 512, icon_image_srcs: Vec::new(), - slice_layout: None, + slice_mode: None, + grid_x: None, + grid_y: None, slice_count: None, slice_warning: Some(EditorIconSpritesheetSliceWarningResponse { code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, @@ -19984,7 +20002,6 @@ mod tests { fn atomic_job_result_keeps_only_the_target_consumer_contract() { let result = json!({ "ok": true, - "sliceLayout": "grid-2x2", "imageSrc": "/api/assets/object/generated.png", "objectKey": "generated/image.png", "width": 512, @@ -20069,7 +20086,6 @@ mod tests { ); assert_eq!(external_payload["result"]["asset"]["assetId"], "asset-1"); assert_eq!(external_payload["result"]["ok"], true); - assert_eq!(external_payload["result"]["sliceLayout"], "grid-2x2"); assert_eq!(external_payload["result"]["prompt"], "用户可见提示词"); assert_eq!( external_payload["result"]["actualPrompt"], diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index f7f318b86..cfecc85be 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -14,7 +14,7 @@ use platform_image::{ generated_asset_sheets::{ GeneratedAssetSheetConnectedIcon, GeneratedAssetSheetConnectedIconPlan, GeneratedAssetSheetError, prepare_generated_icon_spritesheet_all_by_connected_components, - prepare_generated_icon_spritesheet_grid_2x2, + prepare_generated_icon_spritesheet_grid, }, }; use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmMessage, LlmRunRequest}; @@ -82,6 +82,7 @@ pub(crate) const EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY: usize = 2; pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY: usize = 2; pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS: u64 = EDITOR_ICON_SPRITESHEET_MAX_PIXELS * 4; +const EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS: u32 = 32; pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION: Duration = @@ -254,8 +255,13 @@ pub(crate) struct EditorIconSpritesheetGenerationRequest { /// 用户要求的切片数量;未提供时按图像中的连通素材自动识别。 #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) slice_count: Option, + /// 图集切分模式;省略时使用连通域切分。 #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) slice_layout: Option, + pub(crate) slice_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) grid_x: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) grid_y: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) style: Option, pub(crate) model: Option, @@ -270,11 +276,50 @@ pub(crate) struct EditorIconSpritesheetGenerationRequest { pub(crate) canvas_completion: Option, } -/// Deprecated compatibility layout. New callers should use `sliceCount`。 #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] -pub(crate) enum EditorIconSpritesheetSliceLayout { - #[serde(rename = "grid-2x2")] - Grid2x2, +#[serde(rename_all = "kebab-case")] +pub(crate) enum EditorIconSpritesheetSliceMode { + ConnectedComponents, + Grid, +} + +impl Default for EditorIconSpritesheetSliceMode { + fn default() -> Self { + Self::ConnectedComponents + } +} + +fn resolve_editor_icon_spritesheet_slice_mode( + slice_mode: Option, +) -> EditorIconSpritesheetSliceMode { + slice_mode.unwrap_or_default() +} + +fn resolve_editor_icon_spritesheet_grid_dimensions( + mode: EditorIconSpritesheetSliceMode, + grid_x: Option, + grid_y: Option, +) -> Result<(u32, u32), AppError> { + if mode == EditorIconSpritesheetSliceMode::ConnectedComponents { + return Ok((0, 0)); + } + let (Some(grid_x), Some(grid_y)) = (grid_x, grid_y) else { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "field": "gridX/gridY", + "message": "grid 模式必须同时提供 gridX 与 gridY。", + })), + ); + }; + if !(1..=EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS).contains(&grid_x) + || !(1..=EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS).contains(&grid_y) + { + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "field": "gridX/gridY", + "message": format!("gridX 与 gridY 必须在 1 到 {} 之间。", EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS), + }))); + } + Ok((grid_x, grid_y)) } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -313,7 +358,11 @@ pub(crate) struct EditorIconSpritesheetGenerationResponse { pub(crate) spritesheet_height: u32, pub(crate) icon_image_srcs: Vec, #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) slice_layout: Option, + pub(crate) slice_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) grid_x: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) grid_y: Option, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) slice_count: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -1579,6 +1628,12 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( .or_else(|| payload.project_id.clone()), ); let http_client = build_openai_image_http_client(&settings)?; + let requested_slice_mode = resolve_editor_icon_spritesheet_slice_mode(payload.slice_mode); + let (grid_x, grid_y) = resolve_editor_icon_spritesheet_grid_dimensions( + requested_slice_mode, + payload.grid_x, + payload.grid_y, + )?; // TODO(legacy-icon-spritesheet-billing-boundary): 该计费边界继承自 master 的历史实现; // Provider 成功后 operation 即提交,后续解码、OSS、资源与画布持久化失败时缺少可对账中间态。 // 调整前需先定义 provider_succeeded/persistence_pending 等状态、稳定幂等键和补偿语义, @@ -1618,15 +1673,18 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( ) .await?; let screen_color = screen_background_decision.color; - let prompt = match payload.slice_layout { - Some(EditorIconSpritesheetSliceLayout::Grid2x2) => { - crate::prompt::icon_spec::build_grid_2x2_spritesheet_prompt( + let slice_mode = requested_slice_mode; + let prompt = match slice_mode { + EditorIconSpritesheetSliceMode::Grid => { + crate::prompt::icon_spec::build_grid_spritesheet_prompt( &spritesheet_prompt, screen_color, icon_spec_genre, + grid_x, + grid_y, ) } - None => crate::prompt::icon_spec::build_spritesheet_prompt( + EditorIconSpritesheetSliceMode::ConnectedComponents => crate::prompt::icon_spec::build_spritesheet_prompt( &spritesheet_prompt, screen_color, icon_spec_genre, @@ -1786,7 +1844,11 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( spritesheet_width: source_width, spritesheet_height: source_height, icon_image_srcs: Vec::new(), - slice_layout: payload.slice_layout, + slice_mode: Some(requested_slice_mode), + grid_x: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) + .then_some(grid_x), + grid_y: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) + .then_some(grid_y), slice_count: Some(0), slice_warning: None, prompt, @@ -1868,7 +1930,11 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( spritesheet_width: source_width, spritesheet_height: source_height, icon_image_srcs: Vec::new(), - slice_layout: payload.slice_layout, + slice_mode: Some(requested_slice_mode), + grid_x: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) + .then_some(grid_x), + grid_y: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) + .then_some(grid_y), slice_count: Some(0), slice_warning: None, prompt, @@ -1966,8 +2032,10 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( let (mut icon_image_srcs, slice_items, slice_warning) = match slice_editor_icon_spritesheet_all( slice_source, request_context.external_call_deadline(), - payload.slice_layout, + requested_slice_mode, payload.slice_count, + grid_x, + grid_y, ) .await { @@ -2059,7 +2127,12 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "spritesheetWidth": spritesheet_width, "spritesheetHeight": spritesheet_height, "iconImageSrcs": &icon_image_srcs, - "sliceLayout": payload.slice_layout, + + "sliceMode": requested_slice_mode, + "gridX": (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) + .then_some(grid_x), + "gridY": (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) + .then_some(grid_y), "sliceCount": payload.slice_count, "sliceWarning": &slice_warning, "warning": &generation_warning, @@ -2137,7 +2210,12 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( spritesheet_width, spritesheet_height, icon_image_srcs, - slice_layout: payload.slice_layout, + + slice_mode: Some(requested_slice_mode), + grid_x: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) + .then_some(grid_x), + grid_y: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) + .then_some(grid_y), slice_count: Some(slice_count), slice_warning, prompt, @@ -2332,8 +2410,10 @@ pub async fn split_editor_icon_spritesheet( source, processing_deadline, memory_admission, + EditorIconSpritesheetSliceMode::ConnectedComponents, None, - None, + 0, + 0, ) .await?; let prompt = source_resource @@ -2408,8 +2488,10 @@ pub async fn split_editor_icon_spritesheet( pub(crate) async fn slice_editor_icon_spritesheet_all( source: DownloadedImage, request_deadline: Option, - slice_layout: Option, + slice_mode: EditorIconSpritesheetSliceMode, slice_count: Option, + grid_x: u32, + grid_y: u32, ) -> Result { let processing_deadline = resolve_editor_icon_spritesheet_processing_deadline(Instant::now(), request_deadline); @@ -2419,8 +2501,10 @@ pub(crate) async fn slice_editor_icon_spritesheet_all( source, processing_deadline, memory_admission, - slice_layout, + slice_mode, slice_count, + grid_x, + grid_y, ) .await } @@ -2458,8 +2542,10 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission( source: DownloadedImage, processing_deadline: Instant, memory_admission: Arc, - slice_layout: Option, + slice_mode: EditorIconSpritesheetSliceMode, slice_count: Option, + grid_x: u32, + grid_y: u32, ) -> Result { if Instant::now() >= processing_deadline { return Err(editor_icon_spritesheet_processing_timeout_error()); @@ -2492,17 +2578,19 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission( return Err(editor_icon_spritesheet_processing_timeout_error()); } validate_editor_icon_spritesheet_source(&source)?; - match slice_layout { - Some(EditorIconSpritesheetSliceLayout::Grid2x2) => { - prepare_generated_icon_spritesheet_grid_2x2(&source) + match slice_mode { + EditorIconSpritesheetSliceMode::Grid => { + prepare_generated_icon_spritesheet_grid(&source, grid_x, grid_y) + } + EditorIconSpritesheetSliceMode::ConnectedComponents => { + prepare_generated_icon_spritesheet_all_by_connected_components( + &source, + slice_count + .unwrap_or(EDITOR_ICON_SPRITESHEET_MAX_SLICES) + .min(EDITOR_ICON_SPRITESHEET_MAX_SLICES), + EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS, + ) } - None => prepare_generated_icon_spritesheet_all_by_connected_components( - &source, - slice_count - .unwrap_or(EDITOR_ICON_SPRITESHEET_MAX_SLICES) - .min(EDITOR_ICON_SPRITESHEET_MAX_SLICES), - EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS, - ), } .map_err(map_editor_icon_spritesheet_platform_error) }); @@ -2909,7 +2997,7 @@ mod tests { } #[tokio::test] - async fn grid_2x2_slicing_returns_exactly_four_quadrants_with_detached_details() { + async fn grid_slicing_returns_one_slice_per_declared_cell_with_detached_details() { use image::{ImageBuffer, ImageFormat, Rgba}; let mut image: image::RgbaImage = ImageBuffer::from_pixel(128, 128, Rgba([0, 255, 0, 255])); @@ -2939,15 +3027,54 @@ mod tests { let prepared = slice_editor_icon_spritesheet_all( source, None, - Some(EditorIconSpritesheetSliceLayout::Grid2x2), + EditorIconSpritesheetSliceMode::Grid, None, + 2, + 2, ) .await - .expect("declared 2x2 sheet should slice"); + .expect("declared grid sheet should slice"); assert_eq!(prepared.plan.len(), 4); } + #[test] + fn slice_mode_defaults_to_connected_components_and_accepts_explicit_modes() { + assert_eq!( + resolve_editor_icon_spritesheet_slice_mode(None), + EditorIconSpritesheetSliceMode::ConnectedComponents + ); + assert_eq!( + resolve_editor_icon_spritesheet_grid_dimensions( + EditorIconSpritesheetSliceMode::Grid, + Some(3), + Some(2), + ) + .expect("grid dimensions should validate"), + (3, 2) + ); + let connected: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({ + "referenceId": "spec", + "iconDescriptions": ["素材"], + "sliceMode": "connected-components" + })) + .expect("explicit connected-components mode should deserialize"); + assert_eq!( + connected.slice_mode, + Some(EditorIconSpritesheetSliceMode::ConnectedComponents) + ); + let grid: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({ + "referenceId": "spec", + "iconDescriptions": ["素材"], + "sliceMode": "grid", + "gridX": 3, + "gridY": 2 + })) + .expect("grid mode should deserialize"); + assert_eq!(grid.grid_x, Some(3)); + assert_eq!(grid.grid_y, Some(2)); + } + #[test] fn spritesheet_genre_requires_exact_game_type_title() { assert_eq!( diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index de48a2d15..eeddb4371 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -2620,6 +2620,14 @@ mod tests { icon_spritesheet_request["properties"]["sliceCount"]["minimum"], json!(1) ); + assert_eq!( + icon_spritesheet_request["properties"]["sliceMode"]["enum"], + json!(["connected-components", "grid"]) + ); + assert_eq!( + icon_spritesheet_request["properties"]["gridX"]["maximum"], + json!(32) + ); let icon_style_schema = &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"] ["properties"]["style"]; assert_eq!(icon_style_schema["anyOf"][0]["type"], "string"); diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index b279b5054..6c89e8fb2 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -1364,7 +1364,9 @@ fn compact_external_api_generation_result(result: Value) -> Value { | "spritesheetWidth" | "spritesheetHeight" | "iconImageSrcs" - | "sliceLayout" + | "sliceMode" + | "gridX" + | "gridY" | "sliceCount" | "frames" | "frameCount" @@ -2436,7 +2438,9 @@ mod tests { let mut job = external_generation_job_record_fixture(Some("lease-1")); job.dedupe_key = "external-api-generation:conversation-1:7:icon-spritesheet".to_string(); let response = json!({ - "sliceLayout": "grid-2x2", + "sliceMode": "grid", + "gridX": 2, + "gridY": 2, "iconImageSrcs": [ { "name": "素材 1", "imageSrc": "/api/assets/object/one.png" }, { "name": "素材 2", "imageSrc": "/api/assets/object/two.png" }, @@ -2449,7 +2453,7 @@ mod tests { serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) .expect("worker result should be valid JSON"); - assert_eq!(payload["result"]["sliceLayout"], json!("grid-2x2")); + assert_eq!(payload["result"]["sliceMode"], json!("grid")); assert_eq!( payload["result"]["iconImageSrcs"].as_array().map(Vec::len), Some(4) @@ -2705,7 +2709,9 @@ mod tests { "spritesheetImageSrc": "/api/assets/object/core-sheet.png", "spritesheetWidth": 1024, "spritesheetHeight": 1024, - "sliceLayout": "grid-2x2", + "sliceMode": "grid", + "gridX": 2, + "gridY": 2, "spritesheetResource": { "resourceId": "sheet-resource-1", "objectKey": "users/user-1/core-sheet.png", @@ -2729,7 +2735,7 @@ mod tests { serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) .expect("游戏创作客户端完成结果应持久化为合法 JSON"); - assert_eq!(payload["result"]["sliceLayout"], json!("grid-2x2")); + assert_eq!(payload["result"]["sliceMode"], json!("grid")); assert_eq!( payload["result"]["iconImageSrcs"].as_array().map(Vec::len), Some(4) diff --git a/server-rs/crates/api-server/src/external_mcp.rs b/server-rs/crates/api-server/src/external_mcp.rs index 5ee492476..6b6c469f7 100644 --- a/server-rs/crates/api-server/src/external_mcp.rs +++ b/server-rs/crates/api-server/src/external_mcp.rs @@ -54,7 +54,7 @@ const SKILL_REQUESTS_AND_OUTPUTS_URI: &str = "genarrative://external-editor/skill/references/requests-and-outputs.md"; const MAX_MCP_REST_RESPONSE_BYTES: usize = 4 * 1024 * 1024; -const MCP_INSTRUCTIONS: &str = r#"陶泥儿外部编辑器工具。先创建或复用画布项目,并创建与画布同名的素材文件夹;生成结果应同时写入画布和素材库。参考本地文件时先走上传票据和对象确认,不要把 Data URL、Blob URL 或临时签名 URL写入生成参数。所有生成工具都是异步提交:必须提供 idempotencyKey,提交后按 pollAfterMs 调用 get_external_editor_generation_job,只有 status=completed 时消费 result;查询超时不能重新提交。warning 表示主结果可用但存在降级,sliceWarning 表示完整透明图集可用但切片未完成。详细说明、OpenAPI、Skill 主入口和分主题 references 见 resources/list;需要本地文件编排或不支持 MCP 时再下载 skill.zip。"#; +const MCP_INSTRUCTIONS: &str = r#"陶泥儿外部编辑器工具。先创建或复用画布项目,并创建与画布同名的素材文件夹;生成结果应同时写入画布和素材库。参考本地文件时先走上传票据和对象确认,不要把 Data URL、Blob URL 或临时签名 URL写入生成参数。所有生成工具都是异步提交:必须提供 idempotencyKey,提交后按 pollAfterMs 调用 get_external_editor_generation_job,只有 status=completed 时消费 result;查询超时不能重新提交。图集生成可用 sliceMode=connected-components(默认连通域切分)或 grid(必须同时提供 gridX/gridY)。warning 表示主结果可用但存在降级,sliceWarning 表示完整透明图集可用但切片未完成。详细说明、OpenAPI、Skill 主入口和分主题 references 见 resources/list;需要本地文件编排或不支持 MCP 时再下载 skill.zip。"#; #[derive(Clone, Debug)] struct McpOperation { diff --git a/server-rs/crates/api-server/src/prompt/icon_spec.rs b/server-rs/crates/api-server/src/prompt/icon_spec.rs index 51469fb1b..c68cacb2e 100644 --- a/server-rs/crates/api-server/src/prompt/icon_spec.rs +++ b/server-rs/crates/api-server/src/prompt/icon_spec.rs @@ -276,13 +276,17 @@ pub(crate) fn build_spritesheet_prompt( ) } -pub(crate) fn build_grid_2x2_spritesheet_prompt( +pub(crate) fn build_grid_spritesheet_prompt( user_prompt: &ValidatedEditorIconSpritesheetPrompt, screen_color: EditorScreenBackgroundColor, genre: Option, + columns: u32, + rows: u32, ) -> String { format!( - "{}\n\n固定 2×2 游戏核心素材图集合同:画面必须严格分为左上、右上、左下、右下四个等大的独立槽位;每个槽位只放一个完整、可单独用于游戏运行时的主体。四个槽位必须按用户给出的四条素材需求顺序对应,且每格都必须有清晰可见的主体。禁止生成任何额外图标、同一主体的多个姿势、序列帧、棋盘、场景、边框、流程箭头、标签、文字、Logo、装饰小物或第五个素材;禁止主体跨格、触碰或重叠。输出须是单张图集,背景只使用统一纯色以便透明化。", + "{}\n\n固定网格游戏核心素材图集合同:画面必须严格分为 {}×{} 个等大的独立槽位;每个槽位只放一个完整、可单独用于游戏运行时的主体。素材按从左到右、从上到下顺序对应,禁止主体跨格、触碰或重叠。输出须是单张图集,背景只使用统一纯色以便透明化。", + columns, + rows, build_spritesheet_prompt(user_prompt, screen_color, genre), ) } diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs index aeffb002d..96982edaa 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs @@ -21,7 +21,7 @@ pub use sheet::{ GeneratedAssetSheetSliceImage, crop_generated_asset_sheet_view_edge_matte, crop_generated_asset_sheet_view_edge_matte_with_options, prepare_generated_icon_spritesheet_all_by_connected_components, - prepare_generated_icon_spritesheet_grid_2x2, slice_generated_asset_sheet, + prepare_generated_icon_spritesheet_grid, slice_generated_asset_sheet, slice_generated_asset_sheet_two_items_per_row, slice_generated_icon_spritesheet_all_by_connected_components, }; diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs index b0d7ec81b..ad75b55ce 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs @@ -254,35 +254,38 @@ pub fn prepare_generated_icon_spritesheet_all_by_connected_components( ) } -/// Prepares the four authoritative slices of a provider-generated 2×2 atlas. +/// Prepares the authoritative slices of a provider-generated rectangular grid atlas. /// /// This deliberately does not inspect connected components. A caller that /// requested the fixed layout has already constrained the provider to place /// one complete asset in every quadrant; trying to infer individual pieces /// again would turn highlights and detached effects into extra game assets. -pub fn prepare_generated_icon_spritesheet_grid_2x2( +pub fn prepare_generated_icon_spritesheet_grid( image: &crate::DownloadedImage, + columns: u32, + rows: u32, ) -> Result { let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { GeneratedAssetSheetError::decode_image(format!("图标 spritesheet 解码失败:{error}")) })?; let source = apply_generated_asset_sheet_green_screen_alpha(source).into_rgba8(); let (width, height) = source.dimensions(); - if width < 2 || height < 2 { + if columns == 0 || rows == 0 || width < columns || height < rows { return Err(GeneratedAssetSheetError::invalid_request( - "2×2 图标 spritesheet 尺寸过小,无法切割。", + "网格图标 spritesheet 的尺寸或网格参数无效,无法切割。", )); } - let mut icons = Vec::with_capacity(4); - for index in 0..4usize { - let row = (index / 2) as u32; - let col = (index % 2) as u32; - let cell = resolve_generated_asset_sheet_cell_bounds(width, height, 2, row, col); + let cell_count = columns.saturating_mul(rows) as usize; + let mut icons = Vec::with_capacity(cell_count); + for index in 0..cell_count { + let row = (index as u32) / columns; + let col = (index as u32) % columns; + let cell = resolve_generated_asset_sheet_cell_bounds(width, height, columns, row, col); let foreground = detect_generated_asset_sheet_visible_bounds_in_cell(&source, cell) .ok_or_else(|| { GeneratedAssetSheetError::invalid_request(format!( - "2×2 图标 spritesheet 的第 {} 个格子没有可见素材。", + "网格图标 spritesheet 的第 {} 个格子没有可见素材。", index + 1 )) })?; @@ -295,7 +298,7 @@ pub fn prepare_generated_icon_spritesheet_grid_2x2( y1: foreground.y1.saturating_add(pad_y).min(cell.y1), }; icons.push(GeneratedAssetSheetConnectedIconPlanItem { - name: format!("2×2 素材 {}", index + 1), + name: format!("网格素材 {}", index + 1), crop, }); } @@ -1009,7 +1012,7 @@ mod tests { } #[test] - fn grid_2x2_plan_keeps_one_durable_slice_per_quadrant_despite_detached_details() { + fn grid_plan_keeps_one_durable_slice_per_cell_despite_detached_details() { let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(128, 128, Rgba([0, 255, 0, 255])); let colors = [ [240, 80, 80, 255], @@ -1036,8 +1039,8 @@ mod tests { extension: "png".to_string(), }; - let plan = prepare_generated_icon_spritesheet_grid_2x2(&source) - .expect("declared 2x2 atlas should prepare"); + let plan = prepare_generated_icon_spritesheet_grid(&source, 2, 2) + .expect("declared grid atlas should prepare"); let icons = (0..plan.len()) .map(|index| plan.encode(index).expect("quadrant should encode")) .collect::>(); diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index eae2857aa..d846e24e3 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -1147,13 +1147,14 @@ describe('editorProjectClient', () => { referenceId: 'editor-resource-icon-spec', referenceImageSrcs: references, iconDescriptions: ['返回按钮'], + sliceMode: 'connected-components', }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/icon-spritesheets/generations', expect.objectContaining({ - body: expect.stringContaining( - '"model":"gemini-3.1-flash-image-preview"', + body: expect.stringMatching( + /(?=.*"model":"gemini-3\.1-flash-image-preview")(?=.*"sliceMode":"connected-components")/, ), }), '生成图标素材失败', diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 6ad26770f..e84825190 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -380,6 +380,9 @@ export type EditorIconSpritesheetGenerationInput = { referenceId: string; referenceImageSrcs?: string[]; iconDescriptions: string[]; + sliceMode?: 'connected-components' | 'grid'; + gridX?: number; + gridY?: number; model?: string; screenColor?: string; segModel?: string; @@ -516,6 +519,9 @@ export type EditorIconSpritesheetGenerationResult = { spritesheetWidth: number; spritesheetHeight: number; iconImageSrcs: EditorIconSpritesheetIconResult[]; + sliceMode?: 'connected-components' | 'grid'; + gridX?: number; + gridY?: number; sliceWarning?: EditorIconSpritesheetSliceWarning | null; prompt: string; actualPrompt?: string | null; @@ -1311,6 +1317,9 @@ export async function generateEditorIconSpritesheet( ? { referenceImageSrcs: input.referenceImageSrcs } : {}), iconDescriptions, + ...(input.sliceMode ? { sliceMode: input.sliceMode } : {}), + ...(input.gridX !== undefined ? { gridX: input.gridX } : {}), + ...(input.gridY !== undefined ? { gridY: input.gridY } : {}), model, ...(input.screenColor ? { screenColor: input.screenColor } : {}), ...(input.segModel ? { segModel: input.segModel } : {}), -- 2.52.0 From fcde920e5e62f31f8e3d37e869cce200ba28d7a9 Mon Sep 17 00:00:00 2001 From: Git Hooks Test Date: Tue, 15 Sep 2026 20:01:21 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E8=AF=8A=E6=96=AD=E4=B8=8E=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一 DirectProject 与 Agent Runtime 错误事件和项目诊断落库`n持久化失败上下文并提供脱敏详情读取`n修复 npm 源码模块与自定义登记素材的完成门误判`n约束试玩次数并补充 AGC 错误诊断规范与验收计划 --- .../src-tauri/src/agent.rs | 2 + .../src-tauri/src/agent/codex_app_server.rs | 53 +++++- .../src-tauri/src/agent/direct_runtime.rs | 176 +++++++++++++++--- .../src-tauri/src/agent/direct_tool_bridge.rs | 28 ++- .../src-tauri/src/agent/direct_tools_mcp.rs | 4 +- .../src-tauri/src/agent/runtime_error.rs | 167 +++++++++++++++++ .../src-tauri/src/agent/runtime_state.rs | 20 ++ .../src-tauri/src/commands.rs | 32 ++++ .../src-tauri/src/main.rs | 1 + apps/ai-game-creator-shell/src/App.tsx | 19 +- ...计划】AGC统一错误诊断与验收反馈-2026-09-15.md | 35 ++++ ...碑】AGC统一错误诊断与验收反馈-2026-09-15.md | 39 ++++ .../shared-memory/decision-log.md | 9 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 12 ++ 14 files changed, 561 insertions(+), 36 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs create mode 100644 docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md create mode 100644 docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 17b8a6700..b9d90f269 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -28,6 +28,7 @@ mod prompt; mod runtime_actions; mod runtime_adapter; mod runtime_driver; +mod runtime_error; mod runtime_protocol; mod runtime_state; mod runtime_tools; @@ -56,6 +57,7 @@ pub(crate) use prompt::*; pub(crate) use runtime_actions::*; pub(crate) use runtime_adapter::*; pub(crate) use runtime_driver::*; +pub(crate) use runtime_error::*; pub(crate) use runtime_protocol::*; pub(crate) use runtime_state::*; pub(crate) use runtime_tools::*; 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 48edc498f..ea4d981a8 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 @@ -271,6 +271,51 @@ fn game_creator_codex_app_server_error_kind(kind: &str) -> platform_llm::LlmErro )) } +fn game_creator_codex_app_server_error_kind_with_machine_detail( + kind: &str, + error: &serde_json::Value, +) -> platform_llm::LlmError { + let mut fields = Vec::new(); + if let Some(object) = error.as_object() { + if let Some(code) = object.get("code").and_then(serde_json::Value::as_str) { + if !code.is_empty() + && code.len() <= 80 + && code + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) + { + fields.push(format!("code={code}")); + } + } + let keys = object + .keys() + .filter(|key| { + matches!( + key.as_str(), + "httpConnectionFailed" + | "responseStreamConnectionFailed" + | "responseStreamDisconnected" + | "responseTooManyFailedAttempts" + | "activeTurnNotSteerable" + | "codexErrorInfo" + ) + }) + .cloned() + .collect::>(); + if !keys.is_empty() { + fields.push(format!("fields={}", keys.join(","))); + } + } + let suffix = if fields.is_empty() { + String::new() + } else { + format!(" detail={}", fields.join(" ")) + }; + platform_llm::LlmError::InvalidRequest(format!( + "{GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX}{kind}{suffix}" + )) +} + fn game_creator_codex_app_server_error_http_status( info: &serde_json::Value, field: &str, @@ -425,7 +470,7 @@ fn game_creator_codex_app_server_failed_turn_error( return game_creator_codex_app_server_error_kind("unauthorized"); } let Some(info) = error.get("codexErrorInfo").filter(|info| !info.is_null()) else { - return game_creator_codex_app_server_error_kind("other"); + return game_creator_codex_app_server_error_kind_with_machine_detail("other", error); }; if let Some(kind) = info.as_str() { return match kind { @@ -449,8 +494,8 @@ fn game_creator_codex_app_server_failed_turn_error( game_creator_codex_app_server_error_kind("thread-rollback-failed") } "sandboxError" => game_creator_codex_app_server_error_kind("sandbox-error"), - "other" => game_creator_codex_app_server_error_kind("other"), - _ => game_creator_codex_app_server_error_kind("other"), + "other" => game_creator_codex_app_server_error_kind_with_machine_detail("other", error), + _ => game_creator_codex_app_server_error_kind_with_machine_detail("other", error), }; } for field in [ @@ -466,7 +511,7 @@ fn game_creator_codex_app_server_failed_turn_error( if info.get("activeTurnNotSteerable").is_some() { return game_creator_codex_app_server_error_kind("active-turn-not-steerable"); } - game_creator_codex_app_server_error_kind("other") + game_creator_codex_app_server_error_kind_with_machine_detail("other", error) } async fn isolate_game_creator_codex_app_server_terminal_unknown( 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 71cf43b44..64343f79f 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 @@ -1905,7 +1905,11 @@ fn direct_codex_error_is_mud_points_insufficient(error: &str) -> bool { || normalized.contains("insufficient-mud-points") } -fn record_direct_codex_turn_failure(root: &Path, failure: DirectCodexTurnFailure) -> String { +fn record_direct_codex_turn_failure( + root: &Path, + failure: DirectCodexTurnFailure, + client_turn_id: Option<&str>, +) -> String { let summary = direct_codex_failure_public_summary(&failure.error) .map(str::to_string) .unwrap_or_else(|| redact_agent_runtime_error(root, &failure.error, 320)); @@ -1942,18 +1946,53 @@ fn record_direct_codex_turn_failure(root: &Path, failure: DirectCodexTurnFailure } else { "未能保存项目诊断" }; - format!( - "direct-codex-failure:v1 stage={} retryable={} summary={};建议:{};{}", + let error_code = classify_direct_codex_error(&failure.error); + let unified_detail_ref = persist_agent_runtime_error( + root, + client_turn_id, + "direct-codex", failure.stage.id(), + error_code, + retryable, + &summary, + recovery_hint, + &failure.error, + None, + serde_json::json!({ + "legacyDiagnosticWritten": diagnostic_written, + }), + ) + .ok() + .map(|event| event.detail_ref); + format!( + "direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{}{}", + failure.stage.id(), + error_code, retryable, diagnostic["summary"] .as_str() .unwrap_or("未提供可安全展示的详细原因"), recovery_hint, diagnostics_suffix, + unified_detail_ref + .map(|path| format!(";详情:{path}")) + .unwrap_or_default(), ) } +fn persist_direct_codex_failure_context( + root: &Path, + client_turn_id: &str, + error: &str, +) -> Result<(), String> { + let item = direct_project_local_message_item( + "assistant", + error, + Some(&format!("direct-codex:{client_turn_id}:failure")), + )?; + append_direct_project_history_item_at(root, &item) +} + fn direct_taonier_art_generation_runtime_context( root: &Path, output_path: &str, @@ -2269,9 +2308,19 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec { } fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec { - let sources = direct_codex_game_outputs(root) + let mut source_paths = direct_codex_game_outputs(root) .into_iter() - .filter_map(|(relative_path, _, _)| std::fs::read_to_string(root.join(relative_path)).ok()) + .map(|(relative_path, _, _)| relative_path) + .collect::>(); + // npm/Phaser projects put the actual scene and loader code below `game/src`. + // Keep the canonical output list for manifest projection, but scan the + // complete bounded source list for the asset reference contract. + source_paths.extend(direct_npm_source_paths(root)); + source_paths.sort(); + source_paths.dedup(); + let sources = source_paths + .into_iter() + .filter_map(|relative_path| std::fs::read_to_string(root.join(relative_path)).ok()) .collect::>(); let mut available_paths = Vec::new(); if direct_taonier_art_base_is_valid(root) { @@ -2284,6 +2333,28 @@ fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec { available_paths.push(DIRECT_CODEX_SPRITESHEET_ASSET_PATH.to_string()); } available_paths.extend(direct_registered_taonier_slice_paths(root)); + // A project may have a valid, client-registered art-spritesheet at a + // project-specific path (for example a generated building sheet). The + // fixed canonical package paths above are compatibility candidates only; + // the manifest is the authority for additional runtime image identities. + if let Ok(manifest) = read_manifest_for_project(root) { + available_paths.extend( + manifest + .assets + .into_iter() + .filter(|asset| { + matches!( + asset.kind.as_str(), + "art-spritesheet" | "art-spritesheet-slice" | "game-background" + ) && asset.media_type == "image/png" + && asset.source.kind == GameCreationAppAssetSourceKind::Canvas + && asset.local_path.starts_with("assets/") + }) + .map(|asset| asset.local_path), + ); + } + available_paths.sort(); + available_paths.dedup(); available_paths .into_iter() .filter(|path| sources.iter().any(|source| source.contains(path.as_str()))) @@ -4087,7 +4158,17 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( { Ok(reply) => Ok(reply), Err(failure) => { - let error = record_direct_codex_turn_failure(root, failure); + let error = record_direct_codex_turn_failure( + root, + failure, + turn_emitter.map(|emitter| emitter.turn_id()), + ); + if let Some(emitter) = turn_emitter { + // Persist the safe terminal projection so the next DirectProject + // turn can answer a diagnostic question from evidence instead of + // guessing or starting another playtest. + let _ = persist_direct_codex_failure_context(root, emitter.turn_id(), &error); + } if let Some(emitter) = turn_emitter { emitter.emit("failed", Some("none"), None); } @@ -6620,26 +6701,27 @@ mod tests { #[test] fn direct_failure_diagnostic_is_redacted_and_persisted_with_a_stable_stage() { - let root = tempfile::tempdir().expect("temp dir"); - init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断") - .expect("init project"); + let parent = tempfile::tempdir().expect("temp dir"); + let root = parent.path().join("project"); + init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); let error = record_direct_codex_turn_failure( - root.path(), + &root, DirectCodexTurnFailure::new( DirectCodexFailureStage::ArtPreparation, "读取陶泥儿画布资源失败:https://provider.example/private?token=secret C:\\Users\\private\\project authorization=Bearer secret", ), + None, ); assert!(error - .starts_with("direct-codex-failure:v1 stage=art-preparation retryable=true summary=")); + .starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-failure retryable=true summary=")); assert!(error.contains(""), "{error}"); assert!(error.contains(""), "{error}"); assert!(!error.contains("authorization=Bearer secret"), "{error}"); assert!(!error.contains("?token=secret"), "{error}"); assert!(!error.contains("provider.example"), "{error}"); - let diagnostics = root.path().join(".agent/runtime/direct-codex-diagnostics"); + let diagnostics = root.join(".agent/runtime/direct-codex-diagnostics"); let entries = std::fs::read_dir(&diagnostics) .expect("diagnostic directory") .filter_map(Result::ok) @@ -6658,25 +6740,25 @@ mod tests { #[test] fn direct_failure_diagnostic_marks_project_history_shape_failure_as_not_retryable() { - let root = tempfile::tempdir().expect("temp dir"); - init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断") - .expect("init project"); + let parent = tempfile::tempdir().expect("temp dir"); + let root = parent.path().join("project"); + init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); let history_path = root - .path() .join(".agent/conversations/project.jsonl") .display() .to_string(); let error = record_direct_codex_turn_failure( - root.path(), + &root, DirectCodexTurnFailure::new( DirectCodexFailureStage::CodeGeneration, format!("DirectProject 历史记录类型无效:{history_path}"), ), + None, ); assert!( error.starts_with( - "direct-codex-failure:v1 stage=code-generation retryable=false summary=" + "direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary=" ), "{error}" ); @@ -6686,9 +6768,9 @@ mod tests { ), "{error}" ); - assert!(error.ends_with("已保存脱敏项目诊断"), "{error}"); + assert!(error.contains("已保存脱敏项目诊断"), "{error}"); - let diagnostics = root.path().join(".agent/runtime/direct-codex-diagnostics"); + let diagnostics = root.join(".agent/runtime/direct-codex-diagnostics"); let entries = std::fs::read_dir(&diagnostics) .expect("diagnostic directory") .filter_map(Result::ok) @@ -6702,19 +6784,20 @@ mod tests { #[test] fn direct_failure_diagnostic_marks_ambiguous_canvas_identity_as_not_retryable() { - let root = tempfile::tempdir().expect("temp dir"); - init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断") - .expect("init project"); + let parent = tempfile::tempdir().expect("temp dir"); + let root = parent.path().join("project"); + init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); let error = record_direct_codex_turn_failure( - root.path(), + &root, DirectCodexTurnFailure::new( DirectCodexFailureStage::ArtPreparation, "陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复", ), + None, ); assert!( - error.contains("stage=art-preparation retryable=false"), + error.contains("stage=art-preparation code=runtime-failure retryable=false"), "{error}" ); assert!(error.contains("历史画布资源不满足安全恢复条件"), "{error}"); @@ -6722,15 +6805,16 @@ mod tests { #[test] fn direct_failure_diagnostic_keeps_private_credential_storage_failure_actionable() { - let root = tempfile::tempdir().expect("temp dir"); - init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断") - .expect("init project"); + let parent = tempfile::tempdir().expect("temp dir"); + let root = parent.path().join("project"); + init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); let error = record_direct_codex_turn_failure( - root.path(), + &root, DirectCodexTurnFailure::new( DirectCodexFailureStage::ArtPreparation, "private-external-editor-credential-storage-preparation-failed: 本机开发者凭据存储目录未安全初始化;未创建远端凭据", ), + None, ); assert!( @@ -7830,6 +7914,40 @@ mod tests { .any(|warning| warning.contains("不得猜测切片"))); } + #[test] + fn direct_completion_scans_npm_scene_modules_for_registered_asset_references() { + let parent = tempfile::tempdir().expect("temp dir"); + let root = parent.path().join("project"); + init_local_game_project_at(&root, "direct-src-runtime", "源码模块素材引用") + .expect("init project"); + register_direct_taonier_art_package_fixture(&root); + register_direct_taonier_art_slice_entries_fixture(&root); + std::fs::write( + root.join("game/package.json"), + "{\"scripts\":{\"build\":\"vite build\"}}", + ) + .expect("package"); + std::fs::write(root.join("game/index.html"), "").expect("index"); + std::fs::write(root.join("game/style.css"), "body {}").expect("style"); + std::fs::write(root.join("game/game.js"), "import './src/scene.js';").expect("entry"); + std::fs::create_dir_all(root.join("game/src")).expect("src dir"); + std::fs::write( + root.join("game/src/scene.js"), + "const player = new Image(); player.src = '/assets/art-spritesheet-slices/player.png';", + ) + .expect("scene"); + std::fs::write( + root.join("assets/art-spritesheet-slices/player.png"), + tiny_opaque_png(), + ) + .expect("slice"); + + assert_eq!( + direct_game_sources_referenced_taonier_assets(&root), + vec!["assets/art-spritesheet-slices/player.png".to_string()] + ); + } + #[test] fn direct_output_sync_accepts_trusted_spec_and_background_without_a_historical_spritesheet() { let root = tempfile::tempdir().expect("temp dir"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 6c94bfe6a..12c6f8d27 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1078,7 +1078,9 @@ fn bridge_attempt(arguments: &Value) -> Result { .and_then(Value::as_u64) .ok_or_else(|| "工具参数 attempt 必须是 1 到 3 的整数".to_string())?; if !(1..=3).contains(&attempt) { - return Err("工具参数 attempt 必须是 1 到 3 的整数".to_string()); + return Err(format!( + "playtest-attempt-limit-exceeded: 本轮试玩最多 3 次,收到 attempt={attempt};请结束试玩并基于最近一次浏览器证据报告结果" + )); } Ok(attempt as usize) } @@ -2608,6 +2610,30 @@ async fn handle_direct_tool_bridge( } _ => bridge_tool_result("未知或未审核的客户端工具".to_string(), Vec::new(), true), }; + if result.get("isError").and_then(Value::as_bool) == Some(true) { + let message = result + .pointer("/content/0/text") + .and_then(Value::as_str) + .unwrap_or("客户端工具执行失败"); + let code = if message.contains("playtest-attempt-limit-exceeded") { + "playtest-attempt-limit-exceeded" + } else { + "tool-error" + }; + let _ = persist_agent_runtime_error( + &state.root, + None, + "agc-tools", + "tool-execution", + code, + true, + message, + "查看项目错误诊断后处理", + message, + None, + serde_json::json!({"tool": request.tool}), + ); + } Json(result) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 294b44c25..1005ed813 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -898,7 +898,9 @@ fn tool_attempt(arguments: &Value) -> Result { .and_then(Value::as_u64) .ok_or_else(|| "工具参数 attempt 必须是 1 到 3 的整数".to_string())?; if !(1..=3).contains(&attempt) { - return Err("工具参数 attempt 必须是 1 到 3 的整数".to_string()); + return Err(format!( + "playtest-attempt-limit-exceeded: 本轮试玩最多 3 次,收到 attempt={attempt};请结束试玩并基于最近一次浏览器证据报告结果" + )); } Ok(attempt as usize) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs new file mode 100644 index 000000000..4fe895680 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs @@ -0,0 +1,167 @@ +//! Shared, project-bound error events for Agent Runtime and DirectProject. +//! +//! Every caller supplies a safe public summary and a private detail. This +//! module is the only persistence boundary for the latter: it redacts project +//! paths and credentials before writing a bounded diagnostic sidecar. + +use super::{redact_agent_runtime_error, write_agent_runtime_json_sidecar_with_max_bytes}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(crate) const AGENT_RUNTIME_ERROR_SCHEMA_VERSION: &str = "agent-runtime-error.v1"; +pub(crate) const AGENT_RUNTIME_ERROR_MAX_DETAIL_CHARS: usize = 8 * 1024; + +static ERROR_EVENT_SEQUENCE: AtomicU64 = AtomicU64::new(1); + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub(crate) struct AgentRuntimeErrorEvent { + pub schema_version: &'static str, + pub event_id: String, + pub client_turn_id: Option, + pub source: String, + pub stage: String, + pub code: String, + pub retryable: bool, + pub occurred_at_unix_nanos: String, + pub elapsed_ms: Option, + pub public_text: String, + pub recovery_hint: String, + pub detail_ref: String, + pub persistence_failed: bool, + pub metadata: Value, +} + +pub(crate) fn persist_agent_runtime_error( + root: &Path, + client_turn_id: Option<&str>, + source: &str, + stage: &str, + code: &str, + retryable: bool, + public_text: &str, + recovery_hint: &str, + detail: &str, + elapsed_ms: Option, + metadata: Value, +) -> Result { + let occurred_at_unix_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("读取错误事件时间失败:{error}"))? + .as_nanos(); + let sequence = ERROR_EVENT_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let event_id = format!("error-{occurred_at_unix_nanos}-{sequence}"); + let detail_ref = format!(".agent/runtime/errors/{event_id}.json"); + let safe_detail = + redact_agent_runtime_error(root, detail, AGENT_RUNTIME_ERROR_MAX_DETAIL_CHARS); + let diagnostic = serde_json::json!({ + "schemaVersion": AGENT_RUNTIME_ERROR_SCHEMA_VERSION, + "eventId": event_id, + "clientTurnId": client_turn_id, + "source": source, + "stage": stage, + "code": code, + "retryable": retryable, + "occurredAtUnixNanos": occurred_at_unix_nanos.to_string(), + "elapsedMs": elapsed_ms, + "publicText": public_text, + "recoveryHint": recovery_hint, + "detail": safe_detail, + "metadata": metadata, + }); + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &detail_ref, + "统一 Agent Runtime 错误诊断", + &diagnostic, + 16 * 1024, + )?; + Ok(AgentRuntimeErrorEvent { + schema_version: AGENT_RUNTIME_ERROR_SCHEMA_VERSION, + event_id, + client_turn_id: client_turn_id.map(str::to_string), + source: source.to_string(), + stage: stage.to_string(), + code: code.to_string(), + retryable, + occurred_at_unix_nanos: occurred_at_unix_nanos.to_string(), + elapsed_ms, + public_text: public_text.to_string(), + recovery_hint: recovery_hint.to_string(), + detail_ref, + persistence_failed: false, + metadata, + }) +} + +pub(crate) fn classify_direct_codex_error(error: &str) -> &'static str { + let normalized = error.to_ascii_lowercase(); + if normalized.contains("等待 turn/completed 超时") { + "turn-idle-timeout" + } else if normalized.contains("达到 directproject 硬上限") { + "turn-hard-timeout" + } else if normalized.contains("transport closed") || normalized.contains("连接已关闭") { + "transport-closed" + } else if normalized.contains("playtest-attempt-limit-exceeded") { + "playtest-attempt-limit-exceeded" + } else if (normalized.contains("tool") || normalized.contains("工具")) + && normalized.contains("参数") + { + "tool-invalid-arguments" + } else if normalized.contains("codex app-server-error:other") { + "app-server-other" + } else { + "runtime-failure" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_event_is_bounded_and_redacts_private_detail() { + let parent = tempfile::tempdir().expect("temp root"); + let root = parent.path().join("project"); + crate::project::init_local_game_project_at(&root, "runtime-error", "错误事件") + .expect("init project"); + let event = persist_agent_runtime_error( + &root, + Some("turn-123"), + "direct-codex", + "code-generation", + "turn-idle-timeout", + true, + "本轮没有收到完成事件", + "查看诊断后重试", + "C:\\Users\\private\\project https://provider.example/a?token=secret", + Some(1200), + serde_json::json!({"lastEvent":"item/started"}), + ) + .expect("persist event"); + assert_eq!(event.code, "turn-idle-timeout"); + let path = root.join(&event.detail_ref); + let text = std::fs::read_to_string(path).expect("diagnostic"); + assert!(text.contains("")); + assert!(text.contains("")); + assert!(!text.contains("token=secret")); + } + + #[test] + fn timeout_and_tool_errors_have_distinct_codes() { + assert_eq!( + classify_direct_codex_error("等待 turn/completed 超时"), + "turn-idle-timeout" + ); + assert_eq!( + classify_direct_codex_error("达到 DirectProject 硬上限"), + "turn-hard-timeout" + ); + assert_eq!( + classify_direct_codex_error("工具参数 attempt 必须是 1 到 3 的整数"), + "tool-invalid-arguments" + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index f23bf5f5b..ede8bb200 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -101,6 +101,26 @@ pub(crate) fn append_game_creator_agent_runtime_terminal_public_message_at( error: &str, ) -> Result<(), String> { let content = game_creator_agent_runtime_failure_conversation_message(&state.agent_id, error); + // Keep the existing conversation projection, but also persist one common + // bounded diagnostic event for every Agent Runtime terminal failure. This + // makes non-DirectProject failures observable through the same detail API. + let _ = persist_agent_runtime_error( + root, + Some(&state.run_id), + "agent-runtime", + &state.phase, + "agent-runtime-terminal", + false, + &content, + "查看项目错误诊断后处理", + error, + None, + serde_json::json!({ + "agentId": state.agent_id, + "sessionId": state.session_id, + "runId": state.run_id, + }), + ); let status = if state.phase == "budget-exhausted" { "budget-exhausted" } else if state.phase == "needs-reconciliation" { diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 828048813..53c77d151 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5244,6 +5244,38 @@ pub(crate) async fn read_direct_project_conversation( .map_err(|error| format!("读取 DirectProject 历史后台任务失败:{error}"))? } +#[tauri::command] +pub(crate) async fn read_agent_runtime_error_detail( + project_path: String, + detail_ref: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + let relative = detail_ref.trim(); + let Some(file_name) = relative.strip_prefix(".agent/runtime/errors/") else { + return Err("错误诊断引用不在项目错误目录内".to_string()); + }; + if file_name.is_empty() + || file_name.contains(['/', '\\']) + || file_name.contains("..") + || !file_name.ends_with(".json") + { + return Err("错误诊断引用格式无效".to_string()); + } + let path = root.join(relative); + prepare_game_creator_private_path_for_read(&path, false, "统一错误诊断")?; + let bytes = std::fs::read(&path).map_err(|error| format!("读取错误诊断失败:{error}"))?; + if bytes.len() > 16 * 1024 { + return Err("错误诊断超过读取上限".to_string()); + } + let text = String::from_utf8(bytes).map_err(|_| "错误诊断不是 UTF-8 文本".to_string())?; + Ok(redact_agent_runtime_error(root, &text, 16 * 1024)) + }) + .await + .map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))? +} + #[tauri::command] pub(crate) fn append_local_conversation_message( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 2f2f5b7e2..b95ebf358 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2768,6 +2768,7 @@ fn main() { archive_game_creator_agent_session, read_local_conversation, read_direct_project_conversation, + read_agent_runtime_error_detail, append_local_conversation_message, append_direct_project_conversation_message, build_local_project_index, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index d1b1cf38c..723c0429c 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -6519,8 +6519,25 @@ export function App({ void captureAgentRuntimeError(error, PROJECT_SUPERVISOR_AGENT_ID); const message = error instanceof Error ? error.message : String(error); + let persistedDetail = ''; + const detailRef = message.match( + /详情:(\.agent\/runtime\/errors\/[^\s;]+)/, + )?.[1]; + if (detailRef && directInvoke) { + try { + persistedDetail = await directInvoke( + 'read_agent_runtime_error_detail', + { + projectPath: directProjectPath, + detailRef, + }, + ); + } catch { + persistedDetail = ''; + } + } const visibleMessage = projectRuntimeVisibleError( - message, + persistedDetail ? `${message}\n\n${persistedDetail}` : message, '陶泥儿智能创作', true, ); diff --git a/docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md b/docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md new file mode 100644 index 000000000..031cd576c --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md @@ -0,0 +1,35 @@ +# AGC 统一错误诊断与验收反馈实施计划 + +Version: 1.0 +Status: active +Date: 2026-09-15 +Parent Milestone: `【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md` + +## 修改边界 + +1. 新增 `agent/runtime_error.rs`,承载统一事件字段、code/stage 白名单、脱敏后的 public projection、项目错误 JSONL/sidecar 落库和 detail 读取边界。 +2. `direct_runtime.rs` 使用统一事件替代仅写 `failure.json` 的路径;失败 assistant 投影带稳定 ID,下一轮 prompt 注入最近失败事件摘要。 +3. `codex_app_server.rs` 将 failed turn、idle/hard timeout、transport close、invalid terminal 和 stderr tail 转成稳定事件字段;不公开原始 detail。 +4. `direct_tool_bridge.rs` 与 `direct_tools_mcp.rs` 让 attempt 由客户端回合状态约束,越界请求返回终态工具错误;不扩展重试预算。 +5. `direct_runtime.rs` 的素材扫描递归覆盖可执行源码模块,基于 manifest 身份和浏览器 URL 映射判定;补充模块引用回归测试。 +6. 前端读取后端 `publicText/detailRef`,在现有 Runtime 错误面板中加入详情入口;不在 React 侧重新分类错误。 + +## 实现顺序 + +先写统一事件模型和 Rust 单测,再接 direct failure/app-server/tool bridge,随后接 prompt/history 与前端详情,最后修素材验收和 attempt 生命周期。每一步保留原有脱敏和失败关闭行为。 + +## 验证命令 + +- `cargo fmt --check` +- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml runtime_error direct_runtime codex_app_server direct_tool_bridge` +- `npm run --prefix apps/ai-game-creator-shell typecheck` +- `npm run check:encoding` +- `git diff --check` +- 必要时运行 AGC deterministic playable E2E;真实 Provider smoke 与浏览器双视口 smoke 单独报告。 + +## 风险与回滚 + +- 统一事件 schema 只新增项目内文件和对话投影,不修改已有 manifest、公开 API 或 SpacetimeDB schema。 +- 若前端详情读取失败,仍展示安全 `publicText`,不阻塞错误终态。 +- 若素材身份无法映射,继续失败关闭并记录明确 code,不回退为路径字符串通过。 +- 回滚可删除新事件写入和详情入口,保留旧 `failure.json` 读取兼容。 diff --git a/docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md b/docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md new file mode 100644 index 000000000..bcc95ef90 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md @@ -0,0 +1,39 @@ +# AGC 统一错误诊断与验收反馈 + +Version: 1.0 +Status: active +Date: 2026-09-15 +Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-15 AGC 统一错误事件、诊断落库与验收反馈” + +## 目标 + +让 DirectProject 和共享 Agent Runtime 对失败使用同一份安全、可追踪、可恢复的错误事件合同;用户追问失败原因时能够读取上一轮证据;构建与浏览器验收只依据真实源码、manifest 身份和运行时证据判断。 + +## 范围 + +- 统一错误事件模型与项目内诊断落库。 +- DirectProject 失败 assistant 投影、下一轮诊断上下文和前端详情入口。 +- app-server 终态/超时、内置 MCP 工具错误和试玩 attempt 上限的分类。 +- 游戏源码模块素材扫描、manifest 身份映射与浏览器观察映射。 +- 定向 Rust/前端回归和现有 AGC 运行时门禁。 + +## 不做 + +- 不改变 Provider、External Editor 或 app-server 的 wire 协议。 +- 不放宽项目写锁、凭据隔离、工具白名单或完成门安全边界。 +- 不迁移历史项目文件;旧诊断只读兼容,新增事件使用新 schema。 +- 不把原始 stderr、请求正文或绝对路径展示给用户。 + +## 验收标准 + +1. 任一 DirectProject 失败均生成统一事件、稳定 `eventId` 和有界诊断引用;落库失败不覆盖原始错误。 +2. 失败安全投影写入对话历史,下一轮能读取 `publicText / code / stage / detailRef`,不会因追问而自动试玩。 +3. 结构化 failed turn、idle/hard timeout、transport close、MCP 参数错误和 `other` 各有稳定 code 与 recoveryHint。 +4. `attempt` 由客户端按回合分配并有上限;越界调用不会让回合继续等待。 +5. `game/src` 下模块引用已登记素材、Vite dist 稳定映射和浏览器实际观察均能通过;未登记素材仍失败。 +6. 脱敏测试证明 Token、Cookie、URL/query、私钥、宿主绝对路径和 stderr 私密内容不会进入用户文本。 + +## 依赖 + +- 现有 `direct_project_history`、`runtime_state`、`codex_app_server`、`direct_tool_bridge` 与浏览器 validation 证据。 +- 现有 DirectProject 诊断 sidecar 和 manifest 资源身份。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d18fbd8ab..bd8416d40 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8741,6 +8741,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响范围:新增 `apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs`(+ `main.rs` 注册)、`src/features/resource-canvas/{resourceCanvasAssetGenerationTaskModel.ts,resourceCanvasAssetGenerationQueue.ts,ResourceCanvasAssetGenerationTasksPanelView.tsx}`;改动 `ResourceCanvasAssetGenerationPanelView.tsx` / `ResourceCanvasGenerationPanelView.tsx` / `src/view/project-development/index.tsx`;测试改动 `tests/{resourceCanvasAssetGenerationBackgroundClose.test.tsx,resourceCanvasAssetGenerationQueue.test.ts,resourceCanvasAssetGenerationTasksPanel.test.tsx}`(新增)与 `tests/appSurface/project-development.suite.ts`(把「每个入口一次 `generate_local_project_asset`」改成 `start_local_project_asset_generation` + `list_...` 轮询桩,载荷断言逐字不变)。**未动**:external v1 / OpenAPI、`packages/`、SpacetimeDB、音频入口的 pending-edit 账本语义、生成参数与 IPC 载荷字段名。 - 关联文档:`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md`(§4 / §4a / §8)、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`(S11a / §7.3)。 + ## 2026-09-15 非 Suno 的 VectorEngine 能力切换到 Tiantoken - 决策:新增本地私密环境变量 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`(图片 timeout 可独立配置),承载原 VectorEngine 的文本和图片;`VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 仅保留给 Suno 背景音乐与 Suno 音效。编辑器 SFX V2 继续走 ElevenLabs。 @@ -8751,3 +8752,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:旧版 Vidu `audio1.0` 的 submit / poll / download builder、旧视觉小说与创建音效死代码、对应 platform-audio 请求类型和测试全部删除。历史素材的 `audio1.0` 展示与定价兼容数据保留;新编辑器音效仍只走 ElevenLabs,Suno 音乐链路不变。 - 验证:platform-audio 全量测试 55 条通过,api-server `cargo check` 通过,fmt / 编码 / diff 检查通过;仓库现役源码不再包含 `VIDU_AUDIO_MODEL`、`AudioTaskKind::SoundEffect` 或 Vidu submit/poll 实现。 + +## 2026-09-15 AGC 统一错误事件与项目诊断落库 + +- 背景:DirectProject 的 app-server 超时、MCP 参数错误、浏览器完成门误判和普通 Agent Runtime 失败分别投影为短文案;失败正文没有稳定落库,下一轮模型看不到上一轮失败证据,用户追问原因时可能继续试玩或重复修改。 +- 决策:新增 `agent/runtime_error.rs` 作为统一错误事件与有界诊断 sidecar 边界。DirectProject 失败、Agent Runtime terminal failure 均持久化 `.agent/runtime/errors/.json`,并将脱敏 assistant 终态写回 `project.jsonl`;前端只通过 `read_agent_runtime_error_detail` 读取脱敏详情。旧 `failure.json` 保留兼容,不把原始 stderr、凭据、URL/query、宿主绝对路径写入用户文本。 +- 决策:错误使用稳定 `source / stage / code / retryable / publicText / recoveryHint / detailRef` 字段;试玩 attempt 越界返回终态错误并停止继续等待。素材完成门扫描实际 npm 源码模块,并把 manifest 中合法的自定义 art-spritesheet 路径纳入候选,构建和浏览器观察仍需通过既有完成门。 +- 关联规范:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-15 AGC 统一错误事件、诊断落库与验收反馈”;开发期计划见 `docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md` 与对应实施计划。 + diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 657dcb9fc..3f091c118 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1379,3 +1379,15 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过 ## 2026-09-14 新游戏策划到真实美术接入的连续交付 DirectProject 在收到完整游戏策划或游戏制作请求后,必须把视觉素材作为同一交付链路处理:先读取当前项目已登记资源;策划案包含角色、对象、背景、特效、界面或其它视觉实体且现有资源不满足时,Codex 必须在同一游戏实现任务中调用审核的 `agc_tools` 生图或编辑工具,读取返回的资源身份与相对路径,把真实产物接入游戏源码,再构建并验证实际渲染。生成了素材但源码仍使用 emoji、CSS 形状或临时占位图替代策划要求的视觉元素,不能报告游戏完成。只有策划明确不需要视觉素材,或现有已登记素材完全满足需求时,才允许跳过生图;图片生成、处理、登记和接入不因用户没有重复输入“生图”而降级为可选建议。 + +## 2026-09-15 AGC 统一错误事件、诊断落库与验收反馈 + +DirectProject、Agent Runtime、Provider、app-server、内置 MCP、命令执行、构建和浏览器试玩的失败必须先转换为统一的 `AgentRuntimeErrorEvent`,再分别投影到用户消息、运行面板和项目诊断文件;业务模块不得自行拼接只有一句“执行失败”的终态文案。统一事件至少包含 `schemaVersion / eventId / clientTurnId / source / stage / code / retryable / occurredAt / elapsedMs / publicText / recoveryHint / detailRef`,其中 `publicText` 是脱敏后的可行动摘要,`detailRef` 指向项目内有界诊断记录;Token、Cookie、URL/query、私钥、宿主绝对路径、原始请求正文和未脱敏 stderr 不得进入对话或用户可见文本。 + +项目内统一落库目录为 `.agent/runtime/errors/`,事件记录采用幂等 JSONL 或 JSON sidecar;写入失败不能覆盖原始业务错误,但必须在事件中标记 `persistenceFailed`。DirectProject 对话历史必须持久化本轮用户消息、终态错误的安全 assistant 投影和诊断引用,使下一轮能够读取上一轮失败证据。前端只展示 `publicText`,点击详情后按 `detailRef` 读取有界、脱敏的诊断,不直接展示私有 `detail`。 + +`turn/completed` 等待超时必须区分 `idle-timeout`、`hard-timeout`、`transport-closed`、`failed-turn`、`invalid-terminal` 和 `tool-error`;收到内置工具参数错误后必须结束当前工具调用并进入可行动终态,不能继续使用越界的试玩 `attempt` 或无限等待。试玩次数由客户端按当前 `clientTurnId` 持久化分配,模型不能自由递增;超过上限必须返回一次终态并停止回合。 + +游戏素材完成门必须扫描实际参与构建的 `game/` 源码模块,读取 manifest 的登记身份与相对路径,并把构建后的 URL 映射回登记身份。固定素材路径只能作为兼容候选,不能作为唯一准入。已登记且被真实源码引用、被构建纳入并在浏览器证据中观察到的资源通过;未登记、来源不匹配或只存在于设计规范中的资源继续失败关闭。 + +验收至少覆盖:普通错误、结构化 app-server failed turn、idle/hard timeout、MCP 参数错误、历史落库失败、脱敏边界、下一轮诊断上下文、源码子模块素材引用、Vite 构建 URL 映射以及试玩次数上限。统一错误事件和诊断落库先于 UI 美化或增加重试预算;不能用延长超时、删除完成门或把失败投影为成功来规避问题。 -- 2.52.0