diff --git a/.gitattributes b/.gitattributes index 1cf14fb9f..572a7e25f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -23,3 +23,6 @@ *.meta text *.anim text *.controller text + +# Rust ts-rs 生成的共享契约:保留在仓库中供 TS 消费,但不作为手写源文件统计。 +packages/shared/src/contracts/generated/** linguist-generated=true diff --git a/.prettierrc.json b/.prettierrc.json index 7d2081a95..c4f9a8e5d 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,5 +1,14 @@ { "singleQuote": true, "semi": true, - "trailingComma": "all" + "trailingComma": "all", + "overrides": [ + { + "files": "packages/shared/src/contracts/generated/**/*.ts", + "options": { + "printWidth": 1000, + "singleQuote": false + } + } + ] } diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs index c6b20f6dd..ef18c3780 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs @@ -676,11 +676,11 @@ async function runSelfTest() { designFoundationAssetCall?.arguments?.input?.outputPath === 'assets/ui-prototype.png' && designFoundationAssetCall.arguments.input.aspectRatio === '16:9' && - designFoundationAssetCall.arguments.input.assetKind === 'ui-prototype' && + designFoundationAssetCall.arguments.input.assetKind === 'ui-design' && artAssetPlanAssetCall?.arguments?.input?.outputPath === 'assets/art-spritesheet.png' && artAssetPlanAssetCall.arguments.input.aspectRatio === '1:1' && - artAssetPlanAssetCall.arguments.input.assetKind === 'art-spritesheet', + artAssetPlanAssetCall.arguments.input.assetKind === 'icon-spritesheet', 'self-test-visual-assets-invalid', ); diff --git a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs index 32a9b8644..7957d9956 100644 --- a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs @@ -70,7 +70,7 @@ const requiredFormalArtifactSpecs = [ { path: 'game/balance.json', kind: 'json' }, { path: 'assets/manifest.art.json', kind: 'json' }, { path: 'assets/manifest.audio.json', kind: 'json' }, - { path: 'game/index.html', kind: 'game-entry' }, + { path: 'game/index.html', kind: 'file' }, { path: 'exports/README.md', kind: 'file' }, ]; const editorImageArtifactSpecs = [ diff --git a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs index c0a748ad3..9c8318c57 100644 --- a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs @@ -290,7 +290,7 @@ function deterministicArtManifest( { id: 'garden-guardians-spritesheet', path: 'assets/art-spritesheet.png', - kind: 'art-spritesheet', + kind: 'icon-spritesheet', usage: ['defenders', 'enemies', 'battlefield-ui'], source: 'canvas', status: 'ready', @@ -710,7 +710,7 @@ function canvasAssetCall(agentId) { outputPath: 'assets/ui-prototype.png', aspectRatio: '16:9', imageSize: '2K', - assetKind: 'ui-prototype', + assetKind: 'ui-design', assetLabel: '游戏横屏界面原型图', replaceExisting: false, }); @@ -721,7 +721,7 @@ function canvasAssetCall(agentId) { outputPath: 'assets/art-spritesheet.png', aspectRatio: '1:1', imageSize: '1K', - assetKind: 'art-spritesheet', + assetKind: 'icon-spritesheet', assetLabel: '游戏首版核心美术素材', replaceExisting: false, sliceMode: 'connected-components', @@ -830,12 +830,12 @@ function missingGeneratedVisualAssetObservation(context, agentId) { }, 'design-foundation': { path: 'assets/ui-prototype.png', - kind: 'ui-prototype', + kind: 'ui-design', summary: '策划界面原型图尚未按正式视觉流程生成并登记,不能完成任务', }, 'art-asset-plan': { path: 'assets/art-spritesheet.png', - kind: 'art-spritesheet', + kind: 'icon-spritesheet', summary: '首版美术素材图尚未按正式视觉流程生成并登记,不能完成任务', }, }[agentId]; @@ -2667,7 +2667,7 @@ function createDeterministicCanvasFixture(apiKey) { asset: { assetId: `asset-${sliceImageId}`, assetObjectId: sliceAssetObjectId, - assetKind: 'art-spritesheet-slice', + assetKind: 'icon', projectId, taskId, }, @@ -2707,7 +2707,7 @@ function createDeterministicCanvasFixture(apiKey) { spritesheetAsset: { assetId: `asset-${imageId}`, assetObjectId, - assetKind: 'art-spritesheet', + assetKind: 'icon-spritesheet', projectId, taskId, }, @@ -2749,7 +2749,7 @@ function createDeterministicCanvasFixture(apiKey) { const assetObjectId = `asset-object-${imageId}`; const resourceId = `resource-${imageId}`; const assetKind = - typeof body?.assetKind === 'string' ? body.assetKind : 'game-art'; + typeof body?.assetKind === 'string' ? body.assetKind : 'image'; images.set(imageId, { ...image, objectKey, diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index bc1227832..21f1d6233 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -5016,6 +5016,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "ts-rs", ] [[package]] diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 5c3e8eb64..0b4d67035 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -56,7 +56,7 @@ platform-agent = { path = "../../../server-rs/crates/platform-agent" } portable-pty = "0.9" reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] } regex = "1" -shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false } +shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] } tauri = { version = "2.11.2", features = [] } tauri-plugin-dialog = "2.7.1" tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] } diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index d1a51e9af..0b9f67ba9 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -63,7 +63,7 @@ "agents/openai.yaml", "references/platform-art-contract.md" ], - "sha256": "47ac742d9b88e5d6cd9833484ab212152578e58ae27f7add312fd1d78183385c" + "sha256": "6668bf1aa69601bcc65c97fdcd879c699c3139befcd70805d95614997f6e44e9" }, { "name": "agc-web-game-development", 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 2c737d022..2ed440460 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,13 +19,13 @@ 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. -With `agc_generate_image`, `kind="character"` and `kind="art-spritesheet"` +With `agc_generate_image`, `kind="character"` and `kind="icon-spritesheet"` generate the subject on a solid-colour background and automatically matte it away afterwards, producing transparent-background results; write the prompt for the subject only, never for a scene. `kind="image"` keeps the rendered frame without extra processing. -When `agc_generate_image` is used with `kind="art-spritesheet"`, `sliceMode` is +When `agc_generate_image` is used with `kind="icon-spritesheet"`, `sliceMode` is required and has no default, so decide it explicitly: - Use `sliceMode="grid"` with `gridX` and `gridY` (1-32 each) only when the user 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 86416a42d..952cfbe93 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 @@ -1198,7 +1198,7 @@ mod tests { "tool": "agc_generate_image", "arguments": { "prompt": prompt, - "kind": "art-spritesheet", + "kind": "icon-spritesheet", "sliceMode": "connected-components", "sliceCount": 8, "screenColor": "#CFEFFF" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index 2ab90cc62..f074f18ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -1,6 +1,10 @@ -use super::model::{DirectCodexUserContentPart, DirectCodexUserItem}; +use super::model::{DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem}; use super::validation::validate_direct_codex_user_item; use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path}; +use crate::ui_editor::persistence::{ + generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND, + UI_DESIGN_DOC_MEDIA_TYPE, +}; use serde_json::Value; use std::path::Path; @@ -111,29 +115,165 @@ pub(crate) fn direct_codex_user_item_to_prompt( item: &DirectCodexUserItem, ) -> Result { let wire = direct_codex_user_item_to_wire_input(root, item)?; - wire.as_array() + let DirectCodexUserItem::Message(message) = item; + let mut prompt = wire + .as_array() .ok_or_else(|| "DirectProject user item wire input 不是数组".to_string()) - .map(|parts| { + .and_then(|parts| { parts .iter() - .filter_map(|part| part.get("text").and_then(Value::as_str)) - .collect::() - }) - .and_then(|prompt| { - if prompt.trim().is_empty() { - Err("DirectProject user item 不能转换为空 prompt".to_string()) - } else { - Ok(prompt) + .map(|part| { + part.get("text") + .and_then(Value::as_str) + .ok_or_else(|| "DirectProject user item wire part 缺少 text".to_string()) + }) + .collect::>() + })?; + if let Some(code_context) = render_ui_design_code_context(root, message)? { + prompt.push('\n'); + prompt.push_str(&code_context); + } + if prompt.trim().is_empty() { + return Err("DirectProject user item 不能转换为空 prompt".to_string()); + } + Ok(prompt) +} + +/// 本轮 prompt 的 UI 设计文档引用上下文:复用 UI Editor 代码导出,把带文档注释的 +/// JS 片段路径交给模型;生成失败只追加原始错误,不阻断本轮引用,其它引用继续处理。 +/// +/// 只在生成本轮 prompt 时展开:历史 item 回读走 `direct_codex_user_item_to_response_item` +/// 的纯投影,不得在这里产生项目写副作用。 +fn render_ui_design_code_context( + root: &Path, + message: &DirectCodexUserMessageItem, +) -> Result, String> { + let referenced_ids = message + .content + .iter() + .filter_map(|part| match part { + DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + Some(resource_id.trim()) } + _ => None, }) + .collect::>(); + if referenced_ids.is_empty() { + return Ok(None); + } + let manifest = read_manifest_for_project(root)?; + let mut lines = Vec::new(); + for resource_id in referenced_ids { + let is_ui_design_doc = manifest.assets.iter().any(|asset| { + asset.id == resource_id + && asset.kind == UI_DESIGN_DOC_ASSET_KIND + && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE + }); + if !is_ui_design_doc { + continue; + } + lines.push( + match generate_ui_design_code_at(GenerateUiDesignCodeInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: manifest.project_id.clone(), + asset_id: resource_id.to_string(), + }) { + Ok(result) => format!("请先阅读生成的带有文档的代码片段: {}", result.relative_path), + Err(error) => format!("生成代码遇到错误{error}"), + }, + ); + } + if lines.is_empty() { + return Ok(None); + } + Ok(Some(lines.join("\n"))) } #[cfg(test)] mod tests { - use super::direct_codex_user_item_to_response_item; + use super::{direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item}; + use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE; use serde_json::json; + use shared_contracts::game_creation_app::{ + GameCreationAppAssetKind, GameCreationAppAssetSource, GameCreationAppAssetSourceKind, + }; use std::path::Path; + const PROMPT_CONTEXT_PROJECT_ID: &str = "direct-prompt-context-project"; + + fn prompt_context_project() -> tempfile::TempDir { + let directory = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at( + directory.path(), + PROMPT_CONTEXT_PROJECT_ID, + "Direct prompt 上下文测试", + ) + .expect("init project"); + directory + } + + fn register_fixture_asset( + root: &Path, + relative_path: &str, + kind: GameCreationAppAssetKind, + media_type: &str, + ) -> String { + let absolute_path = crate::resolve_local_project_path(root, relative_path) + .expect("resolve fixture asset path"); + std::fs::create_dir_all(absolute_path.parent().expect("fixture asset parent")) + .expect("create fixture asset parent"); + std::fs::write(&absolute_path, b"{}").expect("write fixture asset file"); + crate::assets::register_local_asset_at( + root, + relative_path, + kind, + media_type, + "prompt-context-test", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: Some("prompt-context".to_string()), + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) + .expect("register fixture asset") + .id + } + + fn ui_design_doc_fixture(initialize_state: bool) -> (tempfile::TempDir, String) { + let directory = prompt_context_project(); + let asset_id = register_fixture_asset( + directory.path(), + "ui/design.json", + GameCreationAppAssetKind::UiDesignDoc, + UI_DESIGN_DOC_MEDIA_TYPE, + ); + if initialize_state { + crate::ui_editor::persistence::initialize_ui_design_state_at( + directory.path(), + PROMPT_CONTEXT_PROJECT_ID, + &asset_id, + ) + .expect("initialize UI design state"); + } + (directory, asset_id) + } + + fn user_item_with_resource_reference(asset_id: &str) -> serde_json::Value { + json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [{"type": "agc_resource_reference", "resourceId": asset_id}], + }) + } + #[test] fn standard_response_item_passes_through_without_agc_private_parts() { let item = json!({ @@ -174,4 +314,81 @@ mod tests { .expect_err("history item without type must fail"); assert!(error.contains("缺少 type"), "{error}"); } + + #[test] + fn ui_design_doc_reference_appends_generated_code_context() { + let (project, asset_id) = ui_design_doc_fixture(true); + let item: super::DirectCodexUserItem = + serde_json::from_value(user_item_with_resource_reference(&asset_id)) + .expect("canonical user item"); + let prompt = + direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection"); + assert!( + prompt.contains(&format!( + "[素材引用 resourceId={asset_id};项目路径=ui/design.json]" + )), + "{prompt}" + ); + assert!( + prompt.contains("请先阅读生成的带有文档的代码片段: ui/generated-"), + "{prompt}" + ); + } + + #[test] + fn history_projection_never_writes_generated_ui_design_code() { + let (project, asset_id) = ui_design_doc_fixture(true); + let item = user_item_with_resource_reference(&asset_id); + direct_codex_user_item_to_response_item(project.path(), &item).expect("history projection"); + let generated_root = + crate::resolve_local_project_path(project.path(), "ui").expect("resolve ui directory"); + let generated_files = std::fs::read_dir(generated_root) + .expect("read ui directory") + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("generated-") + }) + .count(); + assert_eq!( + generated_files, 0, + "历史回读只做纯投影,不得生成 UI 设计代码" + ); + } + + #[test] + fn ui_design_generation_failure_keeps_reference_and_reports_error() { + let (project, asset_id) = ui_design_doc_fixture(false); + let item: super::DirectCodexUserItem = + serde_json::from_value(user_item_with_resource_reference(&asset_id)) + .expect("canonical user item"); + let prompt = + direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection"); + assert!(prompt.contains("素材引用 resourceId="), "{prompt}"); + assert!(prompt.contains("生成代码遇到错误"), "{prompt}"); + } + + #[test] + fn other_asset_kind_reference_does_not_generate_ui_design_code() { + let project = prompt_context_project(); + let asset_id = register_fixture_asset( + project.path(), + "assets/hero.png", + GameCreationAppAssetKind::Character, + "image/png", + ); + let item: super::DirectCodexUserItem = + serde_json::from_value(user_item_with_resource_reference(&asset_id)) + .expect("canonical user item"); + let prompt = + direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection"); + assert!(prompt.contains("素材引用 resourceId="), "{prompt}"); + assert!( + !prompt.contains("请先阅读生成的带有文档的代码片段"), + "{prompt}" + ); + assert!(!prompt.contains("生成代码遇到错误"), "{prompt}"); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 1c27b262f..5c4746b3b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -69,32 +69,31 @@ const DIRECT_CODEX_ART_ASSET_PATHS: [&str; 3] = [ const DIRECT_CODEX_ART_AGENT_ID: &str = "direct-codex-art"; const DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER: &str = "[[AGC_CREATE_PROJECT]]"; -fn direct_codex_game_outputs(root: &Path) -> Vec<(String, &'static str, &'static str)> { +/// 直连 Codex 生成的游戏工程文件 → manifest 登记项。 +/// +/// 这些不是用户素材,而是游戏工程自身的源码/配置(`index.html`、`style.css`、`game.js`、 +/// `package.json`、`package-lock.json`、`vite.config.js`)。它们的 manifest kind 统一是正式成员 +/// `code`:`code` 与旧的 `game-*` / `image` 兜底一样派生 `unclassified`(「待归类」), +/// 不会把工程文件塞进内容栏目,同时写入侧不再产出非 canonical 的 kind。 +fn direct_codex_game_outputs(root: &Path) -> Vec<(String, GameCreationAppAssetKind, &'static str)> { let entry = agent_runtime_game_entry_relative_path(root); let prefix = if entry == AGENT_RUNTIME_GAME_ENTRY_ROOT_PATH { "" } else { "game/" }; + let code = GameCreationAppAssetKind::Code; vec![ - (entry.to_string(), "game-entry", "text/html"), - (format!("{prefix}style.css"), "game-style", "text/css"), - (format!("{prefix}game.js"), "game-script", "text/javascript"), - ( - format!("{prefix}package.json"), - "game-package", - "application/json", - ), + (entry.to_string(), code, "text/html"), + (format!("{prefix}style.css"), code, "text/css"), + (format!("{prefix}game.js"), code, "text/javascript"), + (format!("{prefix}package.json"), code, "application/json"), ( format!("{prefix}package-lock.json"), - "game-lockfile", + code, "application/json", ), - ( - format!("{prefix}vite.config.js"), - "game-build-config", - "text/javascript", - ), + (format!("{prefix}vite.config.js"), code, "text/javascript"), ] } @@ -328,7 +327,7 @@ struct DirectTaonierArtAssetIdentity { local_asset_id: String, source_sha256: String, media_type: String, - canonical_asset_kind: String, + canonical_asset_kind: GameCreationAppAssetKind, resource_id: String, asset_object_id: String, canvas_project_id: String, @@ -847,10 +846,20 @@ pub(in crate::agent) fn direct_taonier_regeneration_workflow_retains_stage_ledge if agent_id != "direct-codex-art" { return Ok(false); } + // TODO: `run_id` 是 Direct 重生成阶段槽标识,不是 manifest `asset_kind`;两者必须保持独立。 let (output_path, asset_kind) = match run_id { - "art-spec" => (DIRECT_CODEX_ART_SPEC_ASSET_PATH, "icon-spec"), - "game-background" => (DIRECT_CODEX_BACKGROUND_ASSET_PATH, "game-background"), - "art-spritesheet" => (DIRECT_CODEX_SPRITESHEET_ASSET_PATH, "art-spritesheet"), + "art-spec" => ( + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + GameCreationAppAssetKind::IconSpec, + ), + "game-background" => ( + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + GameCreationAppAssetKind::Scene, + ), + "art-spritesheet" => ( + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + GameCreationAppAssetKind::IconSpritesheet, + ), _ => return Ok(false), }; if read_direct_taonier_regeneration_workflow_at(root)?.is_none() { @@ -883,9 +892,18 @@ fn write_direct_taonier_regeneration_workflow_at( fn clear_direct_taonier_regeneration_stage_ledgers_at(root: &Path) -> Result<(), String> { for (output_path, asset_kind) in [ - (DIRECT_CODEX_ART_SPEC_ASSET_PATH, "icon-spec"), - (DIRECT_CODEX_BACKGROUND_ASSET_PATH, "game-background"), - (DIRECT_CODEX_SPRITESHEET_ASSET_PATH, "art-spritesheet"), + ( + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + GameCreationAppAssetKind::IconSpec, + ), + ( + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + GameCreationAppAssetKind::Scene, + ), + ( + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + GameCreationAppAssetKind::IconSpritesheet, + ), ] { let context = direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?; remove_platform_art_generation_runtime_state_at(root, &context.agent_id, &context.run_id)?; @@ -903,9 +921,9 @@ fn direct_taonier_regeneration_stage_ledgers_exist_at(root: &Path) -> bool { fn validate_direct_taonier_retained_stage_ledgers_at(root: &Path) -> Result<(), String> { for (run_id, asset_kind) in [ - ("art-spec", "icon-spec"), - ("game-background", "game-background"), - ("art-spritesheet", "art-spritesheet"), + ("art-spec", GameCreationAppAssetKind::IconSpec), + ("game-background", GameCreationAppAssetKind::Scene), + ("art-spritesheet", GameCreationAppAssetKind::IconSpritesheet), ] { let exists = game_creator_agent_runtime_external_generation_exists(root, "direct-codex-art", run_id); @@ -917,7 +935,8 @@ fn validate_direct_taonier_retained_stage_ledgers_at(root: &Path) -> Result<(), ) .map_err(|error| { format!( - "{DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX} 无法校验陶泥儿 {asset_kind} 阶段账本,已保留现场:{error}" + "{DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX} 无法校验陶泥儿 {} 阶段账本,已保留现场:{error}", + asset_kind.as_str() ) })? } else { @@ -938,7 +957,7 @@ fn direct_taonier_spritesheet_matches_retained_stage_result_at( let Some(art_spec) = direct_taonier_art_asset_identity( root, DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "/api/external/v1/editor/images/generations", "spec", None, @@ -948,7 +967,7 @@ fn direct_taonier_spritesheet_matches_retained_stage_result_at( let Some(spritesheet) = direct_taonier_art_asset_identity( root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, "/api/external/v1/editor/icon-spritesheets/generations", "icon-spritesheet", Some(&art_spec), @@ -958,7 +977,7 @@ fn direct_taonier_spritesheet_matches_retained_stage_result_at( let context = direct_taonier_art_generation_runtime_context( root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, )?; let retained = retained_platform_art_generation_runtime_spritesheet_identity_at( root, @@ -2472,7 +2491,7 @@ fn persist_direct_codex_failure_context( fn direct_taonier_art_generation_runtime_context( root: &Path, output_path: &str, - asset_kind: &str, + asset_kind: GameCreationAppAssetKind, ) -> Result { let project_id = read_manifest(&root.join(".agent/manifest.json"))? .project_id @@ -2501,7 +2520,7 @@ fn direct_taonier_art_generation_runtime_context( fn direct_taonier_art_asset_identity( root: &Path, expected_path: &str, - expected_kind: &str, + expected_kind: GameCreationAppAssetKind, expected_generation_route: &str, expected_generation_kind: &str, expected_reference_source: Option<&DirectTaonierArtAssetIdentity>, @@ -2535,7 +2554,7 @@ fn direct_taonier_art_asset_identity( local_asset_id: asset.id.clone(), source_sha256: validated.content_sha256, media_type: asset.media_type.clone(), - canonical_asset_kind: asset.kind.clone(), + canonical_asset_kind: asset.kind, resource_id: asset .source .resource_id @@ -2618,7 +2637,7 @@ fn direct_taonier_art_base_is_valid(root: &Path) -> bool { let Some(art_spec) = direct_taonier_art_asset_identity( root, DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "/api/external/v1/editor/images/generations", "spec", None, @@ -2628,7 +2647,7 @@ fn direct_taonier_art_base_is_valid(root: &Path) -> bool { let Some(background) = direct_taonier_art_asset_identity( root, DIRECT_CODEX_BACKGROUND_ASSET_PATH, - "game-background", + GameCreationAppAssetKind::Scene, "/api/external/v1/editor/images/generations", "spec", Some(&art_spec), @@ -2643,7 +2662,7 @@ fn direct_taonier_art_package_is_valid(root: &Path) -> bool { let Some(art_spec) = direct_taonier_art_asset_identity( root, DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "/api/external/v1/editor/images/generations", "spec", None, @@ -2656,7 +2675,7 @@ fn direct_taonier_art_package_is_valid(root: &Path) -> bool { let Some(spritesheet) = direct_taonier_art_asset_identity( root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, "/api/external/v1/editor/icon-spritesheets/generations", "icon-spritesheet", Some(&art_spec), @@ -2707,7 +2726,7 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec { let Some(art_spec) = direct_taonier_art_asset_identity( root, DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "/api/external/v1/editor/images/generations", "spec", None, @@ -2749,7 +2768,10 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec { return Vec::new(); }; if assets.next().is_some() - || asset.kind != "art-spritesheet-slice" + || asset.kind != GameCreationAppAssetKind::Icon + || !asset + .local_path + .starts_with("assets/art-spritesheet-slices/") || asset.media_type != "image/png" || asset.source.kind != GameCreationAppAssetSourceKind::Canvas || asset.source.generation_route.as_deref() @@ -2829,15 +2851,17 @@ fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec { .into_iter() .filter(|asset| { matches!( - asset.kind.as_str(), - "art-spritesheet" | "art-spritesheet-slice" | "game-background" + asset.kind, + GameCreationAppAssetKind::IconSpritesheet + | GameCreationAppAssetKind::Icon + | GameCreationAppAssetKind::Scene ) && asset.media_type == "image/png" && asset.source.kind == GameCreationAppAssetSourceKind::Canvas && asset.local_path.starts_with("assets/") // Canonical slices are admitted above only after the // full slice manifest/receipt/content validation. Do // not let this generic manifest fallback bypass it. - && !(asset.kind == "art-spritesheet-slice" + && !(asset.kind == GameCreationAppAssetKind::Icon && asset .local_path .starts_with("assets/art-spritesheet-slices/")) @@ -2868,7 +2892,7 @@ fn direct_registered_taonier_runtime_image_paths(root: &Path) -> Vec { .iter() .filter(|asset| { asset.media_type.starts_with("image/") - && asset.kind != "icon-spec" + && asset.kind != GameCreationAppAssetKind::IconSpec && asset.source.kind == GameCreationAppAssetSourceKind::Canvas && asset .source @@ -2880,7 +2904,7 @@ fn direct_registered_taonier_runtime_image_paths(root: &Path) -> Vec { // manifest/receipt/content validation. This generic fallback // must not re-admit a slice whose bytes no longer match the // registered receipt. - && !(asset.kind == "art-spritesheet-slice" + && !(asset.kind == GameCreationAppAssetKind::Icon && asset .local_path .starts_with("assets/art-spritesheet-slices/")) @@ -3059,7 +3083,7 @@ fn snapshot_direct_spritesheet_recovery_manifest_for_install( let current_art_spec = direct_taonier_art_asset_identity( root, DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "/api/external/v1/editor/images/generations", "spec", None, @@ -3078,7 +3102,7 @@ fn register_direct_recovered_spritesheet_at( register_local_asset_at( root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, "image/png", "platform-art-recovery", source, @@ -3115,7 +3139,7 @@ fn install_direct_recovered_spritesheet_binding_at( ®istered_asset.id, content_sha256, "image/png", - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet.as_str(), )?; let binding = new_external_editor_resource_binding( project_id, @@ -3162,7 +3186,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at( &source_asset.id, &format!("{:x}", Sha256::digest(&source_bytes)), &source_asset.media_type, - &source_asset.kind, + source_asset.kind.as_str(), )?; let principal = external_editor_binding_principal(&access)?; let Some(project_binding) = @@ -3190,7 +3214,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at( local_asset_id: art_spec.local_asset_id.clone(), source_sha256: art_spec.source_sha256.clone(), media_type: art_spec.media_type.clone(), - canonical_asset_kind: art_spec.canonical_asset_kind.clone(), + canonical_asset_kind: art_spec.canonical_asset_kind, resource_id: remote_spec_resource_id, asset_object_id: spec_binding.asset_object_id, canvas_project_id: project_binding.remote_project_id, @@ -3379,24 +3403,29 @@ async fn generate_direct_taonier_art_asset_at( prompt: &str, output_path: &str, aspect_ratio: &str, - asset_kind: &str, + asset_kind: GameCreationAppAssetKind, asset_label: &str, require_slices: bool, retain_runtime_state: bool, ) -> Result { + let parsed_asset_kind = asset_kind; + if parsed_asset_kind == GameCreationAppAssetKind::Unknown { + return Err("直连美术生成的资源 kind 无法解析".to_string()); + } let options = PlatformArtAssetGenerationOptions { output_path: Some(output_path.to_string()), aspect_ratio: aspect_ratio.to_string(), image_size: "1K".to_string(), - asset_kind: asset_kind.to_string(), + asset_kind: parsed_asset_kind, asset_label: asset_label.to_string(), replace_existing: root.join(output_path).is_file(), // 标准美术包必须产出四张 canonical 切片:连通域模式下显式声明目标数量, // 让平台要么给出四张,要么以可执行的 422 说明实际识别数量。 - slice_count: (asset_kind == "art-spritesheet").then_some(4), + slice_count: (parsed_asset_kind == GameCreationAppAssetKind::IconSpritesheet).then_some(4), // 切分模式没有默认值:陶泥儿标准美术包按自由排布生成核心图集,因此只在 // art-spritesheet 阶段显式声明连通域切分。 - slice_mode: (asset_kind == "art-spritesheet").then(|| "connected-components".to_string()), + slice_mode: (parsed_asset_kind == GameCreationAppAssetKind::IconSpritesheet) + .then(|| "connected-components".to_string()), grid_x: None, grid_y: None, reference_asset_ids: Vec::new(), @@ -3435,7 +3464,7 @@ async fn generate_direct_taonier_art_asset_at( let art_spec = direct_taonier_art_asset_identity( root, DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "/api/external/v1/editor/images/generations", "spec", None, @@ -3595,7 +3624,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( direct_taonier_art_asset_identity( root, DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "/api/external/v1/editor/images/generations", "spec", None, @@ -3606,7 +3635,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( direct_taonier_art_asset_identity( root, DIRECT_CODEX_BACKGROUND_ASSET_PATH, - "game-background", + GameCreationAppAssetKind::Scene, "/api/external/v1/editor/images/generations", "spec", Some(art_spec), @@ -3622,7 +3651,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( prompt, DIRECT_CODEX_ART_SPEC_ASSET_PATH, "1:1", - "icon-spec", + GameCreationAppAssetKind::IconSpec, "陶泥儿首版游戏视觉规范图", false, mode.regenerates_existing(), @@ -3672,7 +3701,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( direct_taonier_art_asset_identity( root, DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "/api/external/v1/editor/images/generations", "spec", None, @@ -3690,7 +3719,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( || direct_taonier_art_asset_identity( root, DIRECT_CODEX_BACKGROUND_ASSET_PATH, - "game-background", + GameCreationAppAssetKind::Scene, "/api/external/v1/editor/images/generations", "spec", Some(&art_spec), @@ -3703,7 +3732,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( prompt, DIRECT_CODEX_BACKGROUND_ASSET_PATH, "16:9", - "game-background", + GameCreationAppAssetKind::Scene, "陶泥儿首版游戏场景背景", false, mode.regenerates_existing(), @@ -3820,7 +3849,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( prompt, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, "1:1", - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, "陶泥儿首版核心游戏美术图集", true, mode.regenerates_existing(), @@ -4430,7 +4459,7 @@ fn sync_direct_codex_project_file_projection_at( register_local_asset_at( root, &local_path, - "game-source", + GameCreationAppAssetKind::Code, media_type, "direct-codex", direct_codex_generated_source(), @@ -7141,7 +7170,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "replacement-before-crash", Vec::new(), &replacement_spec, @@ -7239,7 +7268,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "strict-crash-spec", Vec::new(), &new_spec, @@ -7253,7 +7282,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_BACKGROUND_ASSET_PATH, - "game-background", + GameCreationAppAssetKind::Scene, "strict-crash-background", vec!["strict-crash-spec".to_string()], &new_background, @@ -7389,7 +7418,7 @@ mod tests { let context = direct_taonier_art_generation_runtime_context( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, ) .expect("direct stage context"); let request_body = serde_json::json!({ @@ -7469,7 +7498,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "replacement-before-crash", Vec::new(), &tiny_replacement_png(64, 96, 224), @@ -7552,7 +7581,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "unknown-stage-replacement", Vec::new(), &tiny_replacement_png(224, 64, 32), @@ -8010,7 +8039,7 @@ mod tests { local_asset_id: "local-art-spec".to_string(), source_sha256: "a".repeat(64), media_type: "image/png".to_string(), - canonical_asset_kind: "icon-spec".to_string(), + canonical_asset_kind: GameCreationAppAssetKind::IconSpec, resource_id: "taonier-resource-icon-spec".to_string(), asset_object_id: "taonier-object-icon-spec".to_string(), canvas_project_id: "taonier-project".to_string(), @@ -8151,7 +8180,7 @@ mod tests { let art_spec = direct_taonier_art_asset_identity( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "/api/external/v1/editor/images/generations", "spec", None, @@ -8166,7 +8195,7 @@ mod tests { let concurrent = register_local_asset_at( root.path(), concurrent_path, - "illustration", + GameCreationAppAssetKind::Image, "image/png", "uploaded", GameCreationAppAssetSource { @@ -8199,7 +8228,7 @@ mod tests { let recovery = register_local_asset_at( root.path(), recovery_path, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, "image/png", "platform-art-recovery", GameCreationAppAssetSource { @@ -8247,7 +8276,7 @@ mod tests { let registered = register_local_asset_at( root.path(), local_path, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, "image/png", "platform-art-recovery", GameCreationAppAssetSource { @@ -8364,14 +8393,18 @@ mod tests { })); } - fn register_direct_taonier_art_asset_fixture(root: &Path, local_path: &str, kind: &str) { + fn register_direct_taonier_art_asset_fixture( + root: &Path, + local_path: &str, + kind: GameCreationAppAssetKind, + ) { let (generation_route, generation_kind, reference_resource_ids) = match kind { - "art-spritesheet" => ( + GameCreationAppAssetKind::IconSpritesheet => ( "/api/external/v1/editor/icon-spritesheets/generations", "icon-spritesheet", vec!["taonier-resource-icon-spec".to_string()], ), - "game-background" => ( + GameCreationAppAssetKind::Scene => ( "/api/external/v1/editor/images/generations", "spec", vec!["taonier-resource-icon-spec".to_string()], @@ -8382,6 +8415,14 @@ mod tests { Vec::new(), ), }; + // 平台侧资源/对象/任务 id 按生成阶段命名(art-spec / game-background / + // art-spritesheet),与 manifest kind 是两套词汇;kind 收紧成枚举后不能让这些 + // fixture 标识跟着改名。 + let identity_slug = match kind { + GameCreationAppAssetKind::IconSpritesheet => "art-spritesheet", + GameCreationAppAssetKind::Scene => "game-background", + _ => kind.as_str(), + }; let mut png_bytes = Vec::new(); let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( 1, @@ -8404,9 +8445,9 @@ mod tests { GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Canvas, canvas_project_id: Some("taonier-project".to_string()), - resource_id: Some(format!("taonier-resource-{kind}")), - asset_object_id: Some(format!("taonier-object-{kind}")), - task_id: Some(format!("taonier-task-{kind}")), + resource_id: Some(format!("taonier-resource-{identity_slug}")), + asset_object_id: Some(format!("taonier-object-{identity_slug}")), + task_id: Some(format!("taonier-task-{identity_slug}")), prompt: None, model: Some("gpt-image-2".to_string()), generation_route: Some(generation_route.to_string()), @@ -8422,7 +8463,7 @@ mod tests { register_direct_taonier_art_asset_fixture( root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, ); let main_bytes = std::fs::read(root.join(DIRECT_CODEX_SPRITESHEET_ASSET_PATH)) .expect("spritesheet bytes"); @@ -8499,7 +8540,7 @@ mod tests { register_local_asset_at( root, path, - "art-spritesheet-slice", + GameCreationAppAssetKind::Icon, "image/png", "platform-art", GameCreationAppAssetSource { @@ -8531,7 +8572,7 @@ mod tests { let context = direct_taonier_art_generation_runtime_context( root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, ) .expect("spritesheet runtime context"); let request_body = serde_json::json!({ @@ -8611,7 +8652,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, &spritesheet_resource_id, vec![art_spec_resource_id.to_string()], &spritesheet_bytes, @@ -8698,19 +8739,20 @@ mod tests { fn replace_direct_taonier_art_asset_fixture( root: &Path, local_path: &str, - kind: &str, + kind: GameCreationAppAssetKind, resource_id: &str, reference_resource_ids: Vec, bytes: &[u8], ) { - let (generation_route, generation_kind) = if kind == "art-spritesheet" { - ( - "/api/external/v1/editor/icon-spritesheets/generations", - "icon-spritesheet", - ) - } else { - ("/api/external/v1/editor/images/generations", "spec") - }; + let (generation_route, generation_kind) = + if kind == GameCreationAppAssetKind::IconSpritesheet { + ( + "/api/external/v1/editor/icon-spritesheets/generations", + "icon-spritesheet", + ) + } else { + ("/api/external/v1/editor/images/generations", "spec") + }; std::fs::write(root.join(local_path), bytes).expect("replace fixture bytes"); register_local_asset_at( root, @@ -8739,12 +8781,12 @@ mod tests { register_direct_taonier_art_asset_fixture( root, DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, ); register_direct_taonier_art_asset_fixture( root, DIRECT_CODEX_BACKGROUND_ASSET_PATH, - "game-background", + GameCreationAppAssetKind::Scene, ); } @@ -9039,7 +9081,11 @@ mod tests { init_local_game_project_at(root.path(), "direct-independent-image", "独立平台图片") .expect("init project"); std::fs::create_dir_all(root.path().join("assets")).expect("assets dir"); - register_direct_taonier_art_asset_fixture(root.path(), "assets/neon-mine.png", "image"); + register_direct_taonier_art_asset_fixture( + root.path(), + "assets/neon-mine.png", + GameCreationAppAssetKind::Image, + ); std::fs::write(root.path().join("game/index.html"), "").expect("index"); std::fs::write(root.path().join("game/style.css"), "body {} ").expect("style"); std::fs::write( @@ -9247,7 +9293,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "replacement-spec", Vec::new(), &tiny_replacement_png(32, 64, 192), @@ -9258,7 +9304,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_BACKGROUND_ASSET_PATH, - "game-background", + GameCreationAppAssetKind::Scene, "replacement-background", vec!["replacement-spec".to_string()], &tiny_replacement_png(16, 128, 224), @@ -9346,7 +9392,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "durable-replacement-spec", Vec::new(), &tiny_replacement_png(32, 64, 192), @@ -9359,7 +9405,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_BACKGROUND_ASSET_PATH, - "game-background", + GameCreationAppAssetKind::Scene, "durable-replacement-background", vec!["durable-replacement-spec".to_string()], &tiny_replacement_png(16, 128, 224), @@ -9444,7 +9490,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "uncertain-committed-spec", Vec::new(), &replacement, @@ -9504,7 +9550,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_SPRITESHEET_ASSET_PATH, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, "uncertain-committed-spritesheet", vec!["taonier-resource-icon-spec".to_string()], &replacement, @@ -9560,7 +9606,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "late-committed-spec", Vec::new(), &tiny_replacement_png(32, 64, 192), @@ -9614,7 +9660,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "replacement-spec-before-background", Vec::new(), &tiny_replacement_png(32, 64, 192), @@ -9633,7 +9679,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_BACKGROUND_ASSET_PATH, - "game-background", + GameCreationAppAssetKind::Scene, "late-committed-background", vec!["replacement-spec-before-background".to_string()], &tiny_replacement_png(16, 128, 224), @@ -9678,7 +9724,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "replacement-spec", Vec::new(), &replacement_spec, @@ -9689,7 +9735,7 @@ mod tests { replace_direct_taonier_art_asset_fixture( root.path(), DIRECT_CODEX_BACKGROUND_ASSET_PATH, - "game-background", + GameCreationAppAssetKind::Scene, "replacement-background", vec!["replacement-spec".to_string()], &replacement_background, 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 20aa93227..40154d024 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 @@ -1197,9 +1197,9 @@ fn bridge_registered_resource( "mediaType": asset.media_type, // Agent 与 UI 必须看到同一个口径:UI 栏目走 TS 的 `gameCreationAppAssetCategory` // (落盘值 + 按 kind 派生 + 读时自愈),这里走 Rust 的同构实现。 - // 直接透传落盘 `asset.category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、 + // 直接透传落盘 `asset.category` 会让 UI 设计资产在 UI 显示「UI 交互」、 // 在 Agent 侧读到 `unclassified`(真机 55 条分歧)。 - "category": game_creation_app_asset_effective_category(&asset.kind, asset.category), + "category": game_creation_app_asset_effective_category(asset.kind, asset.category), "tags": asset.tags, "canvasProjectId": asset.source.canvas_project_id, "resourceId": asset.source.resource_id, @@ -1240,7 +1240,9 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value { arguments, "kind", DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS, - )?; + )? + .map(|kind| bridge_asset_list_kind_filter(&kind)) + .transpose()?; let asset_id = bridge_optional_bounded_string( arguments, "assetId", @@ -1260,7 +1262,7 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value { let mut assets = manifest .assets .iter() - .filter(|asset| kind.as_ref().is_none_or(|kind| asset.kind == *kind)) + .filter(|asset| kind.is_none_or(|kind| asset.kind == kind)) .filter(|asset| { asset_id .as_ref() @@ -1326,6 +1328,24 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value { } } +/// `asset.list` 的 `kind` 过滤:只接受 canonical 值,认不出的值直接报错。 +/// +/// 以前认不出的值会收口成 `unknown` 再参与等值过滤,结果是**静默返回空列表**:调用方(模型) +/// 会以为项目里没有这类资产,而不是"你传的 kind 不合法",于是继续按错误前提往下走。 +/// `unknown` 本身仍是合法输入(确有 kind 未知的资产),只拒绝"既不是 canonical、也不是字面 +/// `unknown`"的原值。 +fn bridge_asset_list_kind_filter(raw: &str) -> Result { + let kind = GameCreationAppAssetKind::parse_with_context(raw, "asset.list.kind"); + if kind == GameCreationAppAssetKind::Unknown + && raw.trim() != GameCreationAppAssetKind::Unknown.as_str() + { + return Err(format!( + "kind 不是已知的 manifest 资源 kind:{raw};请改用 canonical kind(如 image、scene、character、icon、icon-spritesheet、character-animation、audio、video、document)" + )); + } + Ok(kind) +} + fn bridge_project_file_class(path: &str) -> (&'static str, Option<&'static str>) { let extension = Path::new(path) .extension() @@ -1896,7 +1916,7 @@ async fn bridge_create_or_derive_resource( source_asset_id: source_asset.as_ref().map(|asset| asset.id.clone()), source_path: source_asset.as_ref().map(|asset| asset.local_path.clone()), source_media_type: source_asset.as_ref().map(|asset| asset.media_type.clone()), - source_subtype: source_asset.as_ref().map(|asset| asset.kind.clone()), + source_subtype: source_asset.as_ref().map(|asset| asset.kind.to_string()), producer_task_id: source_asset .as_ref() .and_then(|asset| asset.source.task_id.clone()), @@ -2006,7 +2026,7 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val source_asset_id: Some(source_asset_id.clone()), source_path: Some(source_asset.local_path.clone()), source_media_type: Some(source_asset.media_type.clone()), - source_subtype: Some(source_asset.kind.clone()), + source_subtype: Some(source_asset.kind.to_string()), producer_task_id: source_asset.source.task_id.clone(), source_version_id: None, prompt: "去除背景".to_string(), @@ -2174,35 +2194,37 @@ async fn bridge_prepare_game_art(state: &DirectToolBridgeState, arguments: &Valu result } -fn bridge_image_generation_kind(arguments: &Value) -> Result { +fn bridge_image_generation_kind(arguments: &Value) -> Result { let kind = arguments .get("kind") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("image"); - normalize_platform_art_asset_generation_kind(kind) - .map(str::to_string) - .ok_or_else(|| { - format!( - "工具参数 kind 只允许 {}", - PLATFORM_ART_ASSET_GENERATION_KINDS.join("、") - ) - }) + normalize_platform_art_asset_generation_kind(kind).ok_or_else(|| { + format!( + "工具参数 kind 只允许 {}", + PLATFORM_ART_ASSET_GENERATION_KINDS + .iter() + .map(|kind| kind.as_str()) + .collect::>() + .join("、") + ) + }) } /// 切分模式没有默认值:图集必须显式声明,且声明必须与 kind 和网格参数自洽。 fn validate_generate_image_slice_declaration( - kind: &str, + kind: GameCreationAppAssetKind, slice_mode: Option<&str>, grid_x: Option, grid_y: Option, slice_count: Option, ) -> Result<(), String> { - if kind == "art-spritesheet" { + if kind == GameCreationAppAssetKind::IconSpritesheet { if slice_mode.is_none() { return Err( - "kind=art-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时传 sliceMode=grid 并提供 gridX/gridY;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components" + "kind=icon-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时传 sliceMode=grid 并提供 gridX/gridY;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components" .to_string(), ); } @@ -2215,19 +2237,19 @@ fn validate_generate_image_slice_declaration( } if slice_mode.is_some() || grid_x.is_some() || grid_y.is_some() || slice_count.is_some() { return Err(format!( - "工具参数 sliceMode/gridX/gridY/sliceCount 仅对 kind=art-spritesheet 生效,当前 kind={kind}" + "工具参数 sliceMode/gridX/gridY/sliceCount 仅对 kind=icon-spritesheet 生效,当前 kind={kind}" )); } Ok(()) } -/// 抠图纯色背景只服务 character 与 art-spritesheet 链路;格式校验收口为 +/// 抠图纯色背景只服务 character 与 icon-spritesheet 链路;格式校验收口为 /// `auto` 或 `#RRGGBB`(服务端另有支持色板,客户端不复制),`auto`/空串归一为 /// None(服务端自动决策),hex 统一大写后透传。其它 kind 携带该字段直接拒绝, /// 避免服务端静默忽略造成“已生效”的误解。 fn normalize_generate_image_screen_color( arguments: &Value, - kind: &str, + kind: GameCreationAppAssetKind, ) -> Result, String> { let Some(value) = arguments.get("screenColor") else { return Ok(None); @@ -2235,9 +2257,12 @@ fn normalize_generate_image_screen_color( if value.is_null() { return Ok(None); } - if !matches!(kind, "character" | "art-spritesheet") { + if !matches!( + kind, + GameCreationAppAssetKind::Character | GameCreationAppAssetKind::IconSpritesheet + ) { return Err(format!( - "工具参数 screenColor 仅对 kind=character 和 kind=art-spritesheet 生效,当前 kind={kind}" + "工具参数 screenColor 仅对 kind=character 和 kind=icon-spritesheet 生效,当前 kind={kind}" )); } let raw = value @@ -2364,18 +2389,18 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) }) .transpose()?; validate_generate_image_slice_declaration( - kind.as_str(), + kind, slice_mode.as_deref(), grid_x, grid_y, slice_count, )?; - let screen_color = normalize_generate_image_screen_color(arguments, kind.as_str())?; + let screen_color = normalize_generate_image_screen_color(arguments, kind)?; let options = PlatformArtAssetGenerationOptions { output_path, aspect_ratio, image_size, - asset_kind: kind.clone(), + asset_kind: kind, asset_label: asset_name.clone(), replace_existing: false, slice_count, @@ -2864,14 +2889,19 @@ pub(crate) async fn start_direct_tool_bridge( mod tests { #[test] fn generate_image_slice_declaration_is_explicit_and_self_consistent() { - let missing = - validate_generate_image_slice_declaration("art-spritesheet", None, None, None, None) - .expect_err("art-spritesheet without sliceMode must fail closed"); + let missing = validate_generate_image_slice_declaration( + GameCreationAppAssetKind::IconSpritesheet, + None, + None, + None, + None, + ) + .expect_err("icon-spritesheet without sliceMode must fail closed"); assert!(missing.contains("没有默认值"), "{missing}"); assert!(missing.contains("connected-components"), "{missing}"); assert!(validate_generate_image_slice_declaration( - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, Some("connected-components"), None, None, @@ -2879,7 +2909,7 @@ mod tests { ) .is_ok()); assert!(validate_generate_image_slice_declaration( - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, Some("grid"), Some(3), Some(2), @@ -2887,7 +2917,7 @@ mod tests { ) .is_ok()); let grid_with_count = validate_generate_image_slice_declaration( - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, Some("grid"), Some(2), Some(2), @@ -2897,58 +2927,80 @@ mod tests { assert!(grid_with_count.contains("gridX×gridY"), "{grid_with_count}"); let wrong_kind = validate_generate_image_slice_declaration( - "image", + GameCreationAppAssetKind::Image, Some("connected-components"), None, None, None, ) - .expect_err("slice declaration must stay scoped to art-spritesheet"); + .expect_err("slice declaration must stay scoped to icon-spritesheet"); assert!( - wrong_kind.contains("仅对 kind=art-spritesheet 生效"), + wrong_kind.contains("仅对 kind=icon-spritesheet 生效"), "{wrong_kind}" ); - let wrong_kind_count = - validate_generate_image_slice_declaration("image", None, None, None, Some(8)) - .expect_err("sliceCount-only violation must be rejected"); + let wrong_kind_count = validate_generate_image_slice_declaration( + GameCreationAppAssetKind::Image, + None, + None, + None, + Some(8), + ) + .expect_err("sliceCount-only violation must be rejected"); assert!( wrong_kind_count.contains("sliceCount"), "sliceCount-only violation must name sliceCount: {wrong_kind_count}" ); - assert!(validate_generate_image_slice_declaration("image", None, None, None, None).is_ok()); + assert!(validate_generate_image_slice_declaration( + GameCreationAppAssetKind::Image, + None, + None, + None, + None + ) + .is_ok()); } #[test] fn generate_image_screen_color_is_normalized_and_kind_gated() { // 省略与显式 null 等价,且不触发 kind 门禁。 assert_eq!( - normalize_generate_image_screen_color(&json!({}), "image").expect("omitted"), + normalize_generate_image_screen_color(&json!({}), GameCreationAppAssetKind::Image) + .expect("omitted"), None ); assert_eq!( - normalize_generate_image_screen_color(&json!({"screenColor": null}), "image") - .expect("null"), + normalize_generate_image_screen_color( + &json!({"screenColor": null}), + GameCreationAppAssetKind::Image + ) + .expect("null"), None ); // auto 家族归一为 None(服务端自动决策),大小写与空白不敏感。 for raw in ["auto", "AUTO", " auto ", ""] { assert_eq!( - normalize_generate_image_screen_color(&json!({"screenColor": raw}), "character") - .expect("auto variants"), + normalize_generate_image_screen_color( + &json!({"screenColor": raw}), + GameCreationAppAssetKind::Character + ) + .expect("auto variants"), None, "{raw}" ); } // hex 统一大写透传;色板白名单由服务端权威校验,客户端只守格式。 assert_eq!( - normalize_generate_image_screen_color(&json!({"screenColor": "#cfefff"}), "character") - .expect("lowercase hex"), + normalize_generate_image_screen_color( + &json!({"screenColor": "#cfefff"}), + GameCreationAppAssetKind::Character + ) + .expect("lowercase hex"), Some("#CFEFFF".to_string()) ); assert_eq!( normalize_generate_image_screen_color( &json!({"screenColor": " #A0BBA0 "}), - "art-spritesheet" + GameCreationAppAssetKind::IconSpritesheet ) .expect("padded hex"), Some("#A0BBA0".to_string()) @@ -2956,19 +3008,24 @@ mod tests { // 非 auto/非 hex、非字符串一律拒绝。 for bad in [json!("green"), json!("#GGGGGG"), json!("#FFF"), json!(12)] { assert!( - normalize_generate_image_screen_color(&json!({"screenColor": bad}), "character") - .is_err(), + normalize_generate_image_screen_color( + &json!({"screenColor": bad}), + GameCreationAppAssetKind::Character + ) + .is_err(), "{bad}" ); } // 其它 kind 携带该字段直接拒绝,即使取值合法。 - let gated = - normalize_generate_image_screen_color(&json!({"screenColor": "#CFEFFF"}), "image") - .expect_err("screenColor must stay scoped to character/art-spritesheet"); + let gated = normalize_generate_image_screen_color( + &json!({"screenColor": "#CFEFFF"}), + GameCreationAppAssetKind::Image, + ) + .expect_err("screenColor must stay scoped to character/icon-spritesheet"); assert!(gated.contains("kind=character"), "{gated}"); assert!(normalize_generate_image_screen_color( &json!({"screenColor": "auto"}), - "ui-prototype" + GameCreationAppAssetKind::UiDesign ) .is_err()); } @@ -3737,12 +3794,14 @@ mod tests { fn bridge_art_resource_exposes_only_the_safe_identity_projection() { let asset = GameCreationAppAssetManifestEntry { id: "local-art-1".to_string(), - kind: "art-spritesheet-slice".to_string(), + kind: GameCreationAppAssetKind::Icon, media_type: "image/png".to_string(), local_path: "assets/art-spritesheet-slices/player.png".to_string(), image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind("art-spritesheet-slice"), + category: game_creation_app_asset_category_for_kind( + GameCreationAppAssetKind::IconSpritesheet, + ), tags: Vec::new(), source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Canvas, @@ -3794,7 +3853,7 @@ mod tests { fn bridge_registered_resource_exposes_formal_sequence_without_private_generation_fields() { let asset = GameCreationAppAssetManifestEntry { id: "animation-1".to_string(), - kind: "character-animation".to_string(), + kind: GameCreationAppAssetKind::CharacterAnimation, media_type: "video/mp4".to_string(), local_path: "assets/edits/animation.mp4".to_string(), image_sequence_frames: Some(vec![ @@ -3807,7 +3866,9 @@ mod tests { }, ]), image_sequence_duration_ms: Some(4_000), - category: game_creation_app_asset_category_for_kind("character-animation"), + category: game_creation_app_asset_category_for_kind( + GameCreationAppAssetKind::CharacterAnimation, + ), tags: Vec::new(), source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Canvas, @@ -3845,7 +3906,7 @@ mod tests { fn bridge_registered_resource_keeps_explicit_manifest_classification() { let asset = GameCreationAppAssetManifestEntry { id: "spec-1".to_string(), - kind: "spec".to_string(), + kind: GameCreationAppAssetKind::Spec, media_type: "application/json".to_string(), local_path: "assets/specs/hero.json".to_string(), image_sequence_frames: None, @@ -3881,13 +3942,13 @@ mod tests { /// /// UI 栏目走 TS 的 `gameCreationAppAssetCategory`(落盘值 + 按 kind 派生 + 读时自愈), /// Agent 走 Rust 的同构实现 `game_creation_app_asset_effective_category`。直接透传落盘 - /// `category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、在 Agent 侧读到 - /// `unclassified`——真机 122 条资产里有 55 条这样分叉。 + /// `category` 会让 `ui-design` 的资产在 UI 显示「UI 交互」、在 Agent 侧读到 + /// `unclassified`,两侧分类口径必须一致。 #[test] fn bridge_registered_resource_projects_effective_category_not_raw_persisted_value() { let asset = GameCreationAppAssetManifestEntry { - id: "ui-1".to_string(), - kind: "ui".to_string(), + id: "ui-design-1".to_string(), + kind: GameCreationAppAssetKind::UiDesign, media_type: "application/json".to_string(), local_path: "assets/UI 设计 1.json".to_string(), image_sequence_frames: None, @@ -3910,7 +3971,7 @@ mod tests { }; let projection = bridge_registered_resource(&asset, false); - assert_eq!(projection["kind"], "ui"); + assert_eq!(projection["kind"], "ui-design"); assert_eq!(projection["category"], "ui-interaction"); } 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 47820340a..95c0b09a9 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 @@ -247,7 +247,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "type": "string", "enum": PLATFORM_ART_ASSET_GENERATION_KINDS, "default": "image", - "description": "image=普通新图(不做额外处理),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),spec/icon-spec=统一视觉规范图(spec 是服务端同义词,客户端统一登记为 icon-spec),ui-prototype=完整 UI 设计图,art-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图" + "description": "image=普通新图(不做额外处理),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),icon-spec=统一视觉规范图,ui-design=完整 UI 设计图,icon-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图" }, "aspectRatio": { "type": "string", @@ -273,7 +273,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "sliceMode": { "type": "string", "enum": ["connected-components", "grid"], - "description": "仅 kind=art-spritesheet 生效,且必填、没有默认值:需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入来自需求本身的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。省略、与 kind 不匹配或与 gridX/gridY 互相矛盾时客户端直接拒绝,不会替你选择" + "description": "仅 kind=icon-spritesheet 生效,且必填、没有默认值:需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入来自需求本身的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。省略、与 kind 不匹配或与 gridX/gridY 互相矛盾时客户端直接拒绝,不会替你选择" }, "gridX": { "type": "integer", @@ -291,11 +291,11 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "type": "integer", "minimum": 1, "maximum": 256, - "description": "只与 kind=art-spritesheet 且 sliceMode=connected-components 同时提供,用于约束目标素材张数;省略时按图像内容自动识别" + "description": "只与 kind=icon-spritesheet 且 sliceMode=connected-components 同时提供,用于约束目标素材张数;省略时按图像内容自动识别" }, "screenColor": { "type": "string", - "description": "抠图纯色背景,仅 kind=character(角色形象)和 kind=art-spritesheet(图标素材)生效,其它 kind 携带会被拒绝。生成时把主体置于该纯色背景上,回图后据此抠除背景。取值只能是 auto 或下列色板 hex 之一,传值只填 hex 本身、不要附带色名:#CFEFFF(浅雾蓝)、#B0C2E0(浅钢蓝)、#FFD6C2(暖浅桃色)、#E6D8FF(淡薰衣草紫)、#F4D8E8(浅粉灰)、#7FB3FF(中度天蓝)、#FFF2A8(浅柠黄)、#CFFFE1(淡薄荷绿)、#D8DEE8(浅中性灰)、#D8D2E8(淡灰紫)、#A8F7F0(高对比浅青)、#A0BBA0(灰竹绿);auto 时由服务端自动选色。手动指定时不能与角色或素材本体的颜色接近" + "description": "抠图纯色背景,仅 kind=character(角色形象)和 kind=icon-spritesheet(图标素材)生效,其它 kind 携带会被拒绝。生成时把主体置于该纯色背景上,回图后据此抠除背景。取值只能是 auto 或下列色板 hex 之一,传值只填 hex 本身、不要附带色名:#CFEFFF(浅雾蓝)、#B0C2E0(浅钢蓝)、#FFD6C2(暖浅桃色)、#E6D8FF(淡薰衣草紫)、#F4D8E8(浅粉灰)、#7FB3FF(中度天蓝)、#FFF2A8(浅柠黄)、#CFFFE1(淡薄荷绿)、#D8DEE8(浅中性灰)、#D8D2E8(淡灰紫)、#A8F7F0(高对比浅青)、#A0BBA0(灰竹绿);auto 时由服务端自动选色。手动指定时不能与角色或素材本体的颜色接近" } }, "required": ["prompt"], @@ -341,7 +341,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "type": "string", "minLength": 1, "maxLength": 80, - "description": "可选的 manifest 资源 kind 精确过滤,例如 video、character-animation、sound-effect、background-music 或 art-spritesheet-slice" + "description": "可选的 manifest 资源 kind 精确过滤,必须是 canonical kind(例如 video、character-animation、sound-effect、background-music、icon-spritesheet);传非 canonical 值会被拒绝并报错,不会静默返回空列表" }, "assetId": { "type": "string", @@ -2523,7 +2523,7 @@ mod tests { // 目录外的 kind 必须在本地拒绝:既不下发 bridge,也不产生任何计费副作用。 let unsupported_kind = call_agc_generate_image(&json!({ "prompt": "像素月光主角", - "kind": "game-art" + "kind": "future-kind" })) .await; assert_eq!(unsupported_kind["isError"], json!(true)); @@ -2533,7 +2533,7 @@ mod tests { let oversized_prompt = call_agc_generate_image(&json!({ "prompt": "x".repeat(DIRECT_TOOLS_MCP_MAX_IMAGE_PROMPT_CHARS + 1), - "kind": "art-spritesheet" + "kind": "icon-spritesheet" })) .await; assert_eq!(oversized_prompt["isError"], json!(true)); @@ -2897,12 +2897,12 @@ mod tests { fn tool_chain_image_asset(id: &str, local_path: &str) -> GameCreationAppAssetManifestEntry { GameCreationAppAssetManifestEntry { id: id.to_string(), - kind: "image".to_string(), + kind: GameCreationAppAssetKind::Image, media_type: "image/png".to_string(), local_path: local_path.to_string(), image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind("image"), + category: game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Image), tags: Vec::new(), source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Canvas, 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 a4327aff2..d59c7a493 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,7 +413,7 @@ pub(crate) struct PlatformArtAssetGenerationOptions { pub(crate) output_path: Option, pub(crate) aspect_ratio: String, pub(crate) image_size: String, - pub(crate) asset_kind: String, + pub(crate) asset_kind: GameCreationAppAssetKind, pub(crate) asset_label: String, pub(crate) replace_existing: bool, pub(crate) slice_count: Option, @@ -443,7 +443,7 @@ pub(crate) struct PlatformArtAssetGenerationOptions { /// 请求正文。同任务的幂等恢复由调用方继续用同一个栏目提交(与 `reference_asset_ids` 同口径)。 pub(crate) target_category: Option, /// 抠图纯色背景(auto/省略已归一为 None;Some 时是规范化后的大写 #RRGGBB)。 - /// 仅 character 与 art-spritesheet 链路透传给服务端。 + /// 仅 character 与 icon-spritesheet 链路透传给服务端。 pub(crate) screen_color: Option, } @@ -453,7 +453,7 @@ impl Default for PlatformArtAssetGenerationOptions { output_path: None, aspect_ratio: "1:1".to_string(), image_size: "1K".to_string(), - asset_kind: "game-art".to_string(), + asset_kind: GameCreationAppAssetKind::Image, asset_label: "AI 游戏首版美术素材".to_string(), replace_existing: false, slice_count: None, @@ -504,47 +504,47 @@ const PLATFORM_ART_REFERENCE_ASSET_ID_MAX_CHARS: usize = 128; /// GUI 侧 `generate_local_project_asset` 与 agent 侧 `agc_generate_image` 共用这一份目录, /// 两条调用路径不允许各写一套白名单;目录外的 kind 一律拒绝,不做兜底猜测。 /// -/// `spec` 只是平台图片生成的 generation kind 名称,客户端真正验证过的规范图类型是 -/// `icon-spec`:`build_platform_art_asset_prompt`、`platform_art_asset_art_spec` 与 -/// `canonical_art_spec_reference_at` 都只认 `icon-spec`,登记成 `spec` 的图既拿不到规范图 -/// 提示词,也无法作为 UI 原型与透明图集的权威参考。因此 `spec` 在 -/// `normalize_platform_art_asset_generation_kind` 里统一收口到 `icon-spec`。 -pub(crate) const PLATFORM_ART_ASSET_GENERATION_KINDS: &[&str] = &[ - "image", - "character", - "spec", - "icon-spec", - "ui-prototype", - "art-spritesheet", - "publication-material", +/// 生成通道只接受这些 canonical manifest kind;平台请求的 `generationKind` 是独立协议字段, +/// 由执行阶段根据 enum 成员派生。 +pub(crate) const PLATFORM_ART_ASSET_GENERATION_KINDS: &[GameCreationAppAssetKind] = &[ + GameCreationAppAssetKind::Image, + GameCreationAppAssetKind::Character, + GameCreationAppAssetKind::IconSpec, + GameCreationAppAssetKind::UiDesign, + GameCreationAppAssetKind::IconSpritesheet, + GameCreationAppAssetKind::PublicationMaterial, ]; /// 把外部传入的 kind 收口到 `PlatformArtAssetGenerationOptions::asset_kind` 的权威取值。 /// 返回 `None` 表示该 kind 未被验证过,调用方必须拒绝。 -pub(crate) fn normalize_platform_art_asset_generation_kind(kind: &str) -> Option<&'static str> { - let kind = kind.trim(); - let canonical = PLATFORM_ART_ASSET_GENERATION_KINDS +pub(crate) fn normalize_platform_art_asset_generation_kind( + kind: &str, +) -> Option { + let parsed = GameCreationAppAssetKind::parse_with_context(kind, "canvas.asset_kind"); + PLATFORM_ART_ASSET_GENERATION_KINDS .iter() - .find(|candidate| **candidate == kind)?; - Some(if *canonical == "spec" { - "icon-spec" - } else { - canonical - }) + .copied() + .find(|candidate| *candidate == parsed) } /// 需要规范图前置的生成类型:这些请求必须解析出当前账号的规范图引用,用户参考最多 /// [`PLATFORM_ART_MAX_USER_REFERENCE_IMAGES_WITH_CANONICAL_SPEC`] 张。 -pub(crate) fn platform_art_asset_kind_requires_canonical_spec_reference(asset_kind: &str) -> bool { +pub(crate) fn platform_art_asset_kind_requires_canonical_spec_reference( + asset_kind: GameCreationAppAssetKind, +) -> bool { matches!( asset_kind, - "ui-prototype" | "game-background" | "art-spritesheet" + GameCreationAppAssetKind::UiDesign + | GameCreationAppAssetKind::Scene + | GameCreationAppAssetKind::IconSpritesheet ) } /// 只有单规范引用的图集操作不接受用户参考:非法参考必须在原生提交处**拒绝**,不能静默丢弃。 -pub(crate) fn platform_art_asset_kind_accepts_user_reference_assets(asset_kind: &str) -> bool { - asset_kind != "art-spritesheet" +pub(crate) fn platform_art_asset_kind_accepts_user_reference_assets( + asset_kind: GameCreationAppAssetKind, +) -> bool { + asset_kind != GameCreationAppAssetKind::IconSpritesheet } /// 收口参考素材 id 入参:trim、去重(保持给出顺序),并拒绝路径 / 远端资源 ID / 跨项目身份。 @@ -552,7 +552,7 @@ pub(crate) fn platform_art_asset_kind_accepts_user_reference_assets(asset_kind: /// 这里只做**形状与数量**校验;「是不是当前项目已登记图片素材」由 /// [`manifest_asset_remote_reference_at`] 用 manifest 身份与图片解码证明,不靠命名猜测。 pub(crate) fn normalize_platform_art_reference_asset_ids( - asset_kind: &str, + asset_kind: GameCreationAppAssetKind, asset_ids: &[String], ) -> Result, String> { if !platform_art_asset_kind_accepts_user_reference_assets(asset_kind) && !asset_ids.is_empty() { @@ -609,7 +609,7 @@ pub(in crate::agent) fn recover_persisted_visual_generation_options( "assets/art-spec.png", "1:1", "1K", - "icon-spec", + GameCreationAppAssetKind::IconSpec, "游戏统一视觉规范图", "spec", ), @@ -617,7 +617,7 @@ pub(in crate::agent) fn recover_persisted_visual_generation_options( "assets/ui-prototype.png", "16:9", "2K", - "ui-prototype", + GameCreationAppAssetKind::UiDesign, "游戏横屏界面原型图", "ui-design", ), @@ -625,7 +625,7 @@ pub(in crate::agent) fn recover_persisted_visual_generation_options( "assets/art-spritesheet.png", "1:1", "1K", - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, "游戏首版核心美术素材", "icon-spritesheet", ), @@ -645,15 +645,17 @@ pub(in crate::agent) fn recover_persisted_visual_generation_options( if options.output_path.is_none() { options.output_path = Some(path.to_string()); } - for (value, default) in [ - (&mut options.aspect_ratio, ratio), - (&mut options.image_size, size), - (&mut options.asset_kind, kind), - (&mut options.asset_label, label), - ] { - if value.is_empty() { - *value = default.to_string(); - } + if options.aspect_ratio.is_empty() { + options.aspect_ratio = ratio.to_string(); + } + if options.image_size.is_empty() { + options.image_size = size.to_string(); + } + if options.asset_kind == GameCreationAppAssetKind::Unknown { + options.asset_kind = kind; + } + if options.asset_label.is_empty() { + options.asset_label = label.to_string(); } let snapshot = platform_art_generation_runtime_request_snapshot(&state)?; // 只恢复经过身份校验的已有请求;显式改参和新请求仍走当前合同。 @@ -1787,7 +1789,7 @@ fn canonical_art_spec_manifest_entry_at( .iter() .find(|asset| { asset.local_path == AGENT_RUNTIME_ART_SPEC_PATH - && asset.kind == "icon-spec" + && asset.kind == GameCreationAppAssetKind::IconSpec && asset.media_type.starts_with("image/") && asset.source.kind == GameCreationAppAssetSourceKind::Canvas }) @@ -1889,7 +1891,7 @@ async fn upload_manifest_asset_remote_reference_at( &source.id, &format!("{:x}", Sha256::digest(&bytes)), &source.media_type, - &source.kind, + source.kind.as_str(), )?; if let Some(binding) = read_external_editor_resource_binding_at( root, @@ -2125,11 +2127,11 @@ async fn resolve_platform_art_generation_references_at( options: &PlatformArtAssetGenerationOptions, ) -> Result { let user_reference_asset_ids = normalize_platform_art_reference_asset_ids( - &options.asset_kind, + options.asset_kind, &options.reference_asset_ids, )?; let requires_canonical = - platform_art_asset_kind_requires_canonical_spec_reference(&options.asset_kind); + platform_art_asset_kind_requires_canonical_spec_reference(options.asset_kind); // 预检:本次请求要用到的所有参考(规范图 + 用户参考)先在本地全部验证一遍, // 任何一个不合格都必须在**任何上传之前**失败,避免「第一张参考已上传、第二张坏图才失败」。 if requires_canonical || !user_reference_asset_ids.is_empty() { @@ -2507,8 +2509,8 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at( briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, ) -> Result { - if options.asset_kind != "art-spritesheet" { - return Err("严格游戏切片生成只允许 art-spritesheet 资产类型".to_string()); + if options.asset_kind != GameCreationAppAssetKind::IconSpritesheet { + return Err("严格游戏切片生成只允许 icon-spritesheet 资产类型".to_string()); } let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); let runtime_context = @@ -2542,7 +2544,7 @@ struct StandalonePlatformArtGenerationFingerprintMaterial<'a> { output_path: Option<&'a str>, aspect_ratio: &'a str, image_size: &'a str, - asset_kind: &'a str, + asset_kind: &'a GameCreationAppAssetKind, asset_label: &'a str, replace_existing: bool, require_slices: bool, @@ -2712,9 +2714,9 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_retained_runtime_ /// 这里判的是「形状」,不是具体身份:规范图到底是什么由调用方用当前账号的解析结果单独比对。 /// 提交侧的顺序合同固定为「规范图前置在最前,用户参考按给定顺序追加在后」,所以: /// -/// - `art-spritesheet`:只接受唯一规范引用,多一个用户参考都不算同合同; -/// - `ui-prototype` / `game-background`:必须有规范图前置(至少 1 项),总数不超过总上限; -/// - 其余放行 kind(`icon-spec` 等):没有规范前置,可以零参考,也可以全是用户参考; +/// - `IconSpritesheet`:只接受唯一规范引用,多一个用户参考都不算同合同; +/// - `UiDesign` / `Scene`:必须有规范图前置(至少 1 项),总数不超过总上限; +/// - 其余放行 kind(`IconSpec` 等):没有规范前置,可以零参考,也可以全是用户参考; /// - 不在目录里的 kind 一律判为不符合,失败关闭,不做兜底猜测。 /// /// 提交侧的收口在 [`normalize_platform_art_reference_asset_ids`] 与 @@ -2722,7 +2724,7 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_retained_runtime_ /// 是不是该 kind 的合法形状」,两边必须共用同一套上限,否则恢复校验会误判合法请求。 pub(crate) fn platform_art_runtime_references_match_request_contract( reference_resource_ids: &[String], - expected_asset_kind: &str, + expected_asset_kind: GameCreationAppAssetKind, ) -> bool { if reference_resource_ids .iter() @@ -2730,9 +2732,11 @@ pub(crate) fn platform_art_runtime_references_match_request_contract( { return false; } - // `game-background` 只由 Direct 运行时直接构造 options,不走 kind 归一目录,所以单独放行。 - if expected_asset_kind != "game-background" - && !PLATFORM_ART_ASSET_GENERATION_KINDS.contains(&expected_asset_kind) + // `Scene` 只由 Direct 运行时直接构造 options,不走 GUI kind 目录,所以单独放行。 + if expected_asset_kind != GameCreationAppAssetKind::Scene + && !PLATFORM_ART_ASSET_GENERATION_KINDS + .iter() + .any(|kind| *kind == expected_asset_kind) { return false; } @@ -2748,7 +2752,7 @@ pub(crate) fn platform_art_runtime_references_match_request_contract( pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_direct_stage_at( root: &Path, runtime_context: &PlatformArtGenerationRuntimeContext, - expected_asset_kind: &str, + expected_asset_kind: GameCreationAppAssetKind, ) -> Result { let Some(state) = read_platform_art_generation_runtime_state(root, runtime_context)? else { return Ok(false); @@ -2765,34 +2769,32 @@ pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_d .and_then(|value| value.get("assetType")) .and_then(serde_json::Value::as_str); let matches = match expected_asset_kind { - // 参考形状按请求合同判定,不再把「icon-spec 必须没有参考」当身份判据: - // 图标规范允许普通参考,恢复校验必须与提交时同一套上限,否则会误判合法的保留账本。 - "icon-spec" => { + GameCreationAppAssetKind::IconSpec => { snapshot.endpoint == "/api/external/v1/editor/images/generations" && snapshot.generation_kind == "spec" && platform_art_runtime_references_match_request_contract( &snapshot.reference_resource_ids, - "icon-spec", + GameCreationAppAssetKind::IconSpec, ) && request_asset_kind.as_deref() == Some("icon-spec") && art_spec_asset_type == Some("icon-spec") } - "game-background" => { + GameCreationAppAssetKind::Scene => { snapshot.endpoint == "/api/external/v1/editor/images/generations" && snapshot.generation_kind == "spec" && platform_art_runtime_references_match_request_contract( &snapshot.reference_resource_ids, - "game-background", + GameCreationAppAssetKind::Scene, ) && request_asset_kind.as_deref() == Some("game-background") && art_spec_asset_type == Some("background") } - "art-spritesheet" => { + GameCreationAppAssetKind::IconSpritesheet => { snapshot.endpoint == "/api/external/v1/editor/icon-spritesheets/generations" && snapshot.generation_kind == "icon-spritesheet" && platform_art_runtime_references_match_request_contract( &snapshot.reference_resource_ids, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, ) && request_asset_kind.is_none() && art_spec_asset_type == Some("art") @@ -2810,7 +2812,7 @@ pub(in crate::agent) fn retained_platform_art_generation_runtime_spritesheet_ide if !retained_platform_art_generation_runtime_state_matches_direct_stage_at( root, runtime_context, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, )? { return Ok(None); } @@ -2851,11 +2853,11 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at( retain_runtime_state: bool, runtime_context: &PlatformArtGenerationRuntimeContext, ) -> Result { - if require_slices && options.asset_kind != "art-spritesheet" { - return Err("严格游戏切片生成只允许 art-spritesheet 资产类型".to_string()); + if require_slices && options.asset_kind != GameCreationAppAssetKind::IconSpritesheet { + return Err("严格游戏切片生成只允许 icon-spritesheet 资产类型".to_string()); } // 切分模式没有默认值:图集生成必须在客户端显式声明,缺失或自相矛盾都在付费提交前失败。 - if options.asset_kind == "art-spritesheet" { + if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet { let Some(slice_mode) = options.slice_mode.as_deref() else { return Err( "图集生成必须显式声明 sliceMode:等分网格或固定槽位用 grid 并提供 gridX/gridY,自由排布用 connected-components" @@ -2877,7 +2879,7 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at( ); } } else if options.slice_mode.is_some() || options.grid_x.is_some() || options.grid_y.is_some() { - return Err("sliceMode/gridX/gridY 仅对 art-spritesheet 生效".to_string()); + return Err("sliceMode/gridX/gridY 仅对 icon-spritesheet 生效".to_string()); } if super::external_generation_state::is_standalone_platform_art_generation_runtime_context( runtime_context, @@ -3179,15 +3181,16 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at } else { let canvas_context = prepare_external_canvas_generation_context(root, &client, &binding_access).await?; - let generation_kind = match options.asset_kind.as_str() { - "ui-prototype" => "ui-design", - "art-spritesheet" => "icon-spritesheet", - "image" => "image", - "character" => "character", - "publication-material" => "publication-material", + let generation_kind = match options.asset_kind { + GameCreationAppAssetKind::UiDesign => "ui-design", + GameCreationAppAssetKind::IconSpritesheet => "icon-spritesheet", + GameCreationAppAssetKind::Image => "image", + GameCreationAppAssetKind::Character => "character", + GameCreationAppAssetKind::PublicationMaterial => "publication-material", _ => "spec", }; - let is_canonical_art_spritesheet = options.asset_kind == "art-spritesheet"; + let is_canonical_art_spritesheet = + options.asset_kind == GameCreationAppAssetKind::IconSpritesheet; // 参考顺序「规范图在前、用户参考随后」与去重、上限都在这里统一决定, // 不区分 GUI 与 agent 调用路径;图集类型的用户参考已在解析处被拒绝。 let references = resolve_platform_art_generation_references_at( @@ -3235,7 +3238,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "kind": generation_kind, "aspectRatio": options.aspect_ratio, "imageSize": options.image_size, - "assetKind": options.asset_kind, + "assetKind": options.asset_kind.as_str(), "assetLabel": options.asset_label, "projectId": canvas_context.project_id, "assetFolderId": canvas_context.asset_folder_id, @@ -3251,16 +3254,18 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at ) }; let request_body = if matches!( - options.asset_kind.as_str(), - "image" | "character" | "publication-material" + options.asset_kind, + GameCreationAppAssetKind::Image + | GameCreationAppAssetKind::Character + | GameCreationAppAssetKind::PublicationMaterial ) { let mut request_body = request_body; if let Some(object) = request_body.as_object_mut() { object.remove("generationInputs"); - if options.asset_kind == "image" { + if options.asset_kind == GameCreationAppAssetKind::Image { object.remove("kind"); } - if options.asset_kind == "character" { + if options.asset_kind == GameCreationAppAssetKind::Character { if let Some(screen_color) = options.screen_color.as_deref() { object.insert( "screenColor".to_string(), @@ -7315,7 +7320,7 @@ fn register_platform_art_slice_manifest_entries_at( .iter_mut() .find(|asset| asset.local_path == registration.local_path) { - existing.kind = "art-spritesheet-slice".to_string(); + existing.kind = GameCreationAppAssetKind::Icon; existing.media_type = registration.media_type.clone(); existing.image_sequence_frames = None; existing.image_sequence_duration_ms = None; @@ -7329,12 +7334,14 @@ fn register_platform_art_slice_manifest_entries_at( ); manifest.assets.push(GameCreationAppAssetManifestEntry { id: id.clone(), - kind: "art-spritesheet-slice".to_string(), + kind: GameCreationAppAssetKind::Icon, media_type: registration.media_type.clone(), local_path: registration.local_path.clone(), image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind("art-spritesheet-slice"), + category: game_creation_app_asset_category_for_kind( + GameCreationAppAssetKind::Icon, + ), tags: Vec::new(), source, }); @@ -7366,7 +7373,7 @@ fn existing_platform_art_slice_registrations_are_complete( return Ok(false); }; if candidates.next().is_some() - || asset.kind != "art-spritesheet-slice" + || asset.kind != GameCreationAppAssetKind::Icon || asset.media_type != "image/png" || asset.source.kind != GameCreationAppAssetSourceKind::Canvas || asset.source.generation_route.as_deref() @@ -7455,10 +7462,15 @@ pub(in crate::agent) fn register_existing_platform_art_slices_at( .clone() .filter(|value| !value.trim().is_empty()) .ok_or_else(|| "旧项目图集源资源缺少 sourceResourceId".to_string())?; - let has_partial_or_invalid_registration = manifest - .assets - .iter() - .any(|asset| asset.kind == "art-spritesheet-slice"); + // 判据只看路径:`assets/art-spritesheet-slices/` 是切片专用目录,任何落在这里的登记都是 + // 切片登记。不能再要求 `kind == Icon`——历史项目里切片刻的 kind 是非 canonical 值 + // (`art-spritesheet-slice` 等),严格解析后收口成 `unknown`,加上 kind 判据就会漏掉这些 + // 半成品登记,让下面的回填被静默跳过。 + let has_partial_or_invalid_registration = manifest.assets.iter().any(|asset| { + asset + .local_path + .starts_with("assets/art-spritesheet-slices/") + }); let receipt_path = resolve_local_project_path(root, ".agent/runtime/art-spritesheet-contract.json")?; @@ -8084,7 +8096,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( let registered = match register_local_asset_entry_with_category( root, &local_path, - &options.asset_kind, + options.asset_kind, &download.media_type, "platform-art", GameCreationAppAssetSource { @@ -8215,7 +8227,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( pub(crate) fn platform_art_asset_art_spec( options: &PlatformArtAssetGenerationOptions, ) -> serde_json::Value { - if options.asset_kind == "icon-spec" { + if options.asset_kind == GameCreationAppAssetKind::IconSpec { return serde_json::json!({ "assetType": "icon-spec", "subject": "游戏角色、场景、UI 图标与反馈特效的统一视觉规范", @@ -8227,7 +8239,7 @@ pub(crate) fn platform_art_asset_art_spec( "references": [], }); } - if options.asset_kind == "ui-prototype" { + if options.asset_kind == GameCreationAppAssetKind::UiDesign { return serde_json::json!({ "assetType": "ui", "subject": "严格依据当前项目玩法合同生成的完整游戏 UI 原型,包含状态 HUD、主要可玩区域、关键实体、操作控件、失败与重开流程", @@ -8239,7 +8251,7 @@ pub(crate) fn platform_art_asset_art_spec( "references": [], }); } - if options.asset_kind == "game-background" { + if options.asset_kind == GameCreationAppAssetKind::Scene { return serde_json::json!({ "assetType": "background", "subject": format!("{};使用项目原创命名和原创场景设计", options.asset_label), @@ -8266,37 +8278,37 @@ pub(crate) fn build_platform_art_asset_prompt( briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, ) -> String { - if options.asset_kind == "image" { + if options.asset_kind == GameCreationAppAssetKind::Image { return format!( "根据用户需求生成一张全新的原创图片。主体、环境、风格、构图、光线和色彩以用户描述为准;不要生成素材图集、规范展板、完整游戏截图或文字说明。不得修改或复述为已有图片编辑。\n\n用户需求:{}", truncate_prompt_context(prompt.trim()) ); } - if options.asset_kind == "character" { + if options.asset_kind == GameCreationAppAssetKind::Character { return format!( "根据用户需求生成一张全新的原创角色形象或人物立绘。清楚表现角色外貌、服饰、姿势、表情、画风、构图和背景;只生成一张完整图片,不要生成图集、规范展板、完整游戏截图或文字说明。不得复刻现有作品角色或 Logo。\n\n用户需求:{}", truncate_prompt_context(prompt.trim()) ); } - if options.asset_kind == "publication-material" { + if options.asset_kind == GameCreationAppAssetKind::PublicationMaterial { return format!( "根据用户需求生成一张全新的原创游戏发布宣传图。突出主体、卖点、氛围、构图、色彩和适合发布展示的画面层次;只生成一张完整图片,不要生成素材图集、规范展板、完整游戏截图或文字说明。不得复刻现有作品角色或 Logo。\n\n用户需求:{}", truncate_prompt_context(prompt.trim()) ); } - if options.asset_kind == "icon-spec" { + if options.asset_kind == GameCreationAppAssetKind::IconSpec { return format!( "为这个 Web 小游戏生成一张 1:1 的统一视觉规范图,作为后续 UI 设计图和透明游戏图集的共同权威参考。规范板必须分区展示:玩家主体及其成长形态、核心目标或收集物、场景地块与障碍、HUD/操作图标、得分/受击/胜负反馈、主辅强调色与材质规则。所有元素使用一致的正交视角、轮廓、光照和原创视觉语言,留出清楚间距;不要生成完整游戏截图、海报、黑底图集或纯文字说明。玩法机制只用于理解功能,不授权复刻现有作品。\n\n项目视觉需求:{}", truncate_prompt_context(prompt.trim()) ); } - if options.asset_kind == "ui-prototype" { + if options.asset_kind == GameCreationAppAssetKind::UiDesign { return format!( "生成一张真正的游戏 UI/UX 原型图,不是场景概念图。必须严格从下方当前项目 UI 需求提取玩法,不得自行假设它属于塔防或加入需求中不存在的单位卡牌、费用、波次、敌人入口等结构。画面是完整 16:9 桌面端单屏界面,并同时明确移动端重排意图;清楚呈现当前玩法所需的分数/资源/生命/局内状态 HUD、主要可玩区域、玩家与目标/收集物/危险物、开始和主要操作、失败状态与重新开始、键盘和触控提示。使用正视角、清晰分区和可读占位文字,使前端开发可直接据此拆分 HTML/CSS。禁止只画无 HUD 场景、概念图、地图、海报或纯插画。玩法机制只用于理解功能,不授权复刻现有作品;必须采用项目已经确定的原创命名、角色轮廓、配色、场景材质和界面视觉语言,不得使用现有游戏 Logo、贴图、标志性布局或受保护视觉语言。\n\n当前项目 UI 需求:{}", truncate_prompt_context(prompt.trim()) ); } - if options.asset_kind == "game-background" { + if options.asset_kind == GameCreationAppAssetKind::Scene { return format!( "为 Web 小游戏生成一张可直接作为运行画面底图的原创 16:9 场景背景。严格从下方用户需求提炼自己的游戏主题、地点、季节、材质和氛围;画面要为真实可玩区域留出足够清楚的中部空间,并有前景、中景、远景层次。不得画玩家角色、道具、棋子、障碍、HUD、操作按钮、文字、Logo、完整游戏截图、海报或素材图集;这些元素会从独立透明核心图集中绘制。不得自行假设为塔防或加入玩法合同中不存在的实体;必须原创,不得复刻现有游戏场景、贴图、标志性布局或受保护视觉语言。\n\n用户需求:{}", truncate_prompt_context(prompt.trim()) @@ -8327,57 +8339,47 @@ mod canvas_generation_tests { }; #[test] - fn generation_kind_catalog_normalizes_spec_onto_the_verified_icon_spec() { - // 目录就是两条调用路径共同的可生成集合,必须逐字固定。 + fn generation_kind_catalog_accepts_only_canonical_manifest_kinds() { assert_eq!( PLATFORM_ART_ASSET_GENERATION_KINDS, &[ - "image", - "character", - "spec", - "icon-spec", - "ui-prototype", - "art-spritesheet", - "publication-material" + GameCreationAppAssetKind::Image, + GameCreationAppAssetKind::Character, + GameCreationAppAssetKind::IconSpec, + GameCreationAppAssetKind::UiDesign, + GameCreationAppAssetKind::IconSpritesheet, + GameCreationAppAssetKind::PublicationMaterial, ] ); - for kind in ["image", "character", "ui-prototype", "art-spritesheet"] { + for kind in [ + GameCreationAppAssetKind::Image, + GameCreationAppAssetKind::Character, + GameCreationAppAssetKind::IconSpec, + GameCreationAppAssetKind::UiDesign, + GameCreationAppAssetKind::IconSpritesheet, + ] { assert_eq!( - normalize_platform_art_asset_generation_kind(kind), + normalize_platform_art_asset_generation_kind(kind.as_str()), Some(kind), - "{kind} 必须原样落到 assetKind" + "{} 必须原样落到 assetKind", + kind.as_str() ); } - // `spec` 只是服务端 generation kind;客户端验证过的规范图类型是 icon-spec, - // 提示词与规范图引用都只认它,所以这里必须收口而不是放行原值。 - assert_eq!( - normalize_platform_art_asset_generation_kind("spec"), - Some("icon-spec") - ); - assert_eq!( - normalize_platform_art_asset_generation_kind("icon-spec"), - Some("icon-spec") - ); - assert_eq!( - normalize_platform_art_asset_generation_kind(" art-spritesheet "), - Some("art-spritesheet") - ); - // 未验证的 kind 一律拒绝,不做兜底猜测。 - for kind in ["game-art", "game-background", "UI", "asset", "scene", ""] { + for kind in ["future-kind", "", "UI"] { assert_eq!( normalize_platform_art_asset_generation_kind(kind), None, - "{kind} 不在已审核目录内,必须被拒绝" + "{kind} 不是当前生成入口的 canonical kind" ); } } #[test] fn generation_kind_catalog_binds_art_spritesheet_to_a_spec_board_prompt() { - // 放行 art-spritesheet 必须真的走到图集提示词与图集请求合同, + // 放行 icon-spritesheet 必须真的走到图集提示词与图集请求合同, // 否则新 IPC 只是加了一个能通过校验但不生成图集的 kind。 let options = PlatformArtAssetGenerationOptions { - asset_kind: "art-spritesheet".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpritesheet, ..PlatformArtAssetGenerationOptions::default() }; assert!( @@ -8580,7 +8582,7 @@ mod canvas_generation_tests { let options = PlatformArtAssetGenerationOptions { output_path: Some("assets/manual-concurrent.png".to_string()), - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "并发手工素材".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -8758,7 +8760,7 @@ mod canvas_generation_tests { let options = PlatformArtAssetGenerationOptions { output_path: Some("assets/manual-failed-retry.png".to_string()), - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "失败后可重试素材".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -8811,7 +8813,7 @@ mod canvas_generation_tests { output_path: Some("assets/manual-art.png".to_string()), aspect_ratio: "16:9".to_string(), image_size: "2K".to_string(), - asset_kind: "game-background".to_string(), + asset_kind: GameCreationAppAssetKind::Scene, asset_label: "手工背景".to_string(), replace_existing: true, slice_count: None, @@ -8879,7 +8881,7 @@ mod canvas_generation_tests { changed.image_size = "1K".to_string(); changed_options.push(changed); let mut changed = options.clone(); - changed.asset_kind = "ui-prototype".to_string(); + changed.asset_kind = GameCreationAppAssetKind::UiDesign; changed_options.push(changed); let mut changed = options.clone(); changed.asset_label = "另一个标签".to_string(); @@ -8934,7 +8936,7 @@ mod canvas_generation_tests { .expect("init independent slot project"); let options = PlatformArtAssetGenerationOptions { output_path: None, - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "图标规范".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -9202,13 +9204,13 @@ mod canvas_generation_tests { // 升级前它们共用同一个 (automatic-output) 槽,因此第二条必然被拒;升级后各自成槽。 let first_options = PlatformArtAssetGenerationOptions { output_path: None, - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "并发素材一".to_string(), ..PlatformArtAssetGenerationOptions::default() }; let second_options = PlatformArtAssetGenerationOptions { output_path: None, - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "并发素材二".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -9316,7 +9318,7 @@ mod canvas_generation_tests { .expect("init legacy slot project"); let options = PlatformArtAssetGenerationOptions { output_path: None, - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "图标规范".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -9436,7 +9438,7 @@ mod canvas_generation_tests { // 另一个精确动作的旧槽账本不得被本次动作消费、改写或删除。 let other_options = PlatformArtAssetGenerationOptions { output_path: None, - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "另一个图标规范".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -10864,7 +10866,7 @@ mod canvas_generation_tests { output_path: Some("assets/art-spec.png".to_string()), aspect_ratio: "1:1".to_string(), image_size: "1K".to_string(), - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "整包规范图".to_string(), replace_existing: false, slice_count: None, @@ -11259,7 +11261,7 @@ mod canvas_generation_tests { }; let current_prompt = "账号 A 已提交的生成请求"; let current_options = PlatformArtAssetGenerationOptions { - asset_kind: "game-art".to_string(), + asset_kind: GameCreationAppAssetKind::Image, asset_label: "账号隔离测试".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -11467,7 +11469,7 @@ mod canvas_generation_tests { let current_prompt = "恢复时使用同一个接受后生成意图"; let current_options = PlatformArtAssetGenerationOptions { output_path: None, - asset_kind: "game-art".to_string(), + asset_kind: GameCreationAppAssetKind::Image, asset_label: "当前标签".to_string(), ..PlatformArtAssetGenerationOptions::default() }; @@ -11710,7 +11712,7 @@ mod canvas_generation_tests { register_local_asset_at( root, "assets/art-spec.png", - "icon-spec", + GameCreationAppAssetKind::IconSpec, "image/png", "changed-reference-test", GameCreationAppAssetSource { @@ -11771,7 +11773,7 @@ mod canvas_generation_tests { output_path: Some("assets/direct-game-background.png".to_string()), aspect_ratio: "16:9".to_string(), image_size: "1K".to_string(), - asset_kind: "game-background".to_string(), + asset_kind: GameCreationAppAssetKind::Scene, asset_label: "整包背景图".to_string(), replace_existing: false, slice_count: None, @@ -12238,7 +12240,7 @@ mod canvas_generation_tests { output_path: Some(AGENT_RUNTIME_ART_SPEC_PATH.to_string()), aspect_ratio: "1:1".to_string(), image_size: "1K".to_string(), - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "游戏统一视觉规范图".to_string(), replace_existing: false, slice_count: None, @@ -12282,7 +12284,7 @@ mod canvas_generation_tests { output_path: Some(AGENT_RUNTIME_ART_SPEC_PATH.to_string()), aspect_ratio: String::new(), image_size: String::new(), - asset_kind: String::new(), + asset_kind: GameCreationAppAssetKind::Unknown, asset_label: String::new(), ..Default::default() }; @@ -12300,7 +12302,7 @@ mod canvas_generation_tests { .unwrap() .is_none()); let mut explicit_options = sparse_options.clone(); - explicit_options.asset_kind = "game-background".to_string(); + explicit_options.asset_kind = GameCreationAppAssetKind::Scene; assert!(recover_persisted_visual_generation_options( root, &pending, @@ -12611,7 +12613,7 @@ mod canvas_generation_tests { import_canvas_asset_at( root, AGENT_RUNTIME_ART_SPEC_PATH, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "image/png", "historical-canvas-project", Some("historical-resource".to_string()), @@ -12856,8 +12858,11 @@ mod canvas_generation_tests { }; // 去重保持给出顺序,空白项直接丢弃。 assert_eq!( - normalize_platform_art_reference_asset_ids("icon-spec", &ids(&[" b ", "a", "b", " "])) - .expect("normalize icon-spec references"), + normalize_platform_art_reference_asset_ids( + GameCreationAppAssetKind::IconSpec, + &ids(&[" b ", "a", "b", " "]) + ) + .expect("normalize icon-spec references"), ids(&["b", "a"]) ); // 路径、跨项目远端资源 ID 与非法字符都不是可接受的素材身份。 @@ -12869,14 +12874,18 @@ mod canvas_generation_tests { &"a".repeat(PLATFORM_ART_REFERENCE_ASSET_ID_MAX_CHARS + 1), ] { assert!( - normalize_platform_art_reference_asset_ids("icon-spec", &ids(&[rejected])).is_err(), + normalize_platform_art_reference_asset_ids( + GameCreationAppAssetKind::IconSpec, + &ids(&[rejected]) + ) + .is_err(), "{rejected} 不能被当成参考素材 id" ); } // 无规范前置:最多 5 张;有规范前置:用户参考最多 4 张。 assert_eq!( normalize_platform_art_reference_asset_ids( - "icon-spec", + GameCreationAppAssetKind::IconSpec, &ids(&["a", "b", "c", "d", "e"]) ) .expect("five references without a canonical spec") @@ -12884,30 +12893,36 @@ mod canvas_generation_tests { 5 ); assert!(normalize_platform_art_reference_asset_ids( - "icon-spec", + GameCreationAppAssetKind::IconSpec, &ids(&["a", "b", "c", "d", "e", "f"]) ) .is_err()); assert_eq!( - normalize_platform_art_reference_asset_ids("ui-prototype", &ids(&["a", "b", "c", "d"])) - .expect("four user references with a canonical spec") - .len(), + normalize_platform_art_reference_asset_ids( + GameCreationAppAssetKind::UiDesign, + &ids(&["a", "b", "c", "d"]) + ) + .expect("four user references with a canonical spec") + .len(), 4 ); assert!(normalize_platform_art_reference_asset_ids( - "ui-prototype", + GameCreationAppAssetKind::UiDesign, &ids(&["a", "b", "c", "d", "e"]) ) .is_err()); // 图集只接受单规范引用:额外参考必须被拒绝,不能静默丢弃。 - assert!( - normalize_platform_art_reference_asset_ids("art-spritesheet", &ids(&["a"])).is_err() - ); - assert!( - normalize_platform_art_reference_asset_ids("art-spritesheet", &[]) - .expect("spritesheet without user references") - .is_empty() - ); + assert!(normalize_platform_art_reference_asset_ids( + GameCreationAppAssetKind::IconSpritesheet, + &ids(&["a"]) + ) + .is_err()); + assert!(normalize_platform_art_reference_asset_ids( + GameCreationAppAssetKind::IconSpritesheet, + &[] + ) + .expect("spritesheet without user references") + .is_empty()); } /// 恢复侧与提交侧必须共用同一套参考上限,否则合法账本会被判成身份不符。 @@ -12921,18 +12936,21 @@ mod canvas_generation_tests { // 根素材(icon-spec):没有规范前置,可以零参考,也可以全是用户参考。 assert!(platform_art_runtime_references_match_request_contract( &[], - "icon-spec" + GameCreationAppAssetKind::IconSpec )); assert!(platform_art_runtime_references_match_request_contract( &references(5), - "icon-spec" + GameCreationAppAssetKind::IconSpec )); assert!(!platform_art_runtime_references_match_request_contract( &references(6), - "icon-spec" + GameCreationAppAssetKind::IconSpec )); // 有规范前置:规范图必须在场,总量仍不超过 5 张(含规范图)。 - for kind in ["ui-prototype", "game-background"] { + for kind in [ + GameCreationAppAssetKind::UiDesign, + GameCreationAppAssetKind::Scene, + ] { assert!( !platform_art_runtime_references_match_request_contract(&[], kind), "{kind} 必须有规范图前置" @@ -12953,32 +12971,32 @@ mod canvas_generation_tests { // 图集:恰好一项,多一项都不算同一份请求合同。 assert!(!platform_art_runtime_references_match_request_contract( &[], - "art-spritesheet" + GameCreationAppAssetKind::IconSpritesheet )); assert!(platform_art_runtime_references_match_request_contract( &references(1), - "art-spritesheet" + GameCreationAppAssetKind::IconSpritesheet )); assert!(!platform_art_runtime_references_match_request_contract( &references(2), - "art-spritesheet" + GameCreationAppAssetKind::IconSpritesheet )); // 普通图片类生成与规范图共用总上限;空白项与未知 kind 一律拒绝。 assert!(platform_art_runtime_references_match_request_contract( &references(5), - "image" + GameCreationAppAssetKind::Image )); assert!(!platform_art_runtime_references_match_request_contract( &references(6), - "image" + GameCreationAppAssetKind::Image )); assert!(!platform_art_runtime_references_match_request_contract( &[" ".to_string()], - "icon-spec" + GameCreationAppAssetKind::IconSpec )); assert!(!platform_art_runtime_references_match_request_contract( &references(1), - "unknown-kind" + GameCreationAppAssetKind::Unknown )); } @@ -13062,7 +13080,7 @@ mod canvas_generation_tests { retained_platform_art_generation_runtime_state_matches_direct_stage_at( root, &icon_spec, - "icon-spec", + GameCreationAppAssetKind::IconSpec, ) .expect("read icon-spec ledger with user references") ); @@ -13086,7 +13104,7 @@ mod canvas_generation_tests { retained_platform_art_generation_runtime_state_matches_direct_stage_at( root, &background, - "game-background", + GameCreationAppAssetKind::Scene, ) .expect("read game-background ledger with a canonical and a user reference") ); @@ -13108,7 +13126,7 @@ mod canvas_generation_tests { retained_platform_art_generation_runtime_state_matches_direct_stage_at( root, &spritesheet, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, ) .expect("read art-spritesheet ledger with the canonical reference") ); @@ -13139,7 +13157,7 @@ mod canvas_generation_tests { !retained_platform_art_generation_runtime_state_matches_direct_stage_at( root, &over_limit, - "icon-spec", + GameCreationAppAssetKind::IconSpec, ) .expect("read over limit icon-spec ledger") ); @@ -13168,7 +13186,7 @@ mod canvas_generation_tests { output_path: Some("assets/art-spritesheet.png".to_string()), aspect_ratio: "1:1".to_string(), image_size: "1K".to_string(), - asset_kind: "art-spritesheet".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpritesheet, asset_label: "游戏首版核心美术素材".to_string(), replace_existing: true, slice_count: None, @@ -13611,7 +13629,7 @@ mod canvas_generation_tests { let first_slice_ids = registered .assets .iter() - .filter(|asset| asset.kind == "art-spritesheet-slice") + .filter(|asset| asset.kind == GameCreationAppAssetKind::Icon) .map(|asset| { assert_eq!(asset.source.kind, GameCreationAppAssetSourceKind::Canvas); assert_eq!( @@ -13645,7 +13663,7 @@ mod canvas_generation_tests { .clone(); mutate_manifest_at(root, |manifest| { manifest.assets.retain(|asset| { - asset.kind != "art-spritesheet-slice" || asset.local_path == retained_path + asset.kind != GameCreationAppAssetKind::Icon || asset.local_path == retained_path }); Ok(()) }) @@ -13659,7 +13677,7 @@ mod canvas_generation_tests { let backfilled_slice_ids = backfilled .assets .iter() - .filter(|asset| asset.kind == "art-spritesheet-slice") + .filter(|asset| asset.kind == GameCreationAppAssetKind::Icon) .map(|asset| (asset.local_path.clone(), asset.id.clone())) .collect::>(); assert_eq!(backfilled_slice_ids.len(), 4); @@ -13708,7 +13726,7 @@ mod canvas_generation_tests { let replaced_slice_ids = replaced .assets .iter() - .filter(|asset| asset.kind == "art-spritesheet-slice") + .filter(|asset| asset.kind == GameCreationAppAssetKind::Icon) .map(|asset| { assert_eq!( asset.source.reference_resource_ids, @@ -13741,7 +13759,7 @@ mod canvas_generation_tests { register_local_asset_entry( root, AGENT_RUNTIME_ART_SPRITESHEET_PATH, - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, "image/png", "platform-art", GameCreationAppAssetSource { @@ -13802,12 +13820,12 @@ mod canvas_generation_tests { assert!(after .assets .iter() - .all(|asset| asset.kind != "art-spritesheet-slice")); + .all(|asset| asset.kind != GameCreationAppAssetKind::Icon)); register_local_asset_entry( root, "assets/art-spritesheet-slices/player.png", - "art-spritesheet-slice", + GameCreationAppAssetKind::Icon, "image/png", "platform-art", GameCreationAppAssetSource { @@ -15363,7 +15381,10 @@ mod canvas_generation_tests { let manifest = read_manifest_for_project(root).expect("read replacement manifest"); assert_eq!(manifest.assets.len(), 1); assert_eq!(manifest.assets[0].local_path, "assets/art-spritesheet.png"); - assert_eq!(manifest.assets[0].kind, "art-spritesheet"); + assert_eq!( + manifest.assets[0].kind, + GameCreationAppAssetKind::IconSpritesheet + ); assert_eq!( fs::read_dir(root.join("assets")) .expect("read assets directory") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs index 0a8974342..c88a8a19c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs @@ -31,9 +31,9 @@ fn is_direct_platform_art_generation_runtime_context( context: &PlatformArtGenerationRuntimeContext, ) -> bool { let asset_kind = match context.run_id.as_str() { - "art-spec" => "icon-spec", - "game-background" => "game-background", - "art-spritesheet" => "art-spritesheet", + "art-spec" => GameCreationAppAssetKind::IconSpec, + "game-background" => GameCreationAppAssetKind::Scene, + "art-spritesheet" => GameCreationAppAssetKind::IconSpritesheet, _ => return false, }; context.agent_id == "direct-codex-art" @@ -42,7 +42,11 @@ fn is_direct_platform_art_generation_runtime_context( && context.source == "direct-codex" && context.action_id == format!("direct-taonier-{}", context.run_id) && context.action_fingerprint - == format!("direct-taonier-art-v1:{asset_kind}:{}", context.run_id) + == format!( + "direct-taonier-art-v1:{}:{}", + asset_kind.as_str(), + context.run_id + ) } #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index dc09f659e..8e97d1242 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -98,7 +98,7 @@ pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result ( + GameCreationAppAssetKind::IconSpec => ( "/api/external/v1/editor/images/generations", "spec", Vec::new(), ), - "ui-prototype" => ( + GameCreationAppAssetKind::UiDesign => ( "/api/external/v1/editor/images/generations", "ui-design", vec!["resource-icon-spec".to_string()], @@ -2082,7 +2088,7 @@ mod tests { vec!["resource-icon-spec".to_string()], ), }; - let bytes = if kind == "art-spritesheet" { + let bytes = if kind == GameCreationAppAssetKind::IconSpritesheet { use image::ImageEncoder; let mut bytes = Vec::new(); image::codecs::png::PngEncoder::new(&mut bytes) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index fa5bbdc7f..579e6703b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -1136,7 +1136,7 @@ pub(in crate::agent) fn ui_prototype_visual_inspection_blocker_detail_at_locked( && record .get("inspectionKind") .and_then(serde_json::Value::as_str) - == Some(AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND) + == Some(AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND) && record .get("validationProfile") .and_then(serde_json::Value::as_str) @@ -1216,11 +1216,19 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked( return None; } let (expected_path, expected_kind, label) = match agent_id { - "art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"), - "design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"), + "art-director" => ( + AGENT_RUNTIME_ART_SPEC_PATH, + GameCreationAppAssetKind::IconSpec, + "统一视觉规范图", + ), + "design-foundation" => ( + "assets/ui-prototype.png", + GameCreationAppAssetKind::UiDesign, + "策划界面原型图", + ), "art-asset-plan" => ( "assets/art-spritesheet.png", - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, "首版美术素材图", ), _ => return None, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index b38ed1023..f30084bfd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -336,7 +336,11 @@ fn build_game_creator_agent_background_tool_plan_request_at( render_game_creator_agent_runtime_steers_for_prompt(root, agent_id, session_id, run_id)?; let loop_index = loop_index.saturating_add(1); let context_preload_notice = game_creator_agent_context_preload_notice(agent_id); - let canvas_asset_kind_catalog = AGENT_RUNTIME_CANVAS_ASSET_KINDS.join("|"); + let canvas_asset_kind_catalog = AGENT_RUNTIME_CANVAS_ASSET_KINDS + .iter() + .map(|kind| kind.as_str()) + .collect::>() + .join("|"); let limited_command_contract = if runtime_owner_artifact_validation_available { "当前固定 owner 不得调用 command.run_limited 或 project.verify;完成固定正式产物后直接交付,由 Runtime 在收束门内执行结构验证。".to_string() } else { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index 68e315d3d..7c688b2b9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -1,7 +1,7 @@ use super::*; -pub(crate) const AGENT_RUNTIME_CANVAS_ASSET_KINDS: &[&str] = - &["game-art", "icon-spec", "ui-prototype", "art-spritesheet"]; +pub(crate) const AGENT_RUNTIME_CANVAS_ASSET_KINDS: &[GameCreationAppAssetKind] = + GameCreationAppAssetKind::CANVAS_ASSET_KINDS; #[cfg(test)] mod canvas_asset_kind_contract_tests { @@ -11,7 +11,12 @@ mod canvas_asset_kind_contract_tests { fn canvas_asset_kind_catalog_preserves_authoritative_contract() { assert_eq!( AGENT_RUNTIME_CANVAS_ASSET_KINDS, - &["game-art", "icon-spec", "ui-prototype", "art-spritesheet"] + &[ + GameCreationAppAssetKind::Image, + GameCreationAppAssetKind::IconSpec, + GameCreationAppAssetKind::UiDesign, + GameCreationAppAssetKind::IconSpritesheet, + ] ); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/art_manifest_contract.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/art_manifest_contract.rs index bc3236a26..a24f84c3e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/art_manifest_contract.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/art_manifest_contract.rs @@ -16,7 +16,7 @@ pub(in crate::agent) fn art_manifest_content() -> String { "status": "generated", "assets": [{ "path": ART_SPRITESHEET_PATH, - "kind": "art-spritesheet", + "kind": GameCreationAppAssetKind::IconSpritesheet.as_str(), "usage": REQUIRED_SLICE_USAGES, }], "sliceManifest": ART_SLICE_MANIFEST_PATH, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index 7e17273aa..45700983c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -8,12 +8,12 @@ pub(in crate::agent) fn autonomous_registered_derived_visuals_need_repair_at(roo ( "design-foundation", "assets/ui-prototype.png", - "ui-prototype", + GameCreationAppAssetKind::UiDesign, ), ( "art-asset-plan", "assets/art-spritesheet.png", - "art-spritesheet", + GameCreationAppAssetKind::IconSpritesheet, ), ] .into_iter() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index ed93b5560..370c90dda 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -644,9 +644,9 @@ fn standalone_platform_art_generation_ledger_identity_is_valid( }; let direct_identity_is_valid = if agent_id == "direct-codex-art" { let expected_asset_kind = match run_id { - "art-spec" => Some("icon-spec"), - "game-background" => Some("game-background"), - "art-spritesheet" => Some("art-spritesheet"), + "art-spec" => Some(GameCreationAppAssetKind::IconSpec), + "game-background" => Some(GameCreationAppAssetKind::Scene), + "art-spritesheet" => Some(GameCreationAppAssetKind::IconSpritesheet), _ => None, }; expected_asset_kind.is_some_and(|asset_kind| { @@ -655,7 +655,9 @@ fn standalone_platform_art_generation_ledger_identity_is_valid( && string_field("source") == Some("direct-codex") && string_field("actionId") == Some(format!("direct-taonier-{run_id}").as_str()) && string_field("actionFingerprint") - == Some(format!("direct-taonier-art-v1:{asset_kind}:{run_id}").as_str()) + == Some( + format!("direct-taonier-art-v1:{}:{run_id}", asset_kind.as_str()).as_str(), + ) }) } else { false @@ -1561,10 +1563,14 @@ mod orphaned_external_generation_recovery_tests { fs::create_dir_all(workflow_path.parent().expect("workflow parent")) .expect("workflow directory"); let rollback_assets = [ - ("assets/art-spec.png", "icon-spec", b"old-spec".as_slice()), + ( + "assets/art-spec.png", + GameCreationAppAssetKind::IconSpec, + b"old-spec".as_slice(), + ), ( "assets/direct-game-background.png", - "game-background", + GameCreationAppAssetKind::Scene, b"old-background".as_slice(), ), ] @@ -1702,7 +1708,7 @@ mod orphaned_external_generation_recovery_tests { write_complete_stage(serde_json::json!({ "prompt": "Direct art spec", "kind": "spec", - "assetKind": "game-background", + "assetKind": "scene", "projectId": "canvas-project", "assetFolderId": "canvas-assets", "generationInputs": { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index 41235d21e..2bbc26736 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -71,9 +71,8 @@ pub(crate) use isolated_joins::{ pub(crate) use media::observe_agent_runtime_platform_art_asset_generation_after_dispatch_for_test; #[allow(unused_imports)] pub(crate) use media::{ - AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, - AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, AGENT_RUNTIME_UI_PROTOTYPE_PATH, - AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, + AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND, AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, + AGENT_RUNTIME_UI_PROTOTYPE_PATH, AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, }; pub(crate) use policy::{ fail_closed_agent_runtime_confirmation_for_run, 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 a61e1df4d..c3890466a 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 @@ -8,14 +8,14 @@ pub(in crate::agent) struct AgentRuntimeImageInspectInput { pub(in crate::agent) question: Option, } -pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND: &str = "ui-prototype"; +pub(crate) const AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND: &str = "ui-design"; pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_PATH: &str = "assets/ui-prototype.png"; pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE: &str = "ui-prototype.v1"; pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE: &str = "ui-prototype.v2"; pub(crate) const AGENT_RUNTIME_ART_SPEC_PATH: &str = "assets/art-spec.png"; pub(crate) const AGENT_RUNTIME_ART_SPRITESHEET_PATH: &str = "assets/art-spritesheet.png"; -fn agent_runtime_canvas_asset_kind_is_supported(asset_kind: &str) -> bool { +fn agent_runtime_canvas_asset_kind_is_supported(asset_kind: GameCreationAppAssetKind) -> bool { AGENT_RUNTIME_CANVAS_ASSET_KINDS.contains(&asset_kind) } @@ -470,7 +470,7 @@ pub(in crate::agent) async fn observe_agent_runtime_image_inspect( "images": image_metadata, "responseId": response_id, "conclusionChars": conclusion_chars, - "inspectionKind": ui_prototype_inspection.then_some(AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND), + "inspectionKind": ui_prototype_inspection.then_some(AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND), "validationProfile": validation_profile, "passed": passed, "checks": checks, @@ -489,7 +489,7 @@ pub(in crate::agent) async fn observe_agent_runtime_image_inspect( "responseId": response_id, "conclusionChars": conclusion_chars, "conclusion": conclusion, - "inspectionKind": ui_prototype_inspection.then_some(AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND), + "inspectionKind": ui_prototype_inspection.then_some(AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND), "validationProfile": validation_profile, "passed": passed, "checks": checks, @@ -543,7 +543,11 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio let output_path = agent_runtime_tool_input_text(input, &["outputPath", "output_path"]); let aspect_ratio = agent_runtime_tool_input_text(input, &["aspectRatio", "aspect_ratio"]); let image_size = agent_runtime_tool_input_text(input, &["imageSize", "image_size"]); - let asset_kind = agent_runtime_tool_input_text(input, &["assetKind", "asset_kind"]); + let requested_asset_kind = agent_runtime_tool_input_text(input, &["assetKind", "asset_kind"]); + let asset_kind = GameCreationAppAssetKind::parse_with_context( + &requested_asset_kind, + "canvas.asset_generate", + ); let asset_label = agent_runtime_tool_input_text(input, &["assetLabel", "asset_label"]); let replace_existing = input .get("replaceExisting") @@ -614,7 +618,12 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio } else { requested_options.image_size }, - asset_kind: if requested_options.asset_kind.is_empty() { + // kind 已是枚举,「空串」不再可表达:只有工具入参**没有**声明 assetKind(空白)才落 + // 本通道默认值。显式给了认不出的值仍是 `Unknown`,继续在下游按「不受支持」失败关闭, + // 不做兜底猜测。 + asset_kind: if requested_options.asset_kind == GameCreationAppAssetKind::Unknown + && requested_asset_kind.trim().is_empty() + { defaults.asset_kind } else { requested_options.asset_kind @@ -703,12 +712,12 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio }; } // 切分模式没有默认值:图集必须显式声明,且声明必须与 assetKind 和网格参数自洽。 - if options.asset_kind == "art-spritesheet" { + if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet { if options.slice_mode.is_none() { return AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), status: "failed".to_string(), - summary: "assetKind=art-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供 gridX/gridY;自由排布时用 connected-components" + summary: "assetKind=icon-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供 gridX/gridY;自由排布时用 connected-components" .to_string(), detail: None, }; @@ -743,13 +752,13 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio tool: "canvas.asset_generate".to_string(), status: "failed".to_string(), summary: format!( - "sliceMode/gridX/gridY/sliceCount 仅对 assetKind=art-spritesheet 生效,当前 assetKind={}", + "sliceMode/gridX/gridY/sliceCount 仅对 assetKind=icon-spritesheet 生效,当前 assetKind={}", options.asset_kind ), detail: None, }; } - if !agent_runtime_canvas_asset_kind_is_supported(&options.asset_kind) { + if !agent_runtime_canvas_asset_kind_is_supported(options.asset_kind) { return AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), status: "failed".to_string(), @@ -943,7 +952,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio ); } }; - let committed = if options.asset_kind == "art-spritesheet" { + let committed = if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet { commit_prepared_platform_art_asset_strict_slices_at(root, prepared, &options, |_| { let output_path = options .output_path @@ -1227,9 +1236,11 @@ mod platform_art_generation_observation_tests { #[test] fn canvas_asset_kind_validation_accepts_shared_catalog() { for asset_kind in AGENT_RUNTIME_CANVAS_ASSET_KINDS { - assert!(agent_runtime_canvas_asset_kind_is_supported(asset_kind)); + assert!(agent_runtime_canvas_asset_kind_is_supported(*asset_kind)); } - assert!(!agent_runtime_canvas_asset_kind_is_supported("unsupported")); + assert!(!agent_runtime_canvas_asset_kind_is_supported( + GameCreationAppAssetKind::Unknown + )); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 2240b3341..adc7cef6e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -1036,10 +1036,10 @@ fn runtime_tool_description(tool: &str) -> &'static str { "preview.validate" => "用真实浏览器验证桌面和移动预览并保存证据。", "image.inspect" => "让视觉模型检查一至两张项目内图片。", "canvas.asset_generate" => { - "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=art-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。" + "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。" } "ui.workflow.run" => { - "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。" + "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-design 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。" } "cocos.editor.execute" => { "在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。" @@ -1306,7 +1306,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value { "canvas.asset_generate" => { let mut asset_kinds = AGENT_RUNTIME_CANVAS_ASSET_KINDS .iter() - .map(|kind| Value::String((*kind).to_string())) + .map(|kind| Value::String(kind.as_str().to_string())) .collect::>(); asset_kinds.push(Value::Null); json!({ @@ -1321,7 +1321,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value { "assetKind": { "type": ["string", "null"], "enum": asset_kinds }, "assetLabel": { "type": ["string", "null"], "maxLength": 80 }, "replaceExisting": { "type": "boolean" }, - "sliceMode": { "type": ["string", "null"], "enum": ["connected-components", "grid", null], "description": "仅 assetKind=art-spritesheet 生效且必填,没有默认值:等分网格或固定槽位用 grid,自由排布用 connected-components" }, + "sliceMode": { "type": ["string", "null"], "enum": ["connected-components", "grid", null], "description": "仅 assetKind=icon-spritesheet 生效且必填,没有默认值:等分网格或固定槽位用 grid,自由排布用 connected-components" }, "gridX": { "type": ["integer", "null"], "minimum": 1, "maximum": 32, "description": "只与 sliceMode=grid 同时提供" }, "gridY": { "type": ["integer", "null"], "minimum": 1, "maximum": 32, "description": "只与 sliceMode=grid 同时提供" }, "sliceCount": { "type": ["integer", "null"], "minimum": 1, "maximum": 256, "description": "只与 sliceMode=connected-components 同时提供,用于约束目标素材张数" } @@ -1871,7 +1871,7 @@ mod tests { let schema = runtime_tool_input_schema("canvas.asset_generate"); let mut expected = AGENT_RUNTIME_CANVAS_ASSET_KINDS .iter() - .map(|kind| Value::String((*kind).to_string())) + .map(|kind| Value::String(kind.as_str().to_string())) .collect::>(); expected.push(Value::Null); diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs index f228420f6..f7b1e897d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -30,6 +30,7 @@ use crate::agent::{ }; use crate::commands::prepare_local_project_asset_generation; use crate::project::{enforce_project_permission_policy, read_existing_manifest_for_project}; +use shared_contracts::game_creation_app::GameCreationAppAssetKind; pub(crate) const ASSET_GENERATION_TASK_SCHEMA_VERSION: &str = "agc-asset-generation-task.v1"; pub(crate) const ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH: &str = @@ -69,7 +70,7 @@ const ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR: &str = pub(crate) struct AssetGenerationTaskRecord { pub(crate) task_id: String, pub(crate) project_id: String, - pub(crate) kind: String, + pub(crate) kind: GameCreationAppAssetKind, pub(crate) asset_name: String, pub(crate) status: String, /// 阶段文案:**由后端拥有**,前端只渲染。 @@ -297,14 +298,14 @@ pub(crate) fn begin_local_project_asset_generation_task( root: &Path, project_id: &str, task_id: &str, - task_kind: &str, + task_kind: GameCreationAppAssetKind, asset_name: &str, output_path: Option<&str>, ) -> Result { let record = AssetGenerationTaskRecord { task_id: task_id.to_string(), project_id: project_id.trim().to_string(), - kind: task_kind.to_string(), + kind: task_kind, asset_name: asset_name.to_string(), status: ASSET_GENERATION_TASK_STATUS_QUEUED.to_string(), phase_detail: ASSET_GENERATION_TASK_PHASE_QUEUED.to_string(), @@ -417,7 +418,7 @@ pub(crate) async fn start_local_project_asset_generation( &request.root, &project_id, &task_id, - &asset_kind, + asset_kind, &asset_label, request.options.output_path.as_deref(), )?; @@ -459,7 +460,7 @@ mod asset_generation_task_tests { init_local_game_project_at, read_manifest_for_project, write_project_permission_policy_at, }; use shared_contracts::game_creation_app::{ - GameCreationAppAssetSource, GameCreationAppAssetSourceKind, + GameCreationAppAssetKind, GameCreationAppAssetSource, GameCreationAppAssetSourceKind, }; fn temp_project_root(label: &str) -> PathBuf { @@ -492,7 +493,7 @@ mod asset_generation_task_tests { register_local_asset_at( root, local_path, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "image/png", "generated", GameCreationAppAssetSource { @@ -524,7 +525,7 @@ mod asset_generation_task_tests { root, "project-1", task_id, - "image", + GameCreationAppAssetKind::Image, "AI 图", None, ) @@ -540,7 +541,7 @@ mod asset_generation_task_tests { root, "project-1", task_id, - "icon-spec", + GameCreationAppAssetKind::IconSpec, "图标规范", Some(output_path), ) @@ -715,7 +716,7 @@ mod asset_generation_task_tests { &root, "project-1", "task-dup", - "image", + GameCreationAppAssetKind::Image, "AI 图", None, ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 2a053191c..0710c3351 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -469,17 +469,9 @@ pub(crate) fn external_editor_api_credentials_override_is_active() -> bool { } /// 上传素材落盘的 manifest `kind`:只由**内容证据**推导(`mediaType` 优先,扩展名兜底)。 -/// -/// 不用 `uploaded` 这类**来源词**当类型:它不在 canonical 目录里,会经别名表落到 -/// `image → unclassified`,把本该归「音频」「文档」的上传素材一起说成图片。 -/// -/// 更不能把 `ui` 当"图片的默认类型":`ui → ui-design → ui-interaction` 会把任意上传图片 -/// 钉死在「UI 交互」栏,而"是不是 UI 素材"跟"扩展名是不是 .png"毫无关系;这类错值还不可恢复 -/// ——读时自愈只在落盘 `category` 是 `unclassified` 且该 `kind` 能派生出非 `unclassified` -/// 分类时才生效,`kind` 本身错时自愈只会把错值放大。 -/// -/// 判不出内容类型时返回中性的 `asset`(派生 `unclassified` → 「待归类」),**不猜具体类型**。 -fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str { +/// 判不出内容类型时返回 `Unknown`;调用者负责决定是否拒绝写入或保留待后续归类, +/// 这里不猜具体类型。 +fn uploaded_asset_kind(file_name: &str, media_type: &str) -> GameCreationAppAssetKind { let media_type = media_type.trim().to_ascii_lowercase(); let extension = Path::new(file_name) .extension() @@ -493,20 +485,20 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str { "mp3" | "wav" | "ogg" | "m4a" | "aac" | "flac" | "opus" ) { - "audio" + GameCreationAppAssetKind::Audio } else if media_type.starts_with("video/") || matches!(extension, "mp4" | "webm" | "mov") { - "video" + GameCreationAppAssetKind::Video } else if media_type.starts_with("font/") || matches!(extension, "ttf" | "otf" | "woff" | "woff2") { - "document" + GameCreationAppAssetKind::Font } else if media_type.starts_with("image/") || matches!( extension, "png" | "jpg" | "jpeg" | "webp" | "gif" | "svg" | "avif" | "bmp" ) { - "image" + GameCreationAppAssetKind::Image } else if matches!(media_type.as_str(), "text/html" | "text/css") || media_type.contains("javascript") || media_type.contains("typescript") @@ -515,7 +507,7 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str { "html" | "htm" | "css" | "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx" ) { - "code" + GameCreationAppAssetKind::Code } else if media_type.starts_with("text/") || matches!(media_type.as_str(), "application/json" | "application/xml") || matches!( @@ -533,12 +525,29 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str { | "xml" ) { - "document" + GameCreationAppAssetKind::Document } else { - "asset" + GameCreationAppAssetKind::Unknown } } +/// 登记边界的 kind 解析:空白入参按「没有信息」处理,沿用登记入口的 `Image` 默认值; +/// 非空但认不出的原值严格收口成 `Unknown` 并把原始串交给 reporter 留痕, +/// 由调用者决定是否以错误结束或继续后续归类。 +/// +/// 三条外部登记边界必须同口径:平台导入(`commands::imported_platform_asset_kind`)、 +/// 画板导入(`sync_canvas_project_assets_at`)、外部 `register_local_asset`。 +/// 只做严格等值匹配,认不出时收口为 `Unknown`。 +pub(crate) fn registration_asset_kind( + value: &str, + context: &'static str, +) -> GameCreationAppAssetKind { + if value.trim().is_empty() { + return GameCreationAppAssetKind::Image; + } + GameCreationAppAssetKind::parse_with_context(value, context) +} + pub(crate) fn upload_local_asset_at( root: &Path, file_name: &str, @@ -585,7 +594,7 @@ pub(crate) fn upload_local_asset_at( pub(crate) fn register_local_asset_at( root: &Path, local_path: &str, - kind: &str, + kind: GameCreationAppAssetKind, media_type: &str, source_kind: &str, source: GameCreationAppAssetSource, @@ -648,7 +657,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result let (_, asset_changed) = register_local_asset_entry_with_change( root, &relative, - "document", + GameCreationAppAssetKind::Document, media_type, "document", GameCreationAppAssetSource { @@ -673,7 +682,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result pub(crate) fn import_canvas_asset_at( root: &Path, local_path: &str, - kind: &str, + kind: GameCreationAppAssetKind, media_type: &str, canvas_project_id: &str, resource_id: Option, @@ -688,7 +697,6 @@ pub(crate) fn import_canvas_asset_at( if resource_id.is_none() && asset_object_id.is_none() { return Err("画板资源 ID 和资产对象 ID 至少需要一个".to_string()); } - register_local_asset_at( root, local_path, @@ -920,7 +928,12 @@ pub(crate) async fn sync_canvas_project_assets_at( &local_path, json_string_field(resource, "assetKind") .as_deref() - .unwrap_or("canvas-resource"), + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| { + GameCreationAppAssetKind::parse_with_context(value, "canvas.asset_sync") + }) + .unwrap_or(GameCreationAppAssetKind::Image), &download.media_type, "canvas", GameCreationAppAssetSource { @@ -1774,33 +1787,26 @@ pub(crate) fn normalized_zip_entry_name(name: &str) -> Result { /// 画板导出图层推断出的资源 kind。 /// -/// 返回值必须是 **canonical kind**:这个值会被 `register_local_asset_entry` 原样写入 -/// manifest,并据以派生落盘 `category`。历史上这里写过非 canonical 的 -/// `ui` / `animation` / `asset`,只能靠别名表兜底,等于同时维护两套词汇;别名表只用于 -/// 兼容存量数据,不作为新写入值的来源。 +/// 直接返回正式枚举成员;该值会被登记边界原样写入 manifest。 pub(crate) fn infer_canvas_export_asset_kind( layer: &CanvasExportLayerMetadata, file: &str, -) -> &'static str { +) -> GameCreationAppAssetKind { let layer_type = layer.visible.layer_type.as_str(); - let inferred = if file.starts_with("sequences/") - || contains_any(layer_type, &["序列", "动画", "动作"]) - { - "character-animation" + if file.starts_with("sequences/") || contains_any(layer_type, &["序列", "动画", "动作"]) { + GameCreationAppAssetKind::CharacterAnimation } else if file.starts_with("media/") || contains_any(layer_type, &["音频", "音乐", "音效"]) { - "audio" + GameCreationAppAssetKind::Audio } else if contains_any(layer_type, &["角色"]) { - "character" + GameCreationAppAssetKind::Character } else if contains_any(layer_type, &["场景", "背景"]) { - "scene" + GameCreationAppAssetKind::Scene } else if contains_any(layer_type, &["UI", "界面", "图标"]) { - "ui-design" + GameCreationAppAssetKind::UiDesign } else { - "image" - }; - // 防御性归一:分支字面量写错时由覆盖测试暴露,这里再兜一层。 - shared_contracts::game_creation_app::canonical_game_creation_app_asset_kind(inferred) + GameCreationAppAssetKind::Image + } } pub(crate) fn infer_canvas_export_media_type(file: &str) -> &'static str { @@ -1873,7 +1879,7 @@ pub(crate) fn build_canvas_project_url( pub(crate) fn register_local_asset_entry( root: &Path, local_path: &str, - kind: &str, + kind: GameCreationAppAssetKind, media_type: &str, id_prefix: &str, source: GameCreationAppAssetSource, @@ -1895,7 +1901,7 @@ pub(crate) fn register_local_asset_entry( pub(crate) fn register_local_asset_entry_with_category( root: &Path, local_path: &str, - kind: &str, + kind: GameCreationAppAssetKind, media_type: &str, id_prefix: &str, source: GameCreationAppAssetSource, @@ -1933,7 +1939,7 @@ fn normalize_asset_category_override( fn register_local_asset_entry_with_change( root: &Path, local_path: &str, - kind: &str, + kind: GameCreationAppAssetKind, media_type: &str, id_prefix: &str, source: GameCreationAppAssetSource, @@ -1942,7 +1948,6 @@ fn register_local_asset_entry_with_change( let normalized_path = normalize_relative_path(local_path)?; let absolute_path = resolve_local_project_path(root, &normalized_path)?; let manifest_path = root.join(".agent/manifest.json"); - let kind = if kind.is_empty() { "asset" } else { kind }; let media_type = if media_type.is_empty() { "application/octet-stream" } else { @@ -1966,7 +1971,7 @@ fn register_local_asset_entry_with_change( || existing.source != source || target_category.is_some_and(|category| existing.category != category); if existing.kind != kind { - existing.kind = kind.to_string(); + existing.kind = kind; existing.category = game_creation_app_asset_category_for_kind(kind); } // 调用方显式给出目标分类时它就是权威值:GUI 完成登记必须能落回入口栏目, @@ -1985,7 +1990,7 @@ fn register_local_asset_entry_with_change( ); manifest.assets.push(GameCreationAppAssetManifestEntry { id: id.clone(), - kind: kind.to_string(), + kind, media_type: media_type.to_string(), local_path: normalized_path.clone(), image_sequence_frames: None, @@ -2004,7 +2009,7 @@ fn register_local_asset_entry_with_change( "recordType": record_type, "assetId": id.clone(), "localPath": normalized_path.clone(), - "kind": kind, + "kind": kind.as_str(), "mediaType": media_type, "source": source_for_record, }), @@ -2275,7 +2280,7 @@ mod tests { let registered = register_local_asset_entry_with_category( root, "assets/hero.png", - "image", + GameCreationAppAssetKind::Image, "image/png", "platform-art", canvas_source(), @@ -2291,7 +2296,7 @@ mod tests { register_local_asset_entry_with_category( root, "assets/hero.png", - "image", + GameCreationAppAssetKind::Image, "image/png", "platform-art", canvas_source(), @@ -2307,7 +2312,7 @@ mod tests { assert!(register_local_asset_entry_with_category( root, "assets/hero.png", - "image", + GameCreationAppAssetKind::Image, "image/png", "platform-art", canvas_source(), @@ -2324,7 +2329,7 @@ mod tests { let plain = register_local_asset_entry( root, "assets/plain.png", - "image", + GameCreationAppAssetKind::Image, "image/png", "platform-art", canvas_source(), @@ -2338,7 +2343,7 @@ mod tests { register_local_asset_entry( root, "assets/hero.png", - "image", + GameCreationAppAssetKind::Image, "image/png", "platform-art", canvas_source(), @@ -2351,10 +2356,6 @@ mod tests { } /// 画板导出推断出的 kind 必须已经是 canonical 值。 - /// - /// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值 - /// (历史上曾写 `ui` / `animation` / `asset`),就只能靠别名表兜底,等于同时维护 - /// 两套词汇,且已落盘的 category 无法随别名修复自愈。这里逐分支钉死。 #[test] fn canvas_export_asset_kind_is_always_canonical() { fn layer(layer_type: &str) -> CanvasExportLayerMetadata { @@ -2373,13 +2374,21 @@ mod tests { } // 每个分支的代表输入 → 期望的 canonical kind。 - let cases: [(&str, &str, &str); 6] = [ - ("序列", "sequences/01.png", "character-animation"), - ("动画", "layer.png", "character-animation"), - ("音频", "layer.png", "audio"), - ("角色", "layer.png", "character"), - ("场景", "layer.png", "scene"), - ("UI", "layer.png", "ui-design"), + let cases: [(&str, &str, GameCreationAppAssetKind); 6] = [ + ( + "序列", + "sequences/01.png", + GameCreationAppAssetKind::CharacterAnimation, + ), + ( + "动画", + "layer.png", + GameCreationAppAssetKind::CharacterAnimation, + ), + ("音频", "layer.png", GameCreationAppAssetKind::Audio), + ("角色", "layer.png", GameCreationAppAssetKind::Character), + ("场景", "layer.png", GameCreationAppAssetKind::Scene), + ("UI", "layer.png", GameCreationAppAssetKind::UiDesign), // 无匹配时落到 image。 ]; for (layer_type, file, expected) in cases { @@ -2389,65 +2398,54 @@ mod tests { // media/ 前缀同样走音频分支。 assert_eq!( infer_canvas_export_asset_kind(&layer("图层"), "media/bgm.mp3"), - "audio" + GameCreationAppAssetKind::Audio ); - // 兜底分支是 image 而不是旧的 "asset"。 + // 无匹配时使用中性的 image。 assert_eq!( infer_canvas_export_asset_kind(&layer("图层"), "layer.png"), - "image" + GameCreationAppAssetKind::Image ); - // 所有分支的返回值都必须在 canonical 目录里,且不能是旧的非 canonical 写法。 + // 所有分支的返回值都必须是正式枚举成员。 for layer_type in ["序列", "音频", "角色", "场景", "UI", "图层", "其他"] { let kind = infer_canvas_export_asset_kind(&layer(layer_type), "layer.png"); assert!( - shared_contracts::game_creation_app::GAME_CREATION_APP_CANONICAL_ASSET_KINDS - .contains(&kind), + GameCreationAppAssetKind::ALL.contains(&kind), "非 canonical kind: {kind}(layer_type={layer_type})" ); - for legacy in ["ui", "animation", "asset"] { - assert_ne!(kind, legacy, "写回了非 canonical 的 {legacy}"); - } } } - /// 上传素材的 `kind` 只由内容证据推导:既不能写 `uploaded`(来源词当类型), - /// 更不能把 `ui` 当图片默认值(`ui → ui-design → ui-interaction` 会把任意上传图片 - /// 钉死在「UI 交互」栏,且落盘 `category` 非 `unclassified` 后读时自愈救不回来)。 - /// - /// 变异验证:把 `uploaded_asset_kind` 改回返回常量(`"uploaded"` 或 `"ui"`)必须让本用例变红。 #[test] - fn uploaded_asset_kind_uses_content_evidence_and_never_ui() { - assert_eq!(uploaded_asset_kind("hero.png", "image/png"), "image"); - assert_eq!(uploaded_asset_kind("../角色.png", "image/png"), "image"); - assert_eq!(uploaded_asset_kind("bg.webp", ""), "image"); - assert_eq!(uploaded_asset_kind("theme.mp3", "audio/mpeg"), "audio"); - assert_eq!(uploaded_asset_kind("intro.mp4", ""), "video"); - assert_eq!(uploaded_asset_kind("rules.md", "text/markdown"), "document"); - assert_eq!(uploaded_asset_kind("ui-font.ttf", "font/ttf"), "document"); - assert_eq!(uploaded_asset_kind("game.js", "text/javascript"), "code"); - // 判不出内容类型 → 中性 `asset`(派生 `unclassified` → 「待归类」),不猜具体类型。 + fn uploaded_asset_kind_uses_content_evidence() { + assert_eq!( + uploaded_asset_kind("hero.png", "image/png"), + GameCreationAppAssetKind::Image + ); + assert_eq!( + uploaded_asset_kind("theme.mp3", "audio/mpeg"), + GameCreationAppAssetKind::Audio + ); + assert_eq!( + uploaded_asset_kind("intro.mp4", ""), + GameCreationAppAssetKind::Video + ); + assert_eq!( + uploaded_asset_kind("rules.md", "text/markdown"), + GameCreationAppAssetKind::Document + ); + assert_eq!( + uploaded_asset_kind("ui-font.ttf", "font/ttf"), + GameCreationAppAssetKind::Font + ); + assert_eq!( + uploaded_asset_kind("game.js", "text/javascript"), + GameCreationAppAssetKind::Code + ); assert_eq!( uploaded_asset_kind("unknown.bin", "application/octet-stream"), - "asset" + GameCreationAppAssetKind::Unknown ); - assert_eq!(uploaded_asset_kind("no-extension", ""), "asset"); - // 反查:正文里的"来源词"和"UI 默认值"都不得出现在返回值里。 - for (file_name, media_type) in [ - ("hero.png", "image/png"), - ("theme.mp3", "audio/mpeg"), - ("unknown.bin", ""), - ] { - let kind = uploaded_asset_kind(file_name, media_type); - assert_ne!(kind, "uploaded", "{file_name} 写回了来源词"); - assert_ne!(kind, "ui", "{file_name} 写回了非 canonical 的 `ui`"); - assert!( - shared_contracts::game_creation_app::GAME_CREATION_APP_CANONICAL_ASSET_KINDS - .contains(&kind) - || kind == "asset", - "非 canonical kind: {kind}({file_name})" - ); - } } #[test] 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 79d0dc2a4..d26f479f1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -2222,7 +2222,7 @@ pub(crate) fn register_local_asset( register_local_asset_at( root, local_path.trim(), - kind.trim(), + crate::assets::registration_asset_kind(&kind, "asset.register"), media_type.trim(), source_kind.trim(), GameCreationAppAssetSource { @@ -2252,14 +2252,8 @@ pub(crate) fn create_ui_design_resource( if manifest.project_id != expected_project_id.trim() { return Err("project-identity-conflict".to_string()); } - let next_index = manifest - .assets - .iter() - .filter(|asset| asset.kind == "UI") - .count() - + 1; - let resource_name = format!("UI 设计 {next_index}"); - let relative_path = format!("ui/{resource_name}.json"); + let (resource_name, relative_path) = + crate::ui_editor::resource_bridge::next_ui_design_path(root, &manifest)?; let absolute_path = resolve_local_project_path(root, &relative_path)?; if let Some(parent) = absolute_path.parent() { ensure_game_creator_private_directory_tree(parent, "UI 资源目录")?; @@ -2289,13 +2283,16 @@ pub(crate) fn create_ui_design_resource( let asset = match register_local_asset_at( root, &relative_path, - "UI", - "application/json", + GameCreationAppAssetKind::UiDesignDoc, + crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE, "generated", GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Generated, canvas_project_id: None, - resource_id: Some(format!("ui:{next_index}")), + resource_id: Some(format!( + "ui:{}", + resource_name.trim_start_matches("UI 设计 ") + )), asset_object_id: None, task_id: None, prompt: None, @@ -2479,7 +2476,7 @@ pub(crate) fn import_canvas_asset( import_canvas_asset_at( root, local_path.trim(), - kind.trim(), + registration_asset_kind(kind.trim(), "canvas.asset_import"), media_type.trim(), canvas_project_id.trim(), trim_optional_string(resource_id), @@ -2801,7 +2798,7 @@ pub(crate) fn import_ui_editor_local_fonts( let registered = register_local_asset_entry( root, &relative_path, - "font", + GameCreationAppAssetKind::Font, validated.metadata.format.media_type(), "font", GameCreationAppAssetSource { @@ -2913,7 +2910,7 @@ mod ui_editor_font_tests { let registered = register_local_asset_entry( root, relative_path, - "font", + GameCreationAppAssetKind::Font, "font/ttf", "font", GameCreationAppAssetSource { @@ -3016,12 +3013,12 @@ mod ui_editor_font_tests { symlink(fixture_font_path(), &target).expect("create font symlink"); let entry = GameCreationAppAssetManifestEntry { id: "font-linked".to_string(), - kind: "font".to_string(), + kind: GameCreationAppAssetKind::Font, media_type: "font/ttf".to_string(), local_path: relative_path.to_string(), image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind("font"), + category: game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Font), tags: Vec::new(), source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Uploaded, @@ -3210,9 +3207,7 @@ mod agent_asset_import_tests { assert!(first.assets[4].local_path.ends_with(".html")); assert!(root.join(&first.assets[1].local_path).is_file()); // P0 回归:本地导入的图片只能落中性的 `image`(→ `unclassified` → 「待归类」)。 - // 曾经写成 `ui`,经 `ui → ui-design → ui-interaction` 把任意图片钉死在「UI 交互」栏, - // 且落盘 `category` 成了非 `unclassified` 值后读时自愈永远救不回来。 - // 把 `agent_local_project_file_type` 的图片分支改回 `"ui"` 必须让本用例变红。 + // 不能把任意图片钉死在「UI 交互」栏,落盘 category 也必须保持中性。 let manifest: serde_json::Value = serde_json::from_str( &fs::read_to_string(root.join(".agent/manifest.json")).expect("read imported manifest"), ) @@ -3230,8 +3225,8 @@ mod agent_asset_import_tests { assert!( !imported_assets .iter() - .any(|asset| asset["kind"] == "ui" || asset["category"] == "ui-interaction"), - "本地导入不得产出 `ui` / `ui-interaction`:{manifest}" + .any(|asset| asset["category"] == "ui-interaction"), + "本地导入不得产出 `ui-interaction`:{manifest}" ); let revision_after_first = read_game_creator_agent_runtime_project_revision(root) .expect("read imported revision") @@ -3451,31 +3446,43 @@ mod agent_asset_import_tests { .is_err()); } - /// 平台导入(账户素材库 / 网页项目画布)落盘的 `kind`:**有真实类型就用真实类型**, - /// 判不出才退回中性 `image`(→ 「待归类」),**永远不许回退成常量 `ui`**。 + /// 平台导入(账户素材库 / 网页项目画布)落盘的 `kind`:**只接受 canonical 值**, + /// 认不出的原值收口成 `unknown`(→ 「待归类」)并留痕原始串。 + /// 缺失/空白退回中性的 `image`。 /// - /// 变异验证:把 `imported_platform_asset_kind` 改成忽略入参、返回 `"ui"` 的实现, + /// 变异验证:把 `imported_platform_asset_kind` 改成忽略入参、返回固定值的实现, /// 本用例必须变红(`character` / `scene` / 空值 / 未知值四组断言都会失败)。 #[test] fn imported_platform_asset_kind_prefers_payload_type_over_ui_default() { - assert_eq!(imported_platform_asset_kind(Some("character")), "character"); - assert_eq!(imported_platform_asset_kind(Some("scene")), "scene"); + use GameCreationAppAssetKind as Kind; + assert_eq!( + imported_platform_asset_kind(Some("character")), + Kind::Character + ); + assert_eq!(imported_platform_asset_kind(Some("scene")), Kind::Scene); assert_eq!( imported_platform_asset_kind(Some("character-animation")), - "character-animation" + Kind::CharacterAnimation ); - assert_eq!(imported_platform_asset_kind(Some("icon-spec")), "icon-spec"); - // 平台确实说它是 UI 设计稿时,才允许落 `ui-design`(→ 「UI 交互」); - // 大写 `UI` 与旧值 `ui` 都要归一到同一口径,但**不允许由我们替它默认**。 - assert_eq!(imported_platform_asset_kind(Some("UI")), "ui-design"); - assert_eq!(imported_platform_asset_kind(Some("ui")), "ui-design"); - // 缺失 / 空白 / 未知值一律落 canonical `image` 兜底(→ 「待归类」)。 - assert_eq!(imported_platform_asset_kind(None), "image"); - assert_eq!(imported_platform_asset_kind(Some(" ")), "image"); - assert_eq!(imported_platform_asset_kind(Some("mystery")), "image"); - // `kind` 是外部输入,原型链键必须走 `image` 兜底,不能取到原型上的值。 - assert_eq!(imported_platform_asset_kind(Some("__proto__")), "image"); - assert_eq!(imported_platform_asset_kind(Some("constructor")), "image"); + assert_eq!( + imported_platform_asset_kind(Some("icon-spec")), + Kind::IconSpec + ); + // 平台确实说它是 UI 设计稿时(`ui-design`)才落 `UiDesign`(→ 「UI 交互」)。 + assert_eq!( + imported_platform_asset_kind(Some("ui-design")), + Kind::UiDesign + ); + // 缺失 / 空白退回中性的 `image`(→ 「待归类」)。 + assert_eq!(imported_platform_asset_kind(None), Kind::Image); + assert_eq!(imported_platform_asset_kind(Some(" ")), Kind::Image); + for raw in ["UI", "mystery", "future-kind"] { + assert_eq!( + imported_platform_asset_kind(Some(raw)), + Kind::Unknown, + "{raw} 不是 canonical kind,不得被归一" + ); + } } /// 反查门禁:两处平台素材导入必须继续用 `imported_platform_asset_kind` 解析 `kind`, @@ -4004,7 +4011,7 @@ pub(crate) async fn list_editor_assets_for_agent_at( #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct AgentLocalProjectFileType { category: &'static str, - asset_kind: &'static str, + asset_kind: GameCreationAppAssetKind, media_type: &'static str, max_file_size: u64, } @@ -4059,14 +4066,14 @@ fn agent_local_project_file_type( // 错值放大成显式的 `ui-interaction`。 media_type.map(|media_type| AgentLocalProjectFileType { category: "image", - asset_kind: "image", + asset_kind: GameCreationAppAssetKind::Image, media_type, max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }) } "ttf" | "otf" | "woff" | "woff2" => Some(AgentLocalProjectFileType { category: "font", - asset_kind: "font", + asset_kind: GameCreationAppAssetKind::Font, media_type: match extension.as_str() { "ttf" => "font/ttf", "otf" => "font/otf", @@ -4077,68 +4084,68 @@ fn agent_local_project_file_type( }), "mp3" => Some(AgentLocalProjectFileType { category: "audio", - asset_kind: "audio", + asset_kind: GameCreationAppAssetKind::Audio, media_type: "audio/mpeg", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "wav" => Some(AgentLocalProjectFileType { category: "audio", - asset_kind: "audio", + asset_kind: GameCreationAppAssetKind::Audio, media_type: "audio/wav", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "ogg" => Some(AgentLocalProjectFileType { category: "audio", - asset_kind: "audio", + asset_kind: GameCreationAppAssetKind::Audio, media_type: "audio/ogg", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "flac" => Some(AgentLocalProjectFileType { category: "audio", - asset_kind: "audio", + asset_kind: GameCreationAppAssetKind::Audio, media_type: "audio/flac", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "m4a" => Some(AgentLocalProjectFileType { category: "audio", - asset_kind: "audio", + asset_kind: GameCreationAppAssetKind::Audio, media_type: "audio/mp4", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "aac" => Some(AgentLocalProjectFileType { category: "audio", - asset_kind: "audio", + asset_kind: GameCreationAppAssetKind::Audio, media_type: "audio/aac", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "opus" => Some(AgentLocalProjectFileType { category: "audio", - asset_kind: "audio", + asset_kind: GameCreationAppAssetKind::Audio, media_type: "audio/opus", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "mp4" => Some(AgentLocalProjectFileType { category: "video", - asset_kind: "video", + asset_kind: GameCreationAppAssetKind::Video, media_type: "video/mp4", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "webm" => Some(AgentLocalProjectFileType { category: "video", - asset_kind: "video", + asset_kind: GameCreationAppAssetKind::Video, media_type: "video/webm", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "mov" => Some(AgentLocalProjectFileType { category: "video", - asset_kind: "video", + asset_kind: GameCreationAppAssetKind::Video, media_type: "video/quicktime", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "md" | "markdown" | "mdx" | "txt" | "json" | "yaml" | "yml" | "toml" | "csv" | "ini" | "conf" | "xml" => Some(AgentLocalProjectFileType { category: "document", - asset_kind: "document", + asset_kind: GameCreationAppAssetKind::Document, media_type: if extension == "json" { "application/json" } else if matches!(extension.as_str(), "yaml" | "yml") { @@ -4155,7 +4162,7 @@ fn agent_local_project_file_type( | "swift" | "php" | "rb" | "lua" | "sh" | "bash" | "zsh" | "sql" | "graphql" | "gql" | "vue" | "svelte" => Some(AgentLocalProjectFileType { category: "code", - asset_kind: "code", + asset_kind: GameCreationAppAssetKind::Code, media_type: if matches!(extension.as_str(), "html" | "htm") { "text/html" } else if matches!(extension.as_str(), "css" | "scss" | "less") { @@ -4180,69 +4187,69 @@ fn agent_local_project_file_type( */ "scene" | "fire" | "prefab" | "terrain" => Some(AgentLocalProjectFileType { category: "document", - asset_kind: "scene", + asset_kind: GameCreationAppAssetKind::Scene, media_type: "application/json", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "tmx" => Some(AgentLocalProjectFileType { category: "document", - asset_kind: "scene", + asset_kind: GameCreationAppAssetKind::Scene, media_type: "application/xml", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "anim" | "animation" | "animgraph" | "animgraphvari" | "animask" => { Some(AgentLocalProjectFileType { category: "document", - asset_kind: "character-animation", + asset_kind: GameCreationAppAssetKind::CharacterAnimation, media_type: "application/json", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }) } "mtl" | "material" | "pmtl" => Some(AgentLocalProjectFileType { category: "document", - asset_kind: "code", + asset_kind: GameCreationAppAssetKind::Code, media_type: "application/json", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "effect" | "chunk" => Some(AgentLocalProjectFileType { category: "document", - asset_kind: "code", + asset_kind: GameCreationAppAssetKind::Code, media_type: "text/plain", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "plist" => Some(AgentLocalProjectFileType { category: "document", - asset_kind: "document", + asset_kind: GameCreationAppAssetKind::Document, media_type: "application/xml", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "labelatlas" | "pac" => Some(AgentLocalProjectFileType { category: "document", - asset_kind: "document", + asset_kind: GameCreationAppAssetKind::Document, media_type: "application/json", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "fnt" | "atlas" => Some(AgentLocalProjectFileType { category: "document", - asset_kind: "document", + asset_kind: GameCreationAppAssetKind::Document, media_type: "text/plain", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "glb" => Some(AgentLocalProjectFileType { category: "binary", - asset_kind: "scene", + asset_kind: GameCreationAppAssetKind::Scene, media_type: "model/gltf-binary", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "gltf" => Some(AgentLocalProjectFileType { category: "binary", - asset_kind: "scene", + asset_kind: GameCreationAppAssetKind::Scene, media_type: "model/gltf+json", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "fbx" => Some(AgentLocalProjectFileType { category: "binary", - asset_kind: "scene", + asset_kind: GameCreationAppAssetKind::Scene, media_type: "application/octet-stream", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), @@ -4251,51 +4258,51 @@ fn agent_local_project_file_type( // JSON、但也存在二进制变体,因此只按「非空」校验;能不能当文本预览由 // 结构化预览读取自己判定(非 UTF-8 时降级成类型卡)。 category: "binary", - asset_kind: "scene", + asset_kind: GameCreationAppAssetKind::Scene, media_type: "application/json", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" => { Some(AgentLocalProjectFileType { category: "binary", - asset_kind: "document", + asset_kind: GameCreationAppAssetKind::Document, media_type: "application/octet-stream", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }) } "psd" | "znt" => Some(AgentLocalProjectFileType { category: "binary", - asset_kind: "image", + asset_kind: GameCreationAppAssetKind::Image, media_type: "application/octet-stream", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "tga" => Some(AgentLocalProjectFileType { category: "image", - asset_kind: "image", + asset_kind: GameCreationAppAssetKind::Image, media_type: "image/x-tga", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "tif" | "tiff" => Some(AgentLocalProjectFileType { category: "image", - asset_kind: "image", + asset_kind: GameCreationAppAssetKind::Image, media_type: "image/tiff", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "hdr" => Some(AgentLocalProjectFileType { category: "image", - asset_kind: "image", + asset_kind: GameCreationAppAssetKind::Image, media_type: "image/vnd.radiance", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "exr" => Some(AgentLocalProjectFileType { category: "image", - asset_kind: "image", + asset_kind: GameCreationAppAssetKind::Image, media_type: "image/x-exr", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), "pcm" => Some(AgentLocalProjectFileType { category: "audio", - asset_kind: "audio", + asset_kind: GameCreationAppAssetKind::Audio, media_type: "audio/pcm", max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), @@ -4407,7 +4414,7 @@ pub(crate) fn import_local_project_assets_for_agent( } inputs.push(( local_path, - file_type.asset_kind.to_string(), + file_type.asset_kind, file_type.media_type.to_string(), bytes, )); @@ -4426,7 +4433,7 @@ pub(crate) fn import_local_project_assets_for_agent( imported.push(ImportedAsset { id: existing.id.clone(), local_path: existing.local_path.clone(), - asset_kind: Some(existing.kind.clone()), + asset_kind: Some(existing.kind.to_string()), }); continue; } @@ -4465,7 +4472,7 @@ pub(crate) fn import_local_project_assets_for_agent( let registered = register_local_asset_entry( root, &local_path, - &asset_kind, + asset_kind, &media_type, "local", GameCreationAppAssetSource { @@ -4484,7 +4491,7 @@ pub(crate) fn import_local_project_assets_for_agent( imported.push(ImportedAsset { id: registered.id, local_path: registered.local_path, - asset_kind: Some(asset_kind), + asset_kind: Some(asset_kind.to_string()), }); advance_agent_runtime_project_revision_locked(root).map_err(|error| { format!("reconciliation-required: 本地资源已登记,但项目 revision 未能推进:{error}") @@ -4493,24 +4500,16 @@ pub(crate) fn import_local_project_assets_for_agent( Ok(RemoteImportResult { assets: imported }) } -/// 平台素材导入落盘的 manifest `kind`:**有真实类型就用真实类型**,判不出才退回中性的 -/// `image`(派生 `unclassified` → 「待归类」)。 -/// -/// 这里绝不允许回退成 `ui`:账户素材库记录与网页项目画布响应本来就带 `assetKind` -/// (`AgentEditorAssetRecord::asset_kind` / 响应字段 `assetKind`),把它换成常量等于丢掉 -/// 唯一一条"这东西是什么"的事实,并把角色立绘、怪物、道具、场景图一并钉进「UI 交互」栏 -/// (`ui → ui-design → ui-interaction`)。落盘 `category` 一旦是非 `unclassified` 值, -/// 读时自愈永远救不回来,所以宁可写 `image`(待归类)也不能写假 `ui`。 -fn imported_platform_asset_kind(platform_kind: Option<&str>) -> String { - match platform_kind.map(str::trim).filter(|kind| !kind.is_empty()) { - // 平台侧词汇可能与 canonical 目录不完全一致(例如大写 `UI`),统一过 canonical 归一, - // 保证写入侧只产出 canonical `kind`。 - Some(kind) => { - shared_contracts::game_creation_app::canonical_game_creation_app_asset_kind(kind) - .to_string() - } - None => "image".to_string(), - } +/// 平台素材导入落盘的 manifest `kind`:缺失时使用中性的 `image`,其它输入只走共享 +/// `GameCreationAppAssetKind` 的严格解析,无法识别则得到 `Unknown` 并保留原始值留痕。 +fn imported_platform_asset_kind(platform_kind: Option<&str>) -> GameCreationAppAssetKind { + // 平台是外部系统,但 kind 口径**只有一条**:严格解析 + 非 canonical 原值留痕 + // (回调已由壳层注册成 `app_log!`,见 `main.rs`);缺失 / 空白按「没有信息」落 + // 中性 `image`。认不出的值就是 `Unknown` → 「待归类」, + // 原值只出现在日志里。 + platform_kind + .map(|kind| crate::assets::registration_asset_kind(kind, "platform.asset_kind")) + .unwrap_or(GameCreationAppAssetKind::Image) } /// 按账户素材 `assetId` 查询权威素材、换签下载并登记到当前项目。模型只提交 @@ -4617,7 +4616,7 @@ pub(crate) async fn import_account_editor_assets_for_agent( imported.push(ImportedAsset { id: existing.id.clone(), local_path: existing.local_path.clone(), - asset_kind: Some(existing.kind.clone()), + asset_kind: Some(existing.kind.to_string()), }); continue; } @@ -4667,7 +4666,7 @@ pub(crate) async fn import_account_editor_assets_for_agent( let registered = register_local_asset_entry( root, &local_path, - &asset_kind, + asset_kind, &media_type, "canvas", GameCreationAppAssetSource { @@ -4785,7 +4784,7 @@ pub(crate) async fn import_ui_editor_remote_assets( let registered = register_local_asset_entry( root, &local_path, - &asset_kind, + asset_kind, &media_type, "canvas", GameCreationAppAssetSource { @@ -4984,7 +4983,7 @@ pub(crate) fn prepare_local_project_asset_generation( "1K", "图片尺寸", )?, - asset_kind: asset_kind.to_string(), + asset_kind, asset_label: local_project_asset_single_line( asset_name, LOCAL_PROJECT_ASSET_MAX_ASSET_NAME_CHARS, @@ -4993,9 +4992,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, - // 切分模式没有默认值:GUI 快速编辑只按自由排布生成图集,因此仅在 art-spritesheet + // 切分模式没有默认值:GUI 快速编辑只按自由排布生成图集,因此仅在 icon-spritesheet // 时显式声明连通域切分;等分网格或固定槽位需求由外部 API 显式传 grid + gridX/gridY。 - slice_mode: (asset_kind == "art-spritesheet") + slice_mode: (asset_kind == GameCreationAppAssetKind::IconSpritesheet) .then(|| "connected-components".to_string()), grid_x: None, grid_y: None, @@ -5074,17 +5073,14 @@ mod local_project_asset_generation_tests { for kind in [ "image", "character", - "spec", "icon-spec", - "ui-prototype", - "art-spritesheet", + "ui-design", + "icon-spritesheet", ] { let request = prepare(kind, "像素月光厨房主角").expect("toolbar kind is supported"); assert_eq!(request.root, PathBuf::from("/tmp/project")); assert_eq!(request.prompt, "像素月光厨房主角"); - // `spec` 只放行到权威类型;其余 kind 原样进入参数化通道。 - let expected = if kind == "spec" { "icon-spec" } else { kind }; - assert_eq!(request.options.asset_kind, expected); + assert_eq!(request.options.asset_kind.as_str(), kind); assert!(!request.options.replace_existing); assert_eq!(request.options.slice_count, None); } @@ -5100,7 +5096,7 @@ mod local_project_asset_generation_tests { let explicit = prepare_local_project_asset_generation( " /tmp/project ", - " art-spritesheet ", + "icon-spritesheet", " 像素图集 ", Some("16:9"), Some("2K"), @@ -5112,7 +5108,10 @@ mod local_project_asset_generation_tests { .expect("explicit options"); assert_eq!(explicit.root, PathBuf::from("/tmp/project")); assert_eq!(explicit.prompt, "像素图集"); - assert_eq!(explicit.options.asset_kind, "art-spritesheet"); + assert_eq!( + explicit.options.asset_kind, + GameCreationAppAssetKind::IconSpritesheet + ); assert_eq!(explicit.options.aspect_ratio, "16:9"); assert_eq!(explicit.options.image_size, "2K"); assert_eq!(explicit.options.asset_label, "主角图集"); @@ -5154,8 +5153,8 @@ mod local_project_asset_generation_tests { "生成要求不能为空" ); assert_eq!( - prepare("game-art", "要求").expect_err("unverified kind"), - "素材类型不受支持:game-art" + prepare("future-kind", "要求").expect_err("unverified kind"), + "素材类型不受支持:future-kind" ); // 目标分类只接受合法素材分类枚举:栏目侧伪值 `version` / `all` 与任意其它值都失败关闭。 for rejected in ["version", "all", "bogus", "UI"] { @@ -5213,7 +5212,7 @@ mod local_project_asset_generation_tests { ); assert_eq!( prepare( - "spec", + "icon-spec", &"x".repeat(LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS + 1) ) .expect_err("oversized prompt"), 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 811456b96..bd69ce008 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -35,8 +35,8 @@ use shared_contracts::game_creation_app::{ GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep, GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace, - GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource, - GameCreationAppAssetSourceKind, GameCreationAppCommandRunState, + GameCreationAppAgentGroup, GameCreationAppAssetKind, GameCreationAppAssetManifestEntry, + GameCreationAppAssetSource, GameCreationAppAssetSourceKind, GameCreationAppCommandRunState, GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor, GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState, GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus, @@ -69,6 +69,36 @@ macro_rules! app_log { }}; } +/// 把 `shared-contracts` 的「非 canonical 资源 kind」留痕接到壳层日志上。 +/// +/// 解析边界(含 manifest 反序列化、画板 assetKind、平台 assetKind)认不出 canonical 值时, +/// 会把**原始输入串**与调用上下文交回来;这里落到 `app_log!` → AppData 日志里, +/// 以便回查非 canonical kind 的写入边界。kind 本身只接受严格值。 +/// +/// 按 `(原始串, 上下文)` 去重:manifest 读取极频繁,每次都落一行会把其它诊断刷掉; +/// 去重后"有哪些非 canonical 值、分别从哪个边界进来"仍然完整。需要具体资产身份时看 +/// `log_non_canonical_manifest_asset_kinds`(按资产去重,带 assetId / localPath)。 +fn register_non_canonical_asset_kind_reporter() { + static REPORTED: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + shared_contracts::game_creation_app::set_non_canonical_asset_kind_reporter( + |raw_kind: &str, context: &str| { + let key = format!("{context}\u{1}{raw_kind}"); + let first_seen = REPORTED + .get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new())) + .lock() + .map(|mut reported| reported.insert(key)) + .unwrap_or(true); + if !first_seen { + return; + } + app_log!( + "GameCreationApp 资源 kind 不是 canonical 值,已按严格口径收口为 unknown:rawKind={raw_kind} context={context}" + ); + }, + ); +} + // 调试落盘模块(保存 LLM 原始输出 / 失败输入,排查截断、空返回等)放在 debug_drafts.rs。 // 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。 mod agent; @@ -2267,6 +2297,7 @@ mod async_runtime_stack_tests { #[cfg(not(test))] fn main() { install_agent_runtime_async_runtime_with_deep_stack(); + register_non_canonical_asset_kind_reporter(); let mut args = std::env::args().skip(1).collect::>(); if let Some(exit_code) = run_direct_tools_mcp_if_requested(&args) { std::process::exit(exit_code); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 4f8b0c793..5741c12c3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -1069,9 +1069,15 @@ pub(crate) fn validate_manifest_required_visual_asset( task_id: &str, ) -> Result<(), String> { let (expected_path, expected_kind) = match task_id { - "art-director" => ("assets/art-spec.png", "icon-spec"), - "design-foundation" => ("assets/ui-prototype.png", "ui-prototype"), - "art-asset-plan" => ("assets/art-spritesheet.png", "art-spritesheet"), + "art-director" => ("assets/art-spec.png", GameCreationAppAssetKind::IconSpec), + "design-foundation" => ( + "assets/ui-prototype.png", + GameCreationAppAssetKind::UiDesign, + ), + "art-asset-plan" => ( + "assets/art-spritesheet.png", + GameCreationAppAssetKind::IconSpritesheet, + ), _ => return Ok(()), }; let asset = manifest @@ -1150,7 +1156,10 @@ pub(crate) fn validate_manifest_required_visual_asset( let art_spec = manifest .assets .iter() - .find(|asset| asset.local_path == "assets/art-spec.png" && asset.kind == "icon-spec") + .find(|asset| { + asset.local_path == "assets/art-spec.png" + && asset.kind == GameCreationAppAssetKind::IconSpec + }) .ok_or_else(|| "缺少当前统一视觉规范图".to_string())?; let art_spec_project_id = art_spec .source @@ -1190,7 +1199,7 @@ pub(crate) fn validate_manifest_required_visual_asset( &art_spec.id, &format!("{:x}", Sha256::digest(&art_spec_bytes)), &art_spec.media_type, - &art_spec.kind, + art_spec.kind.as_str(), )?; external_editor_remote_reference_matches_local_source_at( root, @@ -2167,6 +2176,13 @@ pub(crate) fn read_manifest(path: &Path) -> Result Result>> = OnceLock::new(); + for (raw_kind, asset_id, local_path) in non_canonical_manifest_asset_kinds(payload) { + let logged = LOGGED_OFFENDERS.get_or_init(|| Mutex::new(std::collections::HashSet::new())); + let first_seen = logged + .lock() + .map(|mut logged| { + logged.insert(format!( + "{}\u{1}{}\u{1}{asset_id}\u{1}{local_path}", + source_path.display(), + raw_kind, + )) + }) + .unwrap_or(true); + if !first_seen { + continue; + } + app_log!( + "manifest 资源 kind 不是 canonical 值,已按严格口径收口为 unknown:rawKind={raw_kind} assetId={asset_id} localPath={local_path} source={}({label})", + source_path.display() + ); + } +} + +/// 从 manifest 原始 JSON 的 `assets[].kind` 里挑出不是 canonical 值的条目, +/// 返回 `(原值, 资产 ID, 本地路径)`。JSON 结构不是预期形状时返回空表(读取本身会另行报错)。 +fn non_canonical_manifest_asset_kinds(payload: &str) -> Vec<(String, String, String)> { + let Ok(value) = serde_json::from_str::(payload) else { + return Vec::new(); + }; + let Some(assets) = value.get("assets").and_then(serde_json::Value::as_array) else { + return Vec::new(); + }; + let mut found = Vec::new(); + for asset in assets { + let Some(raw_kind) = asset.get("kind").and_then(serde_json::Value::as_str) else { + continue; + }; + // 判据直接来自唯一词汇表(`ALL` 含字面 `unknown`),不再另开一个"纯解析"入口: + // 严格解析入口只有 `parse_with_context`,这里只需要判真值、不需要再留痕。 + if GameCreationAppAssetKind::ALL + .iter() + .any(|kind| kind.as_str() == raw_kind) + { + continue; + } + found.push(( + raw_kind.to_string(), + asset + .get("id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + asset + .get("localPath") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + )); + } + found +} + /// manifest 的 `schemaVersion` 门:只接受当前唯一已知版本,未知版本一律失败关闭。 /// /// 为什么必须在**读侧**校验:manifest 是项目的持久化业务真相,而 [`write_manifest_locked`] 会把 diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs index 0dab003fb..78ca10a5d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs @@ -19,7 +19,7 @@ fn project_with_two_assets(test_name: &str) -> (PathBuf, String) { let first = register_local_asset_entry( &root, "assets/hero.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "asset", GameCreationAppAssetSource { @@ -39,7 +39,7 @@ fn project_with_two_assets(test_name: &str) -> (PathBuf, String) { register_local_asset_entry( &root, "assets/theme.mp3", - "background-music", + GameCreationAppAssetKind::BackgroundMusic, "audio/mpeg", "asset", GameCreationAppAssetSource { @@ -561,7 +561,12 @@ fn asset_classification_audit_failure_is_reported_and_never_faked() { } /// 批量标签测试的公共脚手架:登记素材、读原始字节、数审计记录。 -fn register_batch_tag_asset(root: &Path, local_path: &str, kind: &str, media_type: &str) -> String { +fn register_batch_tag_asset( + root: &Path, + local_path: &str, + kind: GameCreationAppAssetKind, + media_type: &str, +) -> String { register_local_asset_entry( root, local_path, @@ -587,7 +592,7 @@ fn register_batch_tag_asset(root: &Path, local_path: &str, kind: &str, media_typ fn project_with_batch_tag_assets( test_name: &str, - specs: &[(&str, &str, &str)], + specs: &[(&str, GameCreationAppAssetKind, &str)], ) -> (PathBuf, Vec) { let root = classification_test_root(test_name); init_local_game_project_at(&root, "batch-tag-project", "批量标签测试") @@ -595,7 +600,7 @@ fn project_with_batch_tag_assets( let ids = specs .iter() .map(|(local_path, kind, media_type)| { - register_batch_tag_asset(&root, local_path, kind, media_type) + register_batch_tag_asset(&root, local_path, *kind, media_type) }) .collect::>(); (root, ids) @@ -647,9 +652,21 @@ fn batch_tags_append_keeps_existing_tags_and_categories() { let (root, ids) = project_with_batch_tag_assets( "batch-append", &[ - ("assets/hero.png", "character", "image/png"), - ("assets/theme.mp3", "background-music", "audio/mpeg"), - ("assets/untouched.png", "image", "image/png"), + ( + "assets/hero.png", + GameCreationAppAssetKind::Character, + "image/png", + ), + ( + "assets/theme.mp3", + GameCreationAppAssetKind::BackgroundMusic, + "audio/mpeg", + ), + ( + "assets/untouched.png", + GameCreationAppAssetKind::Image, + "image/png", + ), ], ); let project_id = batch_tag_project_id(&root); @@ -739,8 +756,16 @@ fn batch_tags_dedupe_asset_ids_and_tags() { let (root, ids) = project_with_batch_tag_assets( "batch-dedupe", &[ - ("assets/hero.png", "image", "image/png"), - ("assets/theme.mp3", "background-music", "audio/mpeg"), + ( + "assets/hero.png", + GameCreationAppAssetKind::Image, + "image/png", + ), + ( + "assets/theme.mp3", + GameCreationAppAssetKind::BackgroundMusic, + "audio/mpeg", + ), ], ); let project_id = batch_tag_project_id(&root); @@ -781,8 +806,16 @@ fn batch_tags_repeat_is_a_noop_without_write_audit_or_revision() { let (root, ids) = project_with_batch_tag_assets( "batch-noop", &[ - ("assets/hero.png", "image", "image/png"), - ("assets/theme.mp3", "background-music", "audio/mpeg"), + ( + "assets/hero.png", + GameCreationAppAssetKind::Image, + "image/png", + ), + ( + "assets/theme.mp3", + GameCreationAppAssetKind::BackgroundMusic, + "audio/mpeg", + ), ], ); let project_id = batch_tag_project_id(&root); @@ -833,8 +866,16 @@ fn batch_tags_mixed_noop_and_change_audits_only_changed_assets() { let (root, ids) = project_with_batch_tag_assets( "batch-mixed", &[ - ("assets/hero.png", "image", "image/png"), - ("assets/theme.mp3", "background-music", "audio/mpeg"), + ( + "assets/hero.png", + GameCreationAppAssetKind::Image, + "image/png", + ), + ( + "assets/theme.mp3", + GameCreationAppAssetKind::BackgroundMusic, + "audio/mpeg", + ), ], ); let project_id = batch_tag_project_id(&root); @@ -907,8 +948,16 @@ fn batch_tags_missing_target_writes_nothing() { let (root, ids) = project_with_batch_tag_assets( "batch-missing", &[ - ("assets/hero.png", "image", "image/png"), - ("assets/theme.mp3", "background-music", "audio/mpeg"), + ( + "assets/hero.png", + GameCreationAppAssetKind::Image, + "image/png", + ), + ( + "assets/theme.mp3", + GameCreationAppAssetKind::BackgroundMusic, + "audio/mpeg", + ), ], ); let project_id = batch_tag_project_id(&root); @@ -947,8 +996,16 @@ fn batch_tags_rejects_merged_tag_limit_and_single_tag_length() { let (root, ids) = project_with_batch_tag_assets( "batch-limits", &[ - ("assets/hero.png", "image", "image/png"), - ("assets/theme.mp3", "background-music", "audio/mpeg"), + ( + "assets/hero.png", + GameCreationAppAssetKind::Image, + "image/png", + ), + ( + "assets/theme.mp3", + GameCreationAppAssetKind::BackgroundMusic, + "audio/mpeg", + ), ], ); let project_id = batch_tag_project_id(&root); @@ -1035,8 +1092,14 @@ fn batch_tags_rejects_merged_tag_limit_and_single_tag_length() { /// 空批次与空标签都不接受:空白 `assetId` 整批拒绝(不静默跳过),全空标签归一后拒绝。 #[test] fn batch_tags_rejects_empty_batch_and_empty_tags() { - let (root, ids) = - project_with_batch_tag_assets("batch-empty", &[("assets/hero.png", "image", "image/png")]); + let (root, ids) = project_with_batch_tag_assets( + "batch-empty", + &[( + "assets/hero.png", + GameCreationAppAssetKind::Image, + "image/png", + )], + ); let project_id = batch_tag_project_id(&root); let revision_before = batch_tag_revision(&root); let bytes_before = batch_tag_manifest_bytes(&root); @@ -1098,7 +1161,12 @@ fn batch_tags_accepts_two_hundred_assets_and_rejects_two_hundred_one() { .expect("initialize batch bound project"); let ids = (0..=ASSET_BATCH_TAG_MAX_ASSETS) .map(|index| { - register_batch_tag_asset(&root, &format!("assets/a{index}.png"), "image", "image/png") + register_batch_tag_asset( + &root, + &format!("assets/a{index}.png"), + GameCreationAppAssetKind::Image, + "image/png", + ) }) .collect::>(); let project_id = batch_tag_project_id(&root); @@ -1139,8 +1207,14 @@ fn batch_tags_accepts_two_hundred_assets_and_rejects_two_hundred_one() { /// 项目身份与 revision CAS 沿用单素材口径:身份不符 / 版本冲突都在写之前失败。 #[test] fn batch_tags_keeps_project_identity_and_revision_cas() { - let (root, ids) = - project_with_batch_tag_assets("batch-cas", &[("assets/hero.png", "image", "image/png")]); + let (root, ids) = project_with_batch_tag_assets( + "batch-cas", + &[( + "assets/hero.png", + GameCreationAppAssetKind::Image, + "image/png", + )], + ); let project_id = batch_tag_project_id(&root); let revision_before = batch_tag_revision(&root); let bytes_before = batch_tag_manifest_bytes(&root); @@ -1201,7 +1275,11 @@ fn batch_tags_input_rejects_unknown_fields() { fn batch_tags_command_requires_asset_register_permission() { let (root, ids) = project_with_batch_tag_assets( "batch-permission", - &[("assets/hero.png", "image", "image/png")], + &[( + "assets/hero.png", + GameCreationAppAssetKind::Image, + "image/png", + )], ); let project_id = batch_tag_project_id(&root); let revision_before = batch_tag_revision(&root); @@ -1234,8 +1312,16 @@ fn batch_tags_audit_failure_reports_written_batch() { let (root, ids) = project_with_batch_tag_assets( "batch-audit-failure", &[ - ("assets/hero.png", "image", "image/png"), - ("assets/theme.mp3", "background-music", "audio/mpeg"), + ( + "assets/hero.png", + GameCreationAppAssetKind::Image, + "image/png", + ), + ( + "assets/theme.mp3", + GameCreationAppAssetKind::BackgroundMusic, + "audio/mpeg", + ), ], ); let project_id = batch_tag_project_id(&root); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs index 43f967d42..206fcaf2e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs @@ -96,7 +96,7 @@ fn manifest_task_and_asset_mutations_are_serialized_across_the_full_transaction( crate::assets::register_local_asset_entry( &second_root, "assets/concurrent.png", - "ui-prototype", + GameCreationAppAssetKind::UiDesign, "image/png", "test", GameCreationAppAssetSource { @@ -180,12 +180,12 @@ fn successful_first_playable_registration_creates_one_initial_version_with_asset let mut manifest = new_game_creation_app_manifest("project-first-playable", "首板项目"); manifest.assets.push(GameCreationAppAssetManifestEntry { id: "asset-player".to_string(), - kind: "character".to_string(), + kind: GameCreationAppAssetKind::Character, media_type: "image/png".to_string(), local_path: "assets/player.png".to_string(), image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind("character"), + category: game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Character), tags: Vec::new(), source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Generated, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs index 8d32d431a..88f18ef86 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs @@ -631,6 +631,7 @@ pub(crate) fn read_project_resource_graph_at( #[cfg(test)] mod tests { use super::*; + use shared_contracts::game_creation_app::game_creation_app_asset_category_for_raw_kind; fn resource( resource_id: &str, @@ -652,12 +653,12 @@ mod tests { ) -> GameCreationAppAssetManifestEntry { GameCreationAppAssetManifestEntry { id: id.to_string(), - kind: "test".to_string(), + kind: GameCreationAppAssetKind::Unknown, media_type: "image/png".to_string(), local_path: format!("assets/{id}.png"), image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind("test"), + category: game_creation_app_asset_category_for_raw_kind("test", "test.resource-kind"), tags: Vec::new(), source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Canvas, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 145330f56..7cee0b18a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -183,6 +183,8 @@ pub(crate) struct DeriveLocalProjectResourceInput { pub(crate) source_path: Option, #[serde(default)] pub(crate) source_media_type: Option, + /// 源资源 subtype:只接受 manifest 的正式 `GameCreationAppAssetKind` 成员。 + /// 任务产物、附件、项目版本与 Agent 回执等非 manifest 来源不通过该字段传递。 #[serde(default)] pub(crate) source_subtype: Option, #[serde(default)] @@ -307,6 +309,7 @@ pub(crate) struct NormalizeLocalProjectRasterResourceInput { pub(crate) source_resource_id: String, pub(crate) source_path: String, pub(crate) source_media_type: String, + /// 源资源 subtype:只接受 manifest 的正式 `GameCreationAppAssetKind` 成员。 #[serde(default)] pub(crate) source_subtype: Option, pub(crate) producer_task_id: String, @@ -320,12 +323,48 @@ pub(crate) struct NormalizeLocalProjectRasterResourceResult { pub(crate) manifest: GameCreationAppManifest, } +/// 资源编辑源身份的词汇表。 +/// +/// `Manifest` 只承载 manifest 的正式 `GameCreationAppAssetKind` 成员;项目版本快照与 +/// Agent 回执文本没有 manifest kind,单独成变体,避免它们混进 kind 字段后被静默收口成 +/// `unknown`。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ResourceEditSourceKind { + Manifest(GameCreationAppAssetKind), + ProjectVersion, + AgentResultText, +} + +impl ResourceEditSourceKind { + /// 私有账本与外部接口上下文使用的稳定标签。 + fn as_str(self) -> &'static str { + match self { + Self::Manifest(kind) => kind.as_str(), + Self::ProjectVersion => "project-version", + Self::AgentResultText => "agent-result-derivative", + } + } + + /// 派生资源写回 manifest 时使用的正式 kind;非 manifest 谱系没有 kind。 + fn manifest_kind(self) -> GameCreationAppAssetKind { + match self { + Self::Manifest(kind) => kind, + // Agent 回执派生物是 markdown 文本,与 `Text => Document` 同口径:写盘只写正式成员, + // 不能主动写 `Unknown`(那会把"派生物是文档"的事实换成"认不出的资源")。 + Self::AgentResultText => GameCreationAppAssetKind::Document, + // 版本编辑不走 manifest 资产写回通道(版本有独立提交路径),此分支不可达; + // 真走到这里也只表示"没有 manifest kind"。 + Self::ProjectVersion => GameCreationAppAssetKind::Unknown, + } + } +} + #[derive(Clone, Debug)] struct ResourceEditSourceSnapshot { canonical_resource_id: String, source_path: Option, media_type: String, - asset_kind: String, + asset_kind: ResourceEditSourceKind, source_sha256: String, bytes: Option>, generation_mode: LocalProjectResourceGenerationMode, @@ -1096,15 +1135,14 @@ fn source_asset_canonical_resource_id(asset: &GameCreationAppAssetManifestEntry) format!("local-asset:{}", asset.id) } -fn resource_edit_audio_kind(asset_kind: &str, path: &str) -> LocalProjectResourceEditKind { - let haystack = format!( - "{} {}", - asset_kind.to_ascii_lowercase(), - path.to_ascii_lowercase() - ); - if ["background", "bgm", "music", "theme"] - .iter() - .any(|marker| haystack.contains(marker)) +fn resource_edit_audio_kind( + asset_kind: ResourceEditSourceKind, + path: &str, +) -> LocalProjectResourceEditKind { + if asset_kind == ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::BackgroundMusic) + || ["background", "bgm", "music", "theme"] + .iter() + .any(|marker| path.to_ascii_lowercase().contains(marker)) { LocalProjectResourceEditKind::BackgroundMusic } else { @@ -1180,20 +1218,28 @@ fn infer_resource_edit_source_media_type( Some(media_type.to_string()) } -fn infer_resource_edit_source_asset_kind(edit_kind: &LocalProjectResourceEditKind) -> String { - match edit_kind { +fn infer_resource_edit_source_asset_kind( + edit_kind: &LocalProjectResourceEditKind, +) -> ResourceEditSourceKind { + let kind = match edit_kind { LocalProjectResourceEditKind::ImageReference - | LocalProjectResourceEditKind::BackgroundRemoval => "art-image", - LocalProjectResourceEditKind::Svg => "svg", - LocalProjectResourceEditKind::CharacterAnimation => "character-animation", - LocalProjectResourceEditKind::Video => "video", - LocalProjectResourceEditKind::SoundEffect => "sound-effect", - LocalProjectResourceEditKind::BackgroundMusic => "background-music", - LocalProjectResourceEditKind::Text => "text", - LocalProjectResourceEditKind::AgentResult => "agent-result-derivative", - LocalProjectResourceEditKind::Version => "project-version", - } - .to_string() + | LocalProjectResourceEditKind::BackgroundRemoval + | LocalProjectResourceEditKind::Svg => GameCreationAppAssetKind::Image, + LocalProjectResourceEditKind::CharacterAnimation => { + GameCreationAppAssetKind::CharacterAnimation + } + LocalProjectResourceEditKind::Video => GameCreationAppAssetKind::Video, + LocalProjectResourceEditKind::SoundEffect => GameCreationAppAssetKind::SoundEffect, + LocalProjectResourceEditKind::BackgroundMusic => GameCreationAppAssetKind::BackgroundMusic, + LocalProjectResourceEditKind::Text => GameCreationAppAssetKind::Document, + LocalProjectResourceEditKind::AgentResult => { + return ResourceEditSourceKind::AgentResultText; + } + LocalProjectResourceEditKind::Version => { + return ResourceEditSourceKind::ProjectVersion; + } + }; + ResourceEditSourceKind::Manifest(kind) } fn resolve_agent_result_source( @@ -1239,7 +1285,7 @@ fn resolve_resource_edit_source( canonical_resource_id: format!("version:{version_id}"), source_path: None, media_type: "application/vnd.genarrative.project-version+json".to_string(), - asset_kind: "project-version".to_string(), + asset_kind: ResourceEditSourceKind::ProjectVersion, source_sha256: sha256_hex( &serde_json::to_vec(&version) .map_err(|error| format!("序列化源项目版本失败:{error}"))?, @@ -1264,7 +1310,7 @@ fn resolve_resource_edit_source( canonical_resource_id, source_path: None, media_type: "text/markdown".to_string(), - asset_kind: "agent-result-derivative".to_string(), + asset_kind: ResourceEditSourceKind::AgentResultText, source_sha256: sha256_hex(content.as_bytes()), bytes: None, generation_mode: input.generation_mode, @@ -1358,9 +1404,18 @@ fn resolve_resource_edit_source( .unwrap_or_default(); let asset_kind = source_asset .as_ref() - .map(|asset| asset.kind.clone()) - .or_else(|| input.source_subtype.clone()) - .unwrap_or_else(|| "asset".to_string()); + .map(|asset| ResourceEditSourceKind::Manifest(asset.kind)) + .or_else(|| { + input.source_subtype.as_deref().map(|value| { + ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::parse_with_context( + value, + "resource-edit.source-subtype", + )) + }) + }) + .unwrap_or(ResourceEditSourceKind::Manifest( + GameCreationAppAssetKind::Unknown, + )); let canonical_resource_id = source_asset .as_ref() .map(source_asset_canonical_resource_id) @@ -1422,7 +1477,7 @@ fn resolve_resource_edit_source( if matches!( input.edit_kind, LocalProjectResourceEditKind::SoundEffect | LocalProjectResourceEditKind::BackgroundMusic - ) && resource_edit_audio_kind(&asset_kind, &path) != input.edit_kind + ) && resource_edit_audio_kind(asset_kind, &path) != input.edit_kind { return Err("音频资源的音效/BGM 编辑类型与源资源用途不一致".to_string()); } @@ -1877,7 +1932,7 @@ async fn confirm_resource_edit_source( "contentType": source.media_type, "contentLength": bytes.len(), "contentHash": source.source_sha256, - "assetKind": source.asset_kind, + "assetKind": source.asset_kind.as_str(), "accessPolicy": "private", })), ) @@ -1985,7 +2040,7 @@ async fn register_resource_edit_source_image( "width": decoded.width(), "height": decoded.height(), "sourceType": "uploaded", - "assetKind": source.asset_kind, + "assetKind": source.asset_kind.as_str(), "generationInputs": { "source": RESOURCE_EDIT_QUEUE_SOURCE, "localAssetId": local_asset_id, @@ -2101,7 +2156,7 @@ async fn ensure_resource_edit_source_reference( local_asset_id, &source.source_sha256, &source.media_type, - &source.asset_kind, + source.asset_kind.manifest_kind().as_str(), )?; let principal_key = external_editor_project_binding_key_sha256(&input.expected_project_id, principal)?; @@ -2327,7 +2382,7 @@ fn build_resource_edit_audio_prompt( .and_then(|asset| asset.source.prompt.as_deref()) .map(str::trim) .filter(|value| !value.is_empty()); - let mut source_context = format!("名称 {source_name},用途 {}", source.asset_kind); + let mut source_context = format!("名称 {source_name},用途 {}", source.asset_kind.as_str()); if let Some(original_prompt) = original_prompt { source_context.push_str(",原始描述 "); source_context.push_str(original_prompt); @@ -2386,7 +2441,7 @@ fn resource_edit_remote_request( .ok_or_else(|| "抠图缺少正式源资源 ID".to_string())?, "sourceResourceId": source_reference .ok_or_else(|| "抠图缺少正式源资源 ID".to_string())?, - "assetKind": source.asset_kind, + "assetKind": source.asset_kind.as_str(), "assetLabel": asset_name, "backgroundMode": background_mode, "generationInputs": generation_inputs, @@ -3925,20 +3980,41 @@ pub(crate) fn normalize_local_project_raster_resource_at( .map_err(|error| format!("序列化源图片身份失败:{error}"))?; let identity_hash = sha256_hex(&identity_material); let asset_id = format!("normalized-{}", &identity_hash[..24]); - let source_subtype = input + // 该接口只把栅格源登记成 manifest 图片资产:源 subtype 只接受**图片族**成员。 + // 未声明/空白时按没有类型信息处理为 `image`;认不出或传入非图片族成员 + // (音频 / 视频 / 文档 / 字体 / 代码…)直接失败,避免静默掩盖调用方错误。 + let source_kind = match input .source_subtype .as_deref() .map(str::trim) - .filter(|value| !value.is_empty() && *value != "task-artifact") - .unwrap_or("art-image"); + .filter(|value| !value.is_empty()) + { + None => GameCreationAppAssetKind::Image, + Some(value) => { + let kind = + GameCreationAppAssetKind::parse_with_context(value, "resource-edit.normalize"); + if kind == GameCreationAppAssetKind::Unknown { + return Err(format!( + "sourceSubtype 必须是图片族 kind,无法识别:{value}" + )); + } + if !kind.is_visual() { + return Err(format!( + "sourceSubtype 必须是图片族 kind,不能使用:{}", + kind.as_str() + )); + } + kind + } + }; let asset = GameCreationAppAssetManifestEntry { id: asset_id.clone(), - kind: source_subtype.to_string(), + kind: source_kind, media_type: verified_media_type, local_path: source_path, image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind(source_subtype), + category: game_creation_app_asset_category_for_kind(source_kind), tags: Vec::new(), source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Generated, @@ -4179,11 +4255,11 @@ fn commit_resource_edit_asset_internal( .transpose()?; let image_sequence_duration_ms = ledger.remote_sequence_duration_ms; let asset_kind = if input.edit_kind == LocalProjectResourceEditKind::CharacterAnimation { - "character-animation".to_string() + GameCreationAppAssetKind::CharacterAnimation } else { - source.asset_kind.clone() + source.asset_kind.manifest_kind() }; - let category = game_creation_app_asset_category_for_kind(&asset_kind); + let category = game_creation_app_asset_category_for_kind(asset_kind); let asset = GameCreationAppAssetManifestEntry { id: asset_id.clone(), kind: asset_kind, @@ -4583,7 +4659,7 @@ fn write_resource_edit_result_binding( &asset.id, &sha256_hex(&bytes), &asset.media_type, - &asset.kind, + asset.kind.as_str(), )?; let binding = new_external_editor_resource_binding( &input.expected_project_id, @@ -5292,8 +5368,14 @@ pub(crate) async fn resume_local_project_resource_edit_at( let source_subtype = ledger .source_asset_kind .clone() - .or_else(|| source_asset.as_ref().map(|asset| asset.kind.clone())) - .or_else(|| Some(infer_resource_edit_source_asset_kind(&ledger.edit_kind))); + .or_else(|| source_asset.as_ref().map(|asset| asset.kind.to_string())) + .or_else(|| { + Some( + infer_resource_edit_source_asset_kind(&ledger.edit_kind) + .as_str() + .to_string(), + ) + }); derive_local_project_resource_at(DeriveLocalProjectResourceInput { project_path: input.project_path, expected_project_id: input.expected_project_id, @@ -5400,7 +5482,7 @@ pub(crate) async fn derive_local_project_resource_at( source_asset_id: source.source_asset.as_ref().map(|asset| asset.id.clone()), source_path: source.source_path.clone(), source_media_type: Some(source.media_type.clone()), - source_asset_kind: Some(source.asset_kind.clone()), + source_asset_kind: Some(source.asset_kind.as_str().to_string()), producer_task_id: input.producer_task_id.clone().or_else(|| { source .source_asset @@ -5683,7 +5765,7 @@ mod tests { "details": { "provider": "editor-image-edit", "message": "当前素材类型不支持图片快速编辑", - "assetKind": "art-spritesheet", + "assetKind": "icon-spritesheet", "mediaType": "image", }, } @@ -5692,7 +5774,7 @@ mod tests { .as_str(), ), Some( - "当前素材类型不支持图片快速编辑|provider editor-image-edit|素材类型 art-spritesheet|媒体类型 image" + "当前素材类型不支持图片快速编辑|provider editor-image-edit|素材类型 icon-spritesheet|媒体类型 image" .to_string() ), "details.message 必须优先于通用 error.message,并附上 provider 与类型上下文" @@ -6126,7 +6208,7 @@ mod tests { source_asset_id: source.source_asset.as_ref().map(|asset| asset.id.clone()), source_path: source.source_path.clone(), source_media_type: Some(source.media_type.clone()), - source_asset_kind: Some(source.asset_kind.clone()), + source_asset_kind: Some(source.asset_kind.as_str().to_string()), producer_task_id: input.producer_task_id.clone().or_else(|| { source .source_asset @@ -6379,6 +6461,25 @@ mod tests { ); } + /// 派生资源写回 manifest 的 kind 必须是正式成员:Agent 回执派生物是 markdown 文本, + /// 与 `Text => Document` 同口径,不许主动写 `Unknown`。 + #[test] + fn derived_asset_manifest_kind_is_never_unknown_for_text_derivatives() { + assert_eq!( + ResourceEditSourceKind::AgentResultText.manifest_kind(), + GameCreationAppAssetKind::Document + ); + assert_eq!( + ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video).manifest_kind(), + GameCreationAppAssetKind::Video + ); + // 版本谱系没有 manifest kind,且不走资产写回通道(版本有独立提交路径)。 + assert_eq!( + ResourceEditSourceKind::ProjectVersion.manifest_kind(), + GameCreationAppAssetKind::Unknown + ); + } + #[test] fn local_media_upload_ticket_uses_legal_private_editor_namespace() { let directory = tempfile::tempdir().expect("create resource editor fixture"); @@ -6393,7 +6494,7 @@ mod tests { canonical_resource_id: "local-asset:video-1".to_string(), source_path: Some("assets/source-video.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: Some(vec![1, 2, 3]), generation_mode: LocalProjectResourceGenerationMode::Derive, @@ -6614,7 +6715,7 @@ mod tests { &uploaded.id, &source.source_sha256, &source.media_type, - &source.asset_kind, + source.asset_kind.as_str(), ) .expect("owner A source identity"); let (a_resource_id, a_width, a_height) = @@ -6761,7 +6862,7 @@ mod tests { first_request.source_asset_id = Some(source_asset.id.clone()); first_request.source_path = Some(source_asset.local_path.clone()); first_request.source_media_type = Some(source_asset.media_type.clone()); - first_request.source_subtype = Some(source_asset.kind.clone()); + first_request.source_subtype = Some(source_asset.kind.to_string()); let mut second_request = first_request.clone(); second_request.operation_id = Uuid::new_v4().to_string(); second_request.idempotency_key = Uuid::new_v4().to_string(); @@ -6854,7 +6955,7 @@ mod tests { &source_asset.id, &source.source_sha256, &source.media_type, - &source.asset_kind, + source.asset_kind.as_str(), ) .expect("concurrent source identity"); let binding_key = external_editor_resource_binding_key_sha256( @@ -6908,7 +7009,7 @@ mod tests { request.source_asset_id = Some(source_asset.id.clone()); request.source_path = Some(source_asset.local_path.clone()); request.source_media_type = Some(source_asset.media_type.clone()); - request.source_subtype = Some(source_asset.kind.clone()); + request.source_subtype = Some(source_asset.kind.to_string()); let source = resolve_resource_edit_source(root, &manifest, &request) .expect("resolve character source"); let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); @@ -6942,7 +7043,7 @@ mod tests { &source_asset.id, &source.source_sha256, &source.media_type, - &source.asset_kind, + source.asset_kind.as_str(), ) .expect("character source identity"); write_external_editor_resource_binding_at( @@ -7032,7 +7133,7 @@ mod tests { request.source_asset_id = Some(source_asset.id.clone()); request.source_path = Some(source_asset.local_path.clone()); request.source_media_type = Some(source_asset.media_type.clone()); - request.source_subtype = Some(source_asset.kind.clone()); + request.source_subtype = Some(source_asset.kind.to_string()); let source = resolve_resource_edit_source(root, &manifest, &request).expect("resolve switch source"); let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); @@ -7203,7 +7304,7 @@ mod tests { canonical_resource_id: request.source_resource_id.clone(), source_path: Some("assets/accepted-switch-video.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), generation_mode: request.generation_mode, @@ -7261,7 +7362,7 @@ mod tests { canonical_resource_id: request.source_resource_id.clone(), source_path: Some("assets/video-one.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), generation_mode: LocalProjectResourceGenerationMode::Derive, @@ -7401,7 +7502,7 @@ mod tests { canonical_resource_id: request.source_resource_id.clone(), source_path: Some("assets/legacy-video.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), generation_mode: LocalProjectResourceGenerationMode::Derive, @@ -7574,7 +7675,7 @@ mod tests { canonical_resource_id: request.source_resource_id.clone(), source_path: Some("assets/identity-switch.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: None, generation_mode: request.generation_mode, @@ -7642,7 +7743,7 @@ mod tests { canonical_resource_id: request.source_resource_id.clone(), source_path: Some("assets/resource-identity-owner.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: None, generation_mode: request.generation_mode, @@ -7749,7 +7850,7 @@ mod tests { canonical_resource_id: request.source_resource_id.clone(), source_path: Some("assets/resource-owner-lease.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: None, generation_mode: request.generation_mode, @@ -7885,7 +7986,7 @@ mod tests { request.source_asset_id = Some(source_asset.id.clone()); request.source_path = Some(source_asset.local_path.clone()); request.source_media_type = Some(source_asset.media_type.clone()); - request.source_subtype = Some(source_asset.kind.clone()); + request.source_subtype = Some(source_asset.kind.to_string()); let source = resolve_resource_edit_source(root, &manifest, &request) .expect("resolve unowned source"); let prompt = normalize_resource_edit_prompt(&request.edit_kind, &request.prompt) @@ -7947,7 +8048,7 @@ mod tests { canonical_resource_id: "local-asset:video-submission-status".to_string(), source_path: Some("assets/video-submission-status.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), generation_mode: LocalProjectResourceGenerationMode::Derive, @@ -8074,7 +8175,7 @@ mod tests { canonical_resource_id: "local-asset:video-accepted-response".to_string(), source_path: Some("assets/video-accepted-response.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), generation_mode: LocalProjectResourceGenerationMode::Derive, @@ -8240,7 +8341,7 @@ mod tests { canonical_resource_id: request.source_resource_id.clone(), source_path: Some("assets/video-terminal-failure.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), generation_mode: LocalProjectResourceGenerationMode::Derive, @@ -8392,7 +8493,7 @@ mod tests { canonical_resource_id: request.source_resource_id.clone(), source_path: Some("assets/archive-owner-video.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), generation_mode: request.generation_mode, @@ -8452,7 +8553,7 @@ mod tests { canonical_resource_id, source_path: Some("assets/pending-owner.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: None, generation_mode: LocalProjectResourceGenerationMode::Derive, @@ -8508,7 +8609,7 @@ mod tests { canonical_resource_id: local_request.source_resource_id.clone(), source_path: Some("assets/pending-local.md".to_string()), media_type: "text/markdown".to_string(), - asset_kind: "text".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Document), source_sha256: "b".repeat(64), bytes: Some(b"local".to_vec()), generation_mode: local_request.generation_mode, @@ -8599,7 +8700,7 @@ mod tests { canonical_resource_id: "local-asset:audio-1".to_string(), source_path: Some("audio/theme.ogg".to_string()), media_type: "audio/ogg".to_string(), - asset_kind: "background-music".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::BackgroundMusic), source_sha256: "a".repeat(64), bytes: Some(vec![1]), generation_mode: LocalProjectResourceGenerationMode::Derive, @@ -8691,7 +8792,7 @@ mod tests { &background, &ResourceEditSourceSnapshot { media_type: "image/png".to_string(), - asset_kind: "art-image".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Image), source_path: Some("assets/source.png".to_string()), bytes: Some(resource_editor_test_png()), ..source.clone() @@ -8728,7 +8829,7 @@ mod tests { canonical_resource_id: "local-asset:media-1".to_string(), source_path: Some("assets/source.mp4".to_string()), media_type: "video/mp4".to_string(), - asset_kind: "video".to_string(), + asset_kind: ResourceEditSourceKind::Manifest(GameCreationAppAssetKind::Video), source_sha256: "a".repeat(64), bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), generation_mode: LocalProjectResourceGenerationMode::Derive, @@ -9186,7 +9287,7 @@ mod tests { source_resource_id: format!("task:{task_id}:assets/task-hero.png"), source_path: "assets/task-hero.png".to_string(), source_media_type: "image/png".to_string(), - source_subtype: Some("task-artifact".to_string()), + source_subtype: None, producer_task_id: task_id.clone(), }; @@ -9218,6 +9319,18 @@ mod tests { assert_eq!(replay.asset.id, first.asset.id); assert_eq!(replay.manifest.assets.len(), 1); + for invalid_subtype in ["background-music", "legacy-raster-kind"] { + let error = normalize_local_project_raster_resource_at( + NormalizeLocalProjectRasterResourceInput { + expected_project_revision: replay.committed_project_revision, + source_subtype: Some(invalid_subtype.to_string()), + ..request.clone() + }, + ) + .expect_err("non-image or unknown source subtype must fail"); + assert!(error.contains("sourceSubtype 必须是图片族 kind"), "{error}"); + } + let rejected = normalize_local_project_raster_resource_at(NormalizeLocalProjectRasterResourceInput { expected_project_revision: replay.committed_project_revision, @@ -9255,7 +9368,7 @@ mod tests { source_resource_id: format!("task:{task_id}:assets/task-hero.png"), source_path: "assets/task-hero.png".to_string(), source_media_type: "image/png".to_string(), - source_subtype: Some("task-artifact".to_string()), + source_subtype: None, producer_task_id: task_id, }; let first = normalize_local_project_raster_resource_at(request.clone()) @@ -9318,7 +9431,7 @@ mod tests { source_resource_id: format!("task:{task_id}:assets/task-hero.png"), source_path: "assets/task-hero.png".to_string(), source_media_type: "image/png".to_string(), - source_subtype: Some("task-artifact".to_string()), + source_subtype: None, producer_task_id: task_id, }; let first = normalize_local_project_raster_resource_at(request.clone()) @@ -9451,7 +9564,7 @@ mod tests { request.source_asset_id = Some(source_asset.id.clone()); request.source_path = Some(source_asset.local_path.clone()); request.source_media_type = Some(source_asset.media_type.clone()); - request.source_subtype = Some(source_asset.kind.clone()); + request.source_subtype = Some(source_asset.kind.to_string()); let source = resolve_resource_edit_source( root, &read_existing_manifest_for_project(root).expect("reread manifest"), @@ -9512,7 +9625,7 @@ mod tests { request.source_asset_id = Some(source_asset.id.clone()); request.source_path = Some(source_asset.local_path.clone()); request.source_media_type = Some(source_asset.media_type.clone()); - request.source_subtype = Some(source_asset.kind.clone()); + request.source_subtype = Some(source_asset.kind.to_string()); let source = resolve_resource_edit_source(root, &manifest, &request).expect("resolve commit source"); let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::MediaDownloaded); @@ -9625,7 +9738,7 @@ mod tests { request.source_asset_id = Some(source_asset.id.clone()); request.source_path = Some(source_asset.local_path.clone()); request.source_media_type = Some(source_asset.media_type.clone()); - request.source_subtype = Some(source_asset.kind.clone()); + request.source_subtype = Some(source_asset.kind.to_string()); let current_source = resolve_resource_edit_source(root, &manifest, &request) .expect("resolve current local source"); assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs index aa731aeef..7fb65e509 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs @@ -197,7 +197,7 @@ fn background_removal_fixture_for_session( source_asset_id: Some(source_asset.id.clone()), source_path: Some(source_asset.local_path.clone()), source_media_type: Some(source_asset.media_type.clone()), - source_subtype: Some(source_asset.kind.clone()), + source_subtype: Some(source_asset.kind.to_string()), producer_task_id: source_asset.source.task_id.clone(), source_version_id: None, prompt: "去除背景".to_string(), @@ -230,7 +230,7 @@ fn background_removal_fixture_for_session( &source_asset.id, &source.source_sha256, &source.media_type, - &source.asset_kind, + source.asset_kind.as_str(), ) .expect("build source identity"); write_external_editor_resource_binding_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs b/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs index eab43394a..9da8b13de 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/version_resource_replacement.rs @@ -1,8 +1,7 @@ use super::*; use shared_contracts::game_creation_app::{ - canonical_game_creation_app_asset_kind, game_creation_app_asset_effective_category, - GameCreationAppAssetManifestEntry, + game_creation_app_asset_effective_category, GameCreationAppAssetManifestEntry, }; /// 版本级资源替换(**直接替换**口径)。 @@ -208,17 +207,16 @@ fn version_resource_size_spec_equal( /// 三项兼容性判据(后端是权威判据,前端只做呈现)。 /// /// - `categoryEqual`:功能分类相等,用**读时自愈**口径(PRD §5.3「分类取值优先级」收口); -/// - `subtypeEqual`:canonical `kind` 相等(别名表在 `shared-contracts`)——以上两项是硬门禁; +/// - `subtypeEqual`:`kind` 严格相等(非 canonical 原值一律收口成 `unknown`)——以上两项是硬门禁; /// - `sizeSpecEqual`:见 [`version_resource_size_spec_equal`],只作提示。 fn version_resource_compatibility( source: &GameCreationAppAssetManifestEntry, replacement: &GameCreationAppAssetManifestEntry, ) -> ProjectVersionResourceCompatibility { ProjectVersionResourceCompatibility { - category_equal: game_creation_app_asset_effective_category(&source.kind, source.category) - == game_creation_app_asset_effective_category(&replacement.kind, replacement.category), - subtype_equal: canonical_game_creation_app_asset_kind(&source.kind) - == canonical_game_creation_app_asset_kind(&replacement.kind), + category_equal: game_creation_app_asset_effective_category(source.kind, source.category) + == game_creation_app_asset_effective_category(replacement.kind, replacement.category), + subtype_equal: source.kind == replacement.kind, size_spec_equal: version_resource_size_spec_equal(source, replacement), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_delete.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_delete.rs index 90c1367c8..225bdc6a5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_delete.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_delete.rs @@ -35,7 +35,7 @@ fn register_asset_delete_fixture_asset(root: &Path, relative_path: &str, task_id register_local_asset_at( root, relative_path, - "character", + GameCreationAppAssetKind::Character, "image/png", "generated", generated_asset_source(task_id), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_rename.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_rename.rs index 21764f49f..83068345d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_rename.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_rename.rs @@ -9,7 +9,7 @@ fn asset_rename_project_fixture() -> (PathBuf, String) { let asset = register_local_asset_at( &root, "assets/hero.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "canvas", asset_rename_source(), @@ -192,7 +192,7 @@ fn rename_local_project_asset_rejects_extension_changes() { let extensionless = register_local_asset_at( &root, "assets/notes", - "asset", + GameCreationAppAssetKind::Document, "text/plain", "canvas", asset_rename_source(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs index 89a8d46e4..345c4f064 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs @@ -1401,7 +1401,11 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r ); init_local_game_project_at(&root, "project-canvas-repair-atomic", "月光厨房") .expect("project init"); - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spec.png", + GameCreationAppAssetKind::IconSpec, + ); bind_canvas_visual_asset_fixture_to_current_editor( &root, "assets/art-spec.png", @@ -1586,7 +1590,7 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r &serde_json::json!({ "prompt": "生成原创晶体与潮汐构装体图集", "outputPath": "assets/art-spritesheet.png", - "assetKind": "art-spritesheet", + "assetKind": "icon-spritesheet", "sliceMode": "connected-components", "replaceExisting": true }), 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 6f5317d98..e01062cdc 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 @@ -3599,28 +3599,32 @@ fn spawn_mock_external_canvas_generation_failure_server() -> String { base_url } -fn register_canvas_visual_asset_fixture(root: &Path, local_path: &str, kind: &str) { +fn register_canvas_visual_asset_fixture( + root: &Path, + local_path: &str, + kind: GameCreationAppAssetKind, +) { let absolute_path = root.join(local_path); fs::create_dir_all(absolute_path.parent().expect("visual asset parent")) .expect("create visual asset fixture directory"); - let bytes = if kind == "art-spritesheet" { + let bytes = if kind == GameCreationAppAssetKind::IconSpritesheet { transparent_test_png_bytes() } else { valid_test_png_bytes() }; fs::write(&absolute_path, bytes).expect("write visual asset fixture"); let (generation_route, generation_kind, reference_resource_ids) = match kind { - "icon-spec" => ( + GameCreationAppAssetKind::IconSpec => ( "/api/external/v1/editor/images/generations", "spec", Vec::new(), ), - "ui-prototype" => ( + GameCreationAppAssetKind::UiDesign => ( "/api/external/v1/editor/images/generations", "ui-design", vec!["resource-icon-spec".to_string()], ), - "art-spritesheet" => ( + GameCreationAppAssetKind::IconSpritesheet => ( "/api/external/v1/editor/icon-spritesheets/generations", "icon-spritesheet", vec!["resource-icon-spec".to_string()], 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 5aedeadd0..5140233a1 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 @@ -369,9 +369,21 @@ fn canonical_visual_completion_requires_persisted_route_kind_and_current_spec_re let root = unique_project_path(); init_local_game_project_at(&root, "visual-provenance", "视觉来源门禁") .expect("init visual provenance project"); - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); - register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); - register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spec.png", + GameCreationAppAssetKind::IconSpec, + ); + register_canvas_visual_asset_fixture( + &root, + "assets/ui-prototype.png", + GameCreationAppAssetKind::UiDesign, + ); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spritesheet.png", + GameCreationAppAssetKind::IconSpritesheet, + ); let mut manifest = read_manifest_for_project(&root).expect("read visual manifest"); for task_id in ["art-director", "design-foundation", "art-asset-plan"] { validate_manifest_required_visual_asset(&root, &manifest, task_id) @@ -776,7 +788,11 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { &canvas_base_url, ); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spec.png", + GameCreationAppAssetKind::IconSpec, + ); bind_canvas_visual_asset_fixture_to_current_editor( &root, "assets/art-spec.png", @@ -804,7 +820,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { "outputPath": "assets/art-spritesheet.png", "aspectRatio": "1:1", "imageSize": "1K", - "assetKind": "art-spritesheet", + "assetKind": "icon-spritesheet", "assetLabel": "游戏首版核心美术素材", "replaceExisting": false, "sliceMode": "grid", @@ -930,7 +946,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { asset["source"]["referenceResourceIds"], serde_json::json!(["resource-icon-spec"]) ); - assert_eq!(asset["kind"], "art-spritesheet"); + assert_eq!(asset["kind"], "icon-spritesheet"); assert_eq!(asset["localPath"], "assets/art-spritesheet.png"); assert_eq!( fs::read(root.join(asset["localPath"].as_str().unwrap())).unwrap(), @@ -1146,7 +1162,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { &spec_config_dir, PlatformArtAssetGenerationOptions { output_path: Some("assets/art-spec.png".to_string()), - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "游戏统一视觉规范图".to_string(), ..PlatformArtAssetGenerationOptions::default() }, @@ -1157,7 +1173,11 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { assert!(spec_request.contains(r#""assetKind":"icon-spec""#)); assert!(spec_request.contains(r#""referenceImageSrcs":[]"#)); - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spec.png", + GameCreationAppAssetKind::IconSpec, + ); let ui_config_dir = unique_project_path(); let ui_request = capture_generation_request( &root, @@ -1166,7 +1186,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { output_path: Some("assets/ui-prototype.png".to_string()), aspect_ratio: "16:9".to_string(), image_size: "2K".to_string(), - asset_kind: "ui-prototype".to_string(), + asset_kind: GameCreationAppAssetKind::UiDesign, asset_label: "游戏横屏界面原型图".to_string(), replace_existing: false, slice_count: None, @@ -1184,7 +1204,11 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { assert!(ui_request.contains(r#""referenceImageSrcs":["resource-icon-spec"]"#)); // 用户参考只接受当前项目已登记图片素材 id,并换成当前账号绑定下的远端资源 ID。 - register_canvas_visual_asset_fixture(&root, "assets/user-reference.png", "image"); + register_canvas_visual_asset_fixture( + &root, + "assets/user-reference.png", + GameCreationAppAssetKind::Image, + ); let user_reference_asset_id = manifest_asset_id(&root, "assets/user-reference.png"); let icon_spec_config_dir = unique_project_path(); let icon_spec_with_reference = capture_generation_request( @@ -1192,7 +1216,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { &icon_spec_config_dir, PlatformArtAssetGenerationOptions { output_path: Some("assets/icon-spec-custom.png".to_string()), - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "带用户参考的图标规范".to_string(), reference_asset_ids: vec![ user_reference_asset_id.clone(), @@ -1217,7 +1241,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { output_path: Some("assets/ui-prototype-custom.png".to_string()), aspect_ratio: "16:9".to_string(), image_size: "2K".to_string(), - asset_kind: "ui-prototype".to_string(), + asset_kind: GameCreationAppAssetKind::UiDesign, asset_label: "带用户参考的界面原型图".to_string(), reference_asset_ids: vec![user_reference_asset_id.clone()], ..PlatformArtAssetGenerationOptions::default() @@ -1297,14 +1321,15 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po }, ) .expect("allow reference guard generation"); - let options = - |asset_kind: &str, reference_asset_ids: Vec| PlatformArtAssetGenerationOptions { + let options = |asset_kind: GameCreationAppAssetKind, reference_asset_ids: Vec| { + PlatformArtAssetGenerationOptions { output_path: Some(format!("assets/reference-guard-{asset_kind}.png")), - asset_kind: asset_kind.to_string(), + asset_kind, asset_label: "参考素材门禁".to_string(), reference_asset_ids, ..PlatformArtAssetGenerationOptions::default() - }; + } + }; async fn reject(root: &Path, options: PlatformArtAssetGenerationOptions) -> String { request_platform_art_asset_with_options_for_test(root, "参考素材门禁", &options) .await @@ -1326,7 +1351,10 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po // 只接受单规范引用的图集不接受用户参考,且必须明确拒绝而不是静默丢弃。 let error = reject( &root, - options("art-spritesheet", vec!["user-asset".to_string()]), + options( + GameCreationAppAssetKind::IconSpritesheet, + vec!["user-asset".to_string()], + ), ) .await; assert!(error.contains("透明美术图集只接受规范图引用"), "{error}"); @@ -1337,7 +1365,14 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po "..\\hero.png", "https://example.com/hero.png", ] { - let error = reject(&root, options("icon-spec", vec![rejected.to_string()])).await; + let error = reject( + &root, + options( + GameCreationAppAssetKind::IconSpec, + vec![rejected.to_string()], + ), + ) + .await; assert!(error.contains("不接受路径或远端资源 ID"), "{error}"); } @@ -1345,7 +1380,7 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po let error = reject( &root, options( - "icon-spec", + GameCreationAppAssetKind::IconSpec, vec!["editor-resource-from-older-account".to_string()], ), ) @@ -1361,7 +1396,7 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po register_local_asset_at( &root, "assets/document.json", - "image", + GameCreationAppAssetKind::Image, "application/json", "canvas", canvas_source(), @@ -1370,7 +1405,7 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po let error = reject( &root, options( - "icon-spec", + GameCreationAppAssetKind::IconSpec, vec![manifest_asset_id(&root, "assets/document.json")], ), ) @@ -1378,18 +1413,26 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po assert!(error.contains("参考素材必须是图片"), "{error}"); // 已登记但本地文件缺失的素材不能被引用。 - register_canvas_visual_asset_fixture(&root, "assets/missing-reference.png", "image"); + register_canvas_visual_asset_fixture( + &root, + "assets/missing-reference.png", + GameCreationAppAssetKind::Image, + ); let missing_asset_id = manifest_asset_id(&root, "assets/missing-reference.png"); fs::remove_file(root.join("assets/missing-reference.png")) .expect("remove missing reference fixture file"); - let error = reject(&root, options("icon-spec", vec![missing_asset_id])).await; + let error = reject( + &root, + options(GameCreationAppAssetKind::IconSpec, vec![missing_asset_id]), + ) + .await; assert!(error.contains("不存在;请重新登记后再引用"), "{error}"); // 超过总上限:无规范前置最多 5 张。 let error = reject( &root, options( - "icon-spec", + GameCreationAppAssetKind::IconSpec, (0..6).map(|index| format!("reference-{index}")).collect(), ), ) @@ -1397,7 +1440,11 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po assert!(error.contains("普通图片生成最多 5 张参考素材"), "{error}"); // SVG 与坏图同样必须在提交前失败:本次不做 SVG 转换,也不允许把「已登记」当成可解码。 - register_canvas_visual_asset_fixture(&root, "assets/plain-reference.png", "image"); + register_canvas_visual_asset_fixture( + &root, + "assets/plain-reference.png", + GameCreationAppAssetKind::Image, + ); let plain_reference_asset_id = manifest_asset_id(&root, "assets/plain-reference.png"); fs::write( root.join("assets/vector-reference.svg"), @@ -1407,7 +1454,7 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po register_local_asset_at( &root, "assets/vector-reference.svg", - "image", + GameCreationAppAssetKind::Image, "image/svg+xml", "canvas", canvas_source(), @@ -1417,7 +1464,7 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po let error = reject( &root, options( - "icon-spec", + GameCreationAppAssetKind::IconSpec, vec![plain_reference_asset_id.clone(), vector_asset_id], ), ) @@ -1433,7 +1480,7 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po register_local_asset_at( &root, "assets/mislabeled-reference.svg", - "image", + GameCreationAppAssetKind::Image, "image/png", "canvas", canvas_source(), @@ -1443,7 +1490,7 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po let error = reject( &root, options( - "icon-spec", + GameCreationAppAssetKind::IconSpec, vec![plain_reference_asset_id.clone(), mislabeled_asset_id], ), ) @@ -1455,7 +1502,7 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po register_local_asset_at( &root, "assets/broken-reference.png", - "image", + GameCreationAppAssetKind::Image, "image/png", "canvas", canvas_source(), @@ -1465,7 +1512,7 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po let error = reject( &root, options( - "icon-spec", + GameCreationAppAssetKind::IconSpec, vec![plain_reference_asset_id.clone(), broken_asset_id], ), ) @@ -1483,7 +1530,10 @@ async fn reference_selection_rejects_unsupported_inputs_before_any_generation_po let differential = request_platform_art_asset_with_options_for_test( &root, "参考素材门禁", - &options("icon-spec", vec![plain_reference_asset_id.clone()]), + &options( + GameCreationAppAssetKind::IconSpec, + vec![plain_reference_asset_id.clone()], + ), ) .await; let mut upload_ticket_attempted = false; @@ -1534,7 +1584,11 @@ async fn reference_upload_permission_gate_fails_closed_before_any_upload() { }, ) .expect("deny reference upload"); - register_canvas_visual_asset_fixture(&root, "assets/plain-reference.png", "image"); + register_canvas_visual_asset_fixture( + &root, + "assets/plain-reference.png", + GameCreationAppAssetKind::Image, + ); let plain_reference_asset_id = manifest_asset_id(&root, "assets/plain-reference.png"); let error = request_platform_art_asset_with_options_for_test( @@ -1542,7 +1596,7 @@ async fn reference_upload_permission_gate_fails_closed_before_any_upload() { "参考素材门禁", &PlatformArtAssetGenerationOptions { output_path: Some("assets/reference-permission.png".to_string()), - asset_kind: "icon-spec".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpec, asset_label: "参考素材门禁".to_string(), reference_asset_ids: vec![plain_reference_asset_id], ..PlatformArtAssetGenerationOptions::default() @@ -1665,7 +1719,11 @@ async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_m ); init_local_game_project_at(&root, "project-canvas-request-lock", "月光厨房") .expect("project init"); - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spec.png", + GameCreationAppAssetKind::IconSpec, + ); bind_canvas_visual_asset_fixture_to_current_editor( &root, "assets/art-spec.png", @@ -1704,7 +1762,7 @@ async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_m "生成首版花园素材", &PlatformArtAssetGenerationOptions { output_path: Some("assets/art-spritesheet.png".to_string()), - asset_kind: "art-spritesheet".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpritesheet, asset_label: "游戏首版核心美术素材".to_string(), slice_mode: Some("connected-components".to_string()), ..PlatformArtAssetGenerationOptions::default() @@ -2496,7 +2554,7 @@ fn upload_local_asset_writes_file_and_manifest_entry() { assert_eq!(manifest["assets"][0]["mediaType"], "image/png"); assert_eq!(manifest["assets"][0]["source"]["kind"], "uploaded"); // 落盘 `kind` 只能由内容证据推导:图片是 `image`(→ `unclassified` → 「待归类」)。 - // 不许写 `uploaded`(来源词当类型),更不许把 `ui` 当图片默认值——那会把任意上传图片 + // 不许写 `uploaded`(来源词当类型),也不能把图片默认归入 UI 交互——那会把任意上传图片 // 钉死在「UI 交互」栏,且落盘 `category` 非 `unclassified` 后读时自愈救不回来。 assert_eq!(manifest["assets"][0]["kind"], "image"); assert_eq!(manifest["assets"][0]["category"], "unclassified"); @@ -2523,10 +2581,8 @@ fn upload_local_asset_writes_file_and_manifest_entry() { .as_array() .unwrap() .iter() - .any(|asset| asset["kind"] == "uploaded" - || asset["kind"] == "ui" - || asset["category"] == "ui-interaction"), - "上传登记不得产出 `uploaded` / `ui` / `ui-interaction`:{manifest}" + .any(|asset| asset["kind"] == "uploaded" || asset["category"] == "ui-interaction"), + "上传登记不得产出 `uploaded` / `ui-interaction`:{manifest}" ); fs::remove_dir_all(root).ok(); @@ -2550,7 +2606,7 @@ fn register_local_asset_records_existing_asset_with_canvas_source() { let result = register_local_asset_at( &root, "assets/hero.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "canvas", GameCreationAppAssetSource { @@ -2593,7 +2649,7 @@ fn register_local_asset_records_existing_asset_with_canvas_source() { let updated = register_local_asset_at( &root, "assets/hero.png", - "ui", + GameCreationAppAssetKind::UiDesign, "image/png", "generated", GameCreationAppAssetSource { @@ -2615,7 +2671,7 @@ fn register_local_asset_records_existing_asset_with_canvas_source() { serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) .expect("manifest json"); assert_eq!(manifest["assets"].as_array().unwrap().len(), 1); - assert_eq!(manifest["assets"][0]["kind"], "ui"); + assert_eq!(manifest["assets"][0]["kind"], "ui-design"); // kind 变了必须重派生 category:旧分类 `character` 是非 unclassified 值,会被读侧 // 无条件信任、自愈也不会触发,资产就永远停在「角色与对象」。 assert_eq!(manifest["assets"][0]["category"], "ui-interaction"); @@ -2649,8 +2705,8 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() { let registered = register_local_asset_at( &root, "ui/UI 设计 1.json", - "UI", - "application/json", + GameCreationAppAssetKind::UiDesignDoc, + shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE, "ui-workflow", source(), ) @@ -2671,8 +2727,8 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() { register_local_asset_at( &root, "ui/UI 设计 1.json", - "UI", - "application/json", + GameCreationAppAssetKind::UiDesignDoc, + shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE, "ui-workflow", source(), ) @@ -2681,20 +2737,22 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() { let manifest: Value = serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) .expect("manifest json"); - assert_eq!(manifest["assets"][0]["kind"], "UI"); + assert_eq!( + manifest["assets"][0]["kind"], + shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND + ); assert_eq!(manifest["assets"][0]["category"], "audio"); assert_eq!(manifest["assets"][0]["tags"], serde_json::json!(["界面"])); fs::remove_dir_all(root).ok(); } -/// 写侧 → 分类的端到端口径:现役写入侧直接写出的非 canonical kind 字面量必须落进明确栏目。 +/// 写侧 → 分类的端到端口径:现役写入侧直接写出的 canonical kind 必须落进明确栏目。 /// /// 这里的字面量与写入侧逐字一致:UI 设计资产是 /// `ui_editor/resource_bridge.rs` / `workflow.rs` / `persistence.rs` 的 -/// `register_local_asset_at(root, path, "UI", "application/json", ...)`, +/// `register_local_asset_at` 使用 UI 文档 kind/media 常量, /// 字体是 `commands.rs` 字体上传的 `register_local_asset_entry(root, path, "font", ...)`。 -/// 只要别名表漏掉它们,真机资产就会永远停在「待归类」且读时自愈也救不回来。 #[test] fn register_local_asset_derives_category_from_real_write_side_kinds() { let root = unique_project_path(); @@ -2717,8 +2775,8 @@ fn register_local_asset_derives_category_from_real_write_side_kinds() { register_local_asset_at( &root, "ui/UI 设计 1.json", - "UI", - "application/json", + GameCreationAppAssetKind::UiDesignDoc, + shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE, "ui-workflow", source(), ) @@ -2726,7 +2784,7 @@ fn register_local_asset_derives_category_from_real_write_side_kinds() { register_local_asset_at( &root, "assets/ui-font.ttf", - "font", + GameCreationAppAssetKind::Font, "font/ttf", "font", source(), @@ -2751,14 +2809,49 @@ fn register_local_asset_derives_category_from_real_write_side_kinds() { assert_eq!( categories, vec![ - ("UI".to_string(), "ui-interaction".to_string()), ("font".to_string(), "document".to_string()), + ( + shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND + .to_string(), + "ui-interaction".to_string(), + ), ] ); fs::remove_dir_all(root).ok(); } +/// 已存在的 `ui/UI 设计 N.json` 不能让新建 UI 设计资源直接失败。 +/// +/// 编号按已登记的 UI 文档数推导,旧项目里 kind 已被收口为 `unknown` 的文档不再计入, +/// 只按计数取名就会撞上仍然存在的同名文件并报「路径已存在」。两个写入侧必须共用 +/// `ui_editor::resource_bridge` 的「第一个空闲编号」规则,既不改名既有文件也不覆盖。 +#[test] +fn create_ui_design_resource_skips_a_taken_ui_design_path() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "UI 设计编号避让").expect("project init"); + write_local_project_file_at(&root, "ui/UI 设计 1.json", "{}").expect("ui asset file"); + + let result = crate::commands::create_ui_design_resource( + root.to_string_lossy().to_string(), + "project-1".to_string(), + ) + .expect("create UI design resource"); + + assert_eq!(result.asset.local_path, "ui/UI 设计 2.json"); + let registered = result + .manifest + .assets + .iter() + .find(|asset| asset.id == result.asset.id) + .expect("registered UI design asset"); + assert_eq!(registered.source.resource_id.as_deref(), Some("ui:2")); + assert_eq!(registered.kind, GameCreationAppAssetKind::UiDesignDoc); + assert!(root.join("ui/UI 设计 1.json").is_file()); + + fs::remove_dir_all(root).ok(); +} + #[test] fn register_local_asset_rejects_missing_or_unsafe_path() { let root = unique_project_path(); @@ -2778,7 +2871,7 @@ fn register_local_asset_rejects_missing_or_unsafe_path() { assert!(register_local_asset_at( &root, "../outside.png", - "asset", + GameCreationAppAssetKind::Image, "image/png", "generated", source.clone() @@ -2787,7 +2880,7 @@ fn register_local_asset_rejects_missing_or_unsafe_path() { assert!(register_local_asset_at( &root, "assets/missing.png", - "asset", + GameCreationAppAssetKind::Image, "image/png", "generated", source @@ -2805,7 +2898,7 @@ fn import_canvas_asset_registers_canvas_source_metadata() { let result = import_canvas_asset_at( &root, "assets/canvas-hero.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "canvas-project-1", Some("resource-1".to_string()), @@ -2851,7 +2944,7 @@ fn import_canvas_asset_requires_traceable_canvas_ids() { let missing_project = import_canvas_asset_at( &root, "assets/canvas-hero.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "", Some("resource-1".to_string()), @@ -2866,7 +2959,7 @@ fn import_canvas_asset_requires_traceable_canvas_ids() { let missing_asset = import_canvas_asset_at( &root, "assets/canvas-hero.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "canvas-project-1", None, @@ -3136,7 +3229,8 @@ async fn generate_platform_art_asset_downloads_and_registers_external_image() { serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) .expect("manifest json"); let asset = &manifest["assets"][0]; - assert_eq!(asset["kind"], "game-art"); + // 平台请求词汇 `game-art` 在写入边界收口成正式成员 `image`。 + assert_eq!(asset["kind"], "image"); assert_eq!(asset["mediaType"], "image/png"); assert_eq!(asset["source"]["kind"], "canvas"); assert_eq!(asset["source"]["canvasProjectId"], "canvas-project-1"); @@ -3315,7 +3409,7 @@ async fn generate_local_project_asset_command_lands_the_requested_target_categor } #[tokio::test] -async fn generate_local_project_asset_command_maps_spec_onto_the_verified_icon_spec_channel() { +async fn generate_local_project_asset_command_registers_icon_spec_with_the_spec_generation_kind() { let root = unique_project_path(); let config_dir = unique_project_path(); let base_url = spawn_mock_external_canvas_generation_api_server(None); @@ -3342,7 +3436,7 @@ async fn generate_local_project_asset_command_maps_spec_onto_the_verified_icon_s let asset = generate_local_project_asset( root.to_string_lossy().into_owned(), - "spec".to_string(), + "icon-spec".to_string(), "原创收集玩法的统一视觉规范".to_string(), None, None, @@ -3363,7 +3457,7 @@ async fn generate_local_project_asset_command_maps_spec_onto_the_verified_icon_s .iter() .find(|entry| entry["id"].as_str() == Some(asset.id.as_str())) .expect("registered toolbar spec asset"); - // spec 必须收口到客户端验证过的 icon-spec,否则规范图无法作为 UI 原型与透明图集的权威参考。 + // 工具栏直接给 canonical kind;平台请求词汇 `spec` 只出现在 generationKind,不再参与登记。 assert_eq!(entry["kind"], "icon-spec"); assert_eq!(entry["source"]["generationKind"], "spec"); assert_eq!( @@ -3382,7 +3476,8 @@ async fn generate_local_project_asset_command_maps_spec_onto_the_verified_icon_s } #[tokio::test] -async fn generate_local_project_asset_command_generates_art_spritesheet_from_the_registered_spec() { +async fn generate_local_project_asset_command_generates_icon_spritesheet_from_the_registered_spec() +{ let root = unique_project_path(); let config_dir = unique_project_path(); let base_url = spawn_mock_external_canvas_generation_api_server(None); @@ -3410,7 +3505,7 @@ async fn generate_local_project_asset_command_generates_art_spritesheet_from_the // 透明图集必须有权威规范图:没有已登记的 icon-spec 时必须明确失败,不能静默降级成普通图。 let missing_reference = generate_local_project_asset( root.to_string_lossy().into_owned(), - "art-spritesheet".to_string(), + "icon-spritesheet".to_string(), "原创收集玩法素材图集".to_string(), None, None, @@ -3426,7 +3521,11 @@ async fn generate_local_project_asset_command_generates_art_spritesheet_from_the "{missing_reference}" ); - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spec.png", + GameCreationAppAssetKind::IconSpec, + ); bind_canvas_visual_asset_fixture_to_current_editor( &root, "assets/art-spec.png", @@ -3435,7 +3534,7 @@ async fn generate_local_project_asset_command_generates_art_spritesheet_from_the let asset = generate_local_project_asset( root.to_string_lossy().into_owned(), - "art-spritesheet".to_string(), + "icon-spritesheet".to_string(), "原创收集玩法素材图集".to_string(), Some("1:1".to_string()), Some("1K".to_string()), @@ -3457,7 +3556,8 @@ async fn generate_local_project_asset_command_generates_art_spritesheet_from_the .iter() .find(|entry| entry["id"].as_str() == Some(asset.id.as_str())) .expect("registered toolbar spritesheet asset"); - assert_eq!(entry["kind"], "art-spritesheet"); + // 工具栏直接给 canonical kind;平台请求词汇 `icon-spritesheet` 只出现在 generationKind。 + assert_eq!(entry["kind"], "icon-spritesheet"); assert_eq!( entry["source"]["generationRoute"], "/api/external/v1/editor/icon-spritesheets/generations" @@ -6183,7 +6283,7 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { output_path: Some("assets/ui-prototype.png".to_string()), aspect_ratio: "16:9".to_string(), image_size: "2K".to_string(), - asset_kind: "ui-prototype".to_string(), + asset_kind: GameCreationAppAssetKind::UiDesign, asset_label: "游戏横屏界面原型图".to_string(), replace_existing: false, slice_count: None, @@ -6241,7 +6341,7 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { fn art_spritesheet_generation_prompt_uses_current_game_instead_of_fixed_tower_defense() { let options = PlatformArtAssetGenerationOptions { output_path: Some("assets/art-spritesheet.png".to_string()), - asset_kind: "art-spritesheet".to_string(), + asset_kind: GameCreationAppAssetKind::IconSpritesheet, asset_label: "游戏首版核心美术素材".to_string(), ..PlatformArtAssetGenerationOptions::default() }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs index 8709bc5b0..7636655ef 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs @@ -6105,7 +6105,7 @@ fn local_project_image_preview_obeys_auto_file_read_policy() { register_local_asset_at( &root, "assets/preview.png", - "ui-prototype", + GameCreationAppAssetKind::UiDesign, "image/png", "canvas", GameCreationAppAssetSource { @@ -6219,7 +6219,7 @@ fn local_project_resource_previews_require_registered_safe_resources() { register_local_asset_at( &root, "game/design.md", - "document", + GameCreationAppAssetKind::Document, "text/markdown", "generated", source(), @@ -6228,7 +6228,7 @@ fn local_project_resource_previews_require_registered_safe_resources() { register_local_asset_at( &root, "assets/icon.svg", - "icon", + GameCreationAppAssetKind::Icon, "image/svg+xml", "generated", source(), @@ -6237,7 +6237,7 @@ fn local_project_resource_previews_require_registered_safe_resources() { register_local_asset_at( &root, "assets/bgm.mp3", - "bgm", + GameCreationAppAssetKind::BackgroundMusic, "audio/mpeg", "generated", source(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index 8fef84893..835d2ce16 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -6955,15 +6955,14 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { canvas_asset.parameters["properties"]["input"]["properties"]["sliceMode"]["enum"], serde_json::json!(["connected-components", "grid", null]) ); + let mut expected_asset_kinds = AGENT_RUNTIME_CANVAS_ASSET_KINDS + .iter() + .map(|kind| serde_json::Value::String(kind.as_str().to_string())) + .collect::>(); + expected_asset_kinds.push(serde_json::Value::Null); assert_eq!( canvas_asset.parameters["properties"]["input"]["properties"]["assetKind"]["enum"], - serde_json::json!([ - "game-art", - "icon-spec", - "ui-prototype", - "art-spritesheet", - null - ]) + serde_json::Value::Array(expected_asset_kinds) ); let preview_name = native_runtime_function_name("preview.validate").expect("preview name"); @@ -7271,7 +7270,7 @@ fn local_asset_prompt_context_summarizes_uploaded_and_canvas_assets() { import_canvas_asset_at( &root, "assets/canvas-hero.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "canvas-project-1", Some("resource-1".to_string()), @@ -7317,7 +7316,7 @@ fn local_asset_prompt_context_discovers_unregistered_media_without_formal_identi import_canvas_asset_at( &root, "assets/registered.png", - "ui", + GameCreationAppAssetKind::UiDesign, "image/png", "canvas-project-registered", Some("resource-registered".to_string()), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs index db84a285b..2ea327cae 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs @@ -752,8 +752,16 @@ async fn background_agent_runtime_can_schedule_ready_manifest_tasks() { async fn background_agent_runtime_can_schedule_ready_tasks_from_tool() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); - register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spec.png", + GameCreationAppAssetKind::IconSpec, + ); + register_canvas_visual_asset_fixture( + &root, + "assets/ui-prototype.png", + GameCreationAppAssetKind::UiDesign, + ); update_manifest_task_status_at(&root, "art-director", GameCreationAppTaskStatus::Completed) .expect("complete art spec dependency fixture"); write_project_permission_policy_at( @@ -1805,7 +1813,11 @@ async fn background_agent_runtime_image_inspect_sends_two_images_without_persist async fn design_ui_image_inspect_fails_scene_and_persists_canonical_checks() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "场景图拒绝测试").expect("project init"); - register_canvas_visual_asset_fixture(&root, AGENT_RUNTIME_UI_PROTOTYPE_PATH, "ui-prototype"); + register_canvas_visual_asset_fixture( + &root, + AGENT_RUNTIME_UI_PROTOTYPE_PATH, + GameCreationAppAssetKind::UiDesign, + ); let (sender, receiver) = mpsc::channel(); let base_url = spawn_mock_llm_server_responses_with_capture( vec![ui_prototype_assessment_fixture(false)], @@ -1908,7 +1920,7 @@ fn image_inspect_safe_receipt_keeps_legacy_v1_audit_readable() { "responseId": "resp_legacy_ui_audit", "conclusionChars": 20, "conclusion": "历史 v1 审计只用于读取。", - "inspectionKind": AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, + "inspectionKind": AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND, "validationProfile": AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, "passed": true, "checks": { @@ -2157,9 +2169,21 @@ fn seed_refresh_preserves_completed_visual_tasks_when_registered_file_is_missing ); let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "旧视觉任务升级测试").expect("project init"); - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); - register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); - register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spec.png", + GameCreationAppAssetKind::IconSpec, + ); + register_canvas_visual_asset_fixture( + &root, + "assets/ui-prototype.png", + GameCreationAppAssetKind::UiDesign, + ); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spritesheet.png", + GameCreationAppAssetKind::IconSpritesheet, + ); let mut manifest = read_manifest_for_project(&root).expect("manifest with visual assets"); for task_id in ["art-director", "design-foundation", "art-asset-plan"] { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_game_build.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_game_build.rs index 3aa224d0d..f68cc1abc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_game_build.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_game_build.rs @@ -468,9 +468,21 @@ fn autonomous_visual_gate_degrades_to_text_without_key_and_requires_images_with_ ); } - register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); - register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); - register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spec.png", + GameCreationAppAssetKind::IconSpec, + ); + register_canvas_visual_asset_fixture( + &root, + "assets/ui-prototype.png", + GameCreationAppAssetKind::UiDesign, + ); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spritesheet.png", + GameCreationAppAssetKind::IconSpritesheet, + ); for task_id in ["art-director", "design-foundation", "art-asset-plan"] { update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Completed) .expect("complete visual owner task with registered image"); @@ -3654,7 +3666,11 @@ async fn autonomous_static_art_asset_delivery_uses_runtime_owner_validation_with br#"{"assets":[{"path":"assets/art-spritesheet.png","kind":"art-spritesheet"}]}"#, ) .expect("write static art manifest after the parent completion baseline"); - register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); + register_canvas_visual_asset_fixture( + &root, + "assets/art-spritesheet.png", + GameCreationAppAssetKind::IconSpritesheet, + ); prepare_agent_runtime_project_mutation_locked( &root, "art-asset-plan", diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs index aa366c760..99478e8b1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs @@ -98,11 +98,12 @@ pub(super) use crate::{ AgentRuntimePendingToolAction, AgentRuntimePlanUpdate, AgentRuntimePlanUpdateStep, AgentRuntimeProviderActionBatchPreparation, AgentRuntimeState, AgentRuntimeTaskLink, AgentRuntimeTaskRecord, AgentRuntimeToolAction, AgentRuntimeToolObservation, - AgentRuntimeToolPlan, AgentRuntimeToolPolicyBlock, GameCreationAppTaskStatus, - LocalConversationMessage, ProjectAgentPermissionPolicy, ProjectPermissionPolicy, - SupervisorCollaborationPolicy, AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, - AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION, AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION, - AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE, AGENT_RUNTIME_AUTONOMOUS_LIVENESS_OBSERVATION_LIMIT, + AgentRuntimeToolPlan, AgentRuntimeToolPolicyBlock, GameCreationAppAssetKind, + GameCreationAppTaskStatus, LocalConversationMessage, ProjectAgentPermissionPolicy, + ProjectPermissionPolicy, SupervisorCollaborationPolicy, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION, + AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION, AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE, + AGENT_RUNTIME_AUTONOMOUS_LIVENESS_OBSERVATION_LIMIT, AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT, AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_MAX_OUTPUT_TOKENS, AGENT_RUNTIME_AUTONOMOUS_TRUNCATED_SCAFFOLD_MAX_OUTPUT_TOKENS, @@ -114,9 +115,9 @@ pub(super) use crate::{ AGENT_RUNTIME_RUN_PROFILE_STANDARD, AGENT_RUNTIME_SCHEMA_VERSION, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION, - AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, - AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, AGENT_RUNTIME_UI_PROTOTYPE_PATH, - AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, - GAME_CREATOR_CONFIG_FILE_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - GAME_CREATOR_USER_INPUT_REQUEST_TOOL, PROJECT_BLACKBOARD_MEMORY_PATH, + AGENT_RUNTIME_UI_DESIGN_INSPECTION_KIND, AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, + AGENT_RUNTIME_UI_PROTOTYPE_PATH, AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, + AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_CONFIG_FILE_NAME, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, GAME_CREATOR_USER_INPUT_REQUEST_TOOL, + PROJECT_BLACKBOARD_MEMORY_PATH, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs index b3c0221e1..2a8d3e389 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs @@ -42,7 +42,7 @@ fn replacement_generated_asset_source(task_id: &str) -> GameCreationAppAssetSour fn register_replacement_fixture_asset( root: &Path, relative_path: &str, - kind: &str, + kind: GameCreationAppAssetKind, media_type: &str, task_id: &str, ) -> String { @@ -146,14 +146,14 @@ fn replacement_rewrites_the_version_binding_without_adding_a_version() { let source_asset = register_replacement_fixture_asset( &root, "assets/hero-a.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-a", ); let untouched_asset = register_replacement_fixture_asset( &root, "assets/scene.png", - "scene", + GameCreationAppAssetKind::Scene, "image/png", "art-scene", ); @@ -162,7 +162,7 @@ fn replacement_rewrites_the_version_binding_without_adding_a_version() { let replacement_asset = register_replacement_fixture_asset( &root, "assets/hero-b.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-b", ); @@ -280,14 +280,14 @@ fn replacement_drops_source_binding_when_replacement_is_already_bound() { let source_asset = register_replacement_fixture_asset( &root, "assets/legacy.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-legacy", ); let already_bound_replacement = register_replacement_fixture_asset( &root, "assets/final.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-final", ); @@ -352,21 +352,21 @@ fn replacement_drops_source_binding_when_replacement_is_already_bound() { fn replacement_blocks_category_and_subtype_mismatch_but_only_hints_size_spec() { struct BlockingCase { name: &'static str, - source_kind: &'static str, - target_kind: &'static str, + source_kind: GameCreationAppAssetKind, + target_kind: GameCreationAppAssetKind, expected_reason: &'static str, } let blocking_cases = [ BlockingCase { name: "分类不同", - source_kind: "character", - target_kind: "scene", + source_kind: GameCreationAppAssetKind::Character, + target_kind: GameCreationAppAssetKind::Scene, expected_reason: "分类不同", }, BlockingCase { name: "类型不同", - source_kind: "character", - target_kind: "character-animation", + source_kind: GameCreationAppAssetKind::Character, + target_kind: GameCreationAppAssetKind::CharacterAnimation, expected_reason: "类型不同", }, ]; @@ -420,7 +420,7 @@ fn replacement_blocks_category_and_subtype_mismatch_but_only_hints_size_spec() { let source_asset = register_replacement_fixture_asset( &root, "assets/hero.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-hero", ); @@ -428,7 +428,7 @@ fn replacement_blocks_category_and_subtype_mismatch_but_only_hints_size_spec() { let webp_asset = register_replacement_fixture_asset( &root, "assets/hero.webp", - "character", + GameCreationAppAssetKind::Character, "image/webp", "art-hero-webp", ); @@ -486,14 +486,14 @@ fn replacement_hints_known_frame_size_and_duration_differences() { let source_asset = register_replacement_fixture_asset( &root, "assets/seq-source.png", - "character-animation", + GameCreationAppAssetKind::CharacterAnimation, "image/png", "anim-source", ); let target_asset = register_replacement_fixture_asset( &root, "assets/seq-target.png", - "character-animation", + GameCreationAppAssetKind::CharacterAnimation, "image/png", "anim-target", ); @@ -551,14 +551,14 @@ fn replacement_heals_persisted_unclassified_category_like_the_shared_contract() let source_asset = register_replacement_fixture_asset( &root, "assets/ui-a.png", - "ui-design", + GameCreationAppAssetKind::UiDesign, "image/png", "ui-a", ); let healed_asset = register_replacement_fixture_asset( &root, "assets/ui-b.png", - "ui-design", + GameCreationAppAssetKind::UiDesign, "image/png", "ui-b", ); @@ -611,7 +611,7 @@ fn replacement_rejects_unresolvable_source_and_target() { let bound_asset = register_replacement_fixture_asset( &root, "assets/bound.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-bound", ); @@ -620,7 +620,7 @@ fn replacement_rejects_unresolvable_source_and_target() { let unbound_asset = register_replacement_fixture_asset( &root, "assets/late.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-late", ); @@ -672,14 +672,14 @@ fn replacement_enforces_revision_and_identity_cas() { let source_asset = register_replacement_fixture_asset( &root, "assets/cas-source.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-cas-source", ); let target_asset = register_replacement_fixture_asset( &root, "assets/cas-target.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-cas-target", ); @@ -722,35 +722,35 @@ fn replacement_candidates_report_authoritative_compatibility() { let source_asset = register_replacement_fixture_asset( &root, "assets/pick-source.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-pick-source", ); let compatible_asset = register_replacement_fixture_asset( &root, "assets/pick-compatible.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-pick-compatible", ); let category_mismatch = register_replacement_fixture_asset( &root, "assets/pick-scene.png", - "scene", + GameCreationAppAssetKind::Scene, "image/png", "art-pick-scene", ); let subtype_mismatch = register_replacement_fixture_asset( &root, "assets/pick-animation.png", - "character-animation", + GameCreationAppAssetKind::CharacterAnimation, "image/png", "art-pick-animation", ); let format_mismatch = register_replacement_fixture_asset( &root, "assets/pick-webp.webp", - "character", + GameCreationAppAssetKind::Character, "image/webp", "art-pick-webp", ); @@ -814,7 +814,7 @@ fn replacement_candidates_report_authoritative_compatibility() { let late_asset = register_replacement_fixture_asset( &root, "assets/pick-late.png", - "character", + GameCreationAppAssetKind::Character, "image/png", "art-pick-late", ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs index 797f8c44d..9d9b9bb3b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs @@ -17,7 +17,6 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::path::Path; -use ts_rs::TS; pub const ASSET_BATCH_SIZE: usize = 5; @@ -62,16 +61,14 @@ struct BindingResponse { changes: Vec, } -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct BindingChange { pub node_id: NodeId, pub component: NodeComponent, pub component_status: StageStatus, } -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct BindingDTO { pub changes: Vec, } @@ -434,7 +431,6 @@ pub(crate) async fn bind_components_impl_with_provider( mod tests { use super::*; use crate::ui_editor::component::text::{FontSource, TextComponent}; - use ts_rs::{Config, TS}; fn id(value: &str) -> NodeId { NodeId::new(value).expect("valid id") @@ -637,13 +633,4 @@ mod tests { assert!(read_ui_reference_image_data_url(path).await.is_err()); } - - #[test] - fn exports_ui_editor_types() { - let config = Config::from_env(); - BindingDTO::export_all(&config).expect("BindingDTO TypeScript export succeeds"); - Node::export_all(&config).expect("Node TypeScript export succeeds"); - TextComponent::export_all(&config).expect("TextComponent TypeScript export succeeds"); - FontSource::export_all(&config).expect("FontSource TypeScript export succeeds"); - } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs index a566a9225..43c045d4b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs @@ -19,6 +19,10 @@ use std::time::{SystemTime, UNIX_EPOCH}; use typed_floats::tf32::StrictlyPositiveFinite; const UI_DESIGN_STATE_SCHEMA_VERSION: &str = "game-creator-ui-design-state.v1"; +pub(crate) use shared_contracts::game_creation_app::{ + GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND as UI_DESIGN_DOC_ASSET_KIND, + GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE as UI_DESIGN_DOC_MEDIA_TYPE, +}; const UI_DESIGN_STATE_MAX_BYTES: usize = 2 * 1024 * 1024; const UI_DESIGN_CODE_MAX_BYTES: usize = UI_DESIGN_STATE_MAX_BYTES * 8; const UI_DESIGN_STATE_MAX_IMAGES: usize = 4; @@ -158,7 +162,7 @@ pub(crate) fn load_ui_design_state_at( let root = Path::new(input.project_path.trim()); let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?; let asset_id = required_identifier(&input.asset_id, "assetId")?; - let asset = registered_json_asset(root, &expected_project_id, &asset_id)?; + let asset = ui_design_asset(root, &expected_project_id, &asset_id)?; let document = read_ui_design_document(root, &asset.local_path, &expected_project_id, &asset_id)?; validate_document(&document, &expected_project_id, &asset_id)?; @@ -175,7 +179,7 @@ pub(crate) fn generate_ui_design_code_at( let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?; let asset_id = required_identifier(&input.asset_id, "assetId")?; let _lock = acquire_project_write_lock(root, "ui_design.code_generate")?; - let asset = registered_json_asset(root, &expected_project_id, &asset_id)?; + let asset = ui_design_asset(root, &expected_project_id, &asset_id)?; let document = read_ui_design_document_locked(root, &asset.local_path, &expected_project_id, &asset_id)?; let (content, tree_exports, node_count) = render_ui_design_state_js(&document.state)?; @@ -225,9 +229,9 @@ pub(crate) fn save_ui_design_state_at( let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?; let asset_id = required_identifier(&input.asset_id, "assetId")?; - let preflight_asset = registered_json_asset(root, &expected_project_id, &asset_id)?; + let preflight_asset = ui_design_asset(root, &expected_project_id, &asset_id)?; let _lock = acquire_project_write_lock(root, "ui_design.state_save")?; - let asset = registered_json_asset(root, &expected_project_id, &asset_id)?; + let asset = ui_design_asset(root, &expected_project_id, &asset_id)?; if asset.local_path != preflight_asset.local_path { return Err("UI 设计资源在保存锁获取期间发生变化,请重试".to_string()); } @@ -314,7 +318,7 @@ fn ui_design_asset( ) -> Result { let asset = registered_json_asset(root, expected_project_id, asset_id)?; // 新状态初始化仍是显式 UI 创建动作,不能因放开已有设计的登记标签而覆盖普通 JSON。 - if asset.kind != "UI" || asset.media_type != "application/json" { + if asset.kind != UI_DESIGN_DOC_ASSET_KIND || asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE { return Err("目标资源不是 UI 设计 JSON 资产".to_string()); } Ok(asset) @@ -847,8 +851,8 @@ mod tests { let asset = register_local_asset_at( directory.path(), relative_path, - "UI", - "application/json", + GameCreationAppAssetKind::UiDesignDoc, + UI_DESIGN_DOC_MEDIA_TYPE, "test", GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Generated, @@ -934,8 +938,11 @@ mod tests { } #[test] - fn json_preview_and_editor_accept_valid_state_without_rewriting_asset_kind() { - for kind in ["UI", "ui", "ui-design", "document"] { + fn json_preview_recognizes_registered_json_and_editor_requires_canonical_kind() { + for (kind, editor_allowed) in [ + (GameCreationAppAssetKind::UiDesignDoc, true), + (GameCreationAppAssetKind::Document, false), + ] { let (directory, asset_id) = fixture(); crate::project::mutate_manifest_at(directory.path(), |manifest| { manifest @@ -943,7 +950,7 @@ mod tests { .iter_mut() .find(|asset| asset.id == asset_id) .unwrap() - .kind = kind.to_string(); + .kind = kind; Ok(()) }) .unwrap(); @@ -963,20 +970,15 @@ mod tests { project_path: directory.path().to_string_lossy().into_owned(), expected_project_id: PROJECT_ID.to_string(), asset_id: asset_id.clone(), - }) - .unwrap(); - assert_eq!(loaded.revision, 0); + }); + assert_eq!(loaded.is_ok(), editor_allowed); let saved = save_ui_design_state_at(input( directory.path(), &asset_id, 0, state_with_unavailable_image("assets/page.png"), - )) - .unwrap(); - assert!(matches!( - saved, - SaveUiDesignStateResult::Saved { revision: 1, .. } )); + assert_eq!(saved.is_ok(), editor_allowed); assert_eq!( read_existing_manifest_for_project(directory.path()) .unwrap() @@ -999,7 +1001,7 @@ mod tests { .iter_mut() .find(|asset| asset.id == asset_id) .unwrap() - .kind = "document".to_string(); + .kind = GameCreationAppAssetKind::Document; Ok(()) }) .unwrap(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs index 410746488..dc76ce37c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs @@ -1,4 +1,7 @@ -use crate::ui_editor::persistence::initialize_ui_design_state_with_source_image_at; +use crate::ui_editor::persistence::{ + initialize_ui_design_state_with_source_image_at, UI_DESIGN_DOC_ASSET_KIND, + UI_DESIGN_DOC_MEDIA_TYPE, +}; use crate::{ acquire_project_write_lock, advance_agent_runtime_project_revision_locked, enforce_project_permission_policy, read_existing_manifest_for_project, @@ -8,6 +11,7 @@ use crate::{ }; use image::GenericImageView; use serde::{Deserialize, Serialize}; +use shared_contracts::game_creation_app::GameCreationAppAssetKind; use std::fs; use std::path::Path; @@ -48,7 +52,7 @@ pub(crate) fn ensure_ui_design_resource_for_prototype( .iter() .find(|asset| asset.id == prototype_asset_id) .ok_or_else(|| "UI 原型资产不存在".to_string())?; - if prototype.kind != "ui-prototype" + if prototype.kind != GameCreationAppAssetKind::UiDesign || !prototype .media_type .to_ascii_lowercase() @@ -89,8 +93,8 @@ pub(crate) fn ensure_ui_design_resource_for_prototype( } if let Some(asset) = manifest.assets.iter().find(|asset| { - asset.kind == "UI" - && asset.media_type == "application/json" + asset.kind == UI_DESIGN_DOC_ASSET_KIND + && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE && asset.source.reference_resource_ids.iter().any(|reference| { source_reference_ids .iter() @@ -118,8 +122,8 @@ pub(crate) fn ensure_ui_design_resource_for_prototype( let asset = match register_local_asset_at( root, &relative_path, - "UI", - "application/json", + GameCreationAppAssetKind::UiDesignDoc, + UI_DESIGN_DOC_MEDIA_TYPE, "generated", GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Generated, @@ -186,14 +190,18 @@ pub(crate) fn ensure_ui_design_resource_for_prototype( }) } -fn next_ui_design_path( +/// UI 设计文档的确定性命名:从已登记文档数 + 1 起找第一个既未被占用、 +/// 也未登记进 manifest 的 `ui/UI 设计 N.json`,不覆盖任何既有文件。 +pub(crate) fn next_ui_design_path( root: &Path, manifest: &GameCreationAppManifest, ) -> Result<(String, String), String> { let mut index = manifest .assets .iter() - .filter(|asset| asset.kind == "UI") + .filter(|asset| { + asset.kind == UI_DESIGN_DOC_ASSET_KIND && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE + }) .count() + 1; loop { @@ -242,7 +250,7 @@ mod tests { register_local_asset_entry( directory.path(), "assets/ui-prototype.png", - "ui-prototype", + GameCreationAppAssetKind::UiDesign, "image/png", "canvas", GameCreationAppAssetSource { @@ -270,7 +278,7 @@ mod tests { let source_id = manifest .assets .iter() - .find(|asset| asset.kind == "ui-prototype") + .find(|asset| asset.kind == GameCreationAppAssetKind::UiDesign) .expect("source asset") .id .clone(); @@ -281,11 +289,9 @@ mod tests { }; let first = ensure_ui_design_resource_for_prototype(input.clone()).expect("bridge"); assert!(first.created); - assert_eq!(first.asset.kind, "UI"); + assert_eq!(first.asset.kind, UI_DESIGN_DOC_ASSET_KIND); // 写侧 → 分类的端到端断言:这条路径走的是与 - // `workflow.rs` / `persistence.rs` 完全相同的 `register_local_asset_at(..., "UI", ...)`。 - // 别名表漏掉大写 `UI` 时,这里会落 unclassified(真机 8 条 UI 资产的表现), - // 且派生值本身就是 unclassified,读时自愈也救不回来。 + // 写侧与 persistence/workflow 共用 UI 文档 kind/media 常量。 assert_eq!( first.asset.category, GameCreationAppAssetCategory::UiInteraction @@ -322,7 +328,10 @@ mod tests { .manifest .assets .iter() - .filter(|asset| asset.kind == "UI") + .filter(|asset| { + asset.kind == UI_DESIGN_DOC_ASSET_KIND + && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE + }) .count(), 1 ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs index 3c7d9a4f1..e827ea75d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs @@ -7,6 +7,7 @@ use crate::ui_editor::layout::node::{Node, StageStatus}; use crate::ui_editor::persistence::{ initialize_ui_design_state_at, load_ui_design_state_at, save_ui_design_state_at, LoadUiDesignStateInput, SaveUiDesignStateInput, SaveUiDesignStateResult, + UI_DESIGN_DOC_ASSET_KIND, UI_DESIGN_DOC_MEDIA_TYPE, }; use crate::ui_editor::resource::font::FontAsset; use crate::ui_editor::resource::sprite::{SpriteAsset, SpriteAssetMetadata, SpriteBorder}; @@ -19,6 +20,7 @@ use image::GenericImageView as _; use nalgebra::Vector2; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; +use shared_contracts::game_creation_app::GameCreationAppAssetKind; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::Path; @@ -533,8 +535,8 @@ fn validate_source_asset( root: &Path, asset: &GameCreationAppAssetManifestEntry, ) -> Result<(), String> { - if asset.kind != "ui-prototype" || !asset.media_type.starts_with("image/") { - return Err("ui.workflow.run sourceAssetId 必须是已登记的 ui-prototype 图片".to_string()); + if asset.kind != GameCreationAppAssetKind::UiDesign || !asset.media_type.starts_with("image/") { + return Err("ui.workflow.run sourceAssetId 必须是已登记的 ui-design 图片".to_string()); } validate_image_asset_file(root, asset, "UI 原型图") } @@ -683,8 +685,8 @@ fn find_page_ui_resource( let Some(asset) = matches.into_iter().next() else { return Ok(None); }; - if asset.kind != "UI" - || asset.media_type != "application/json" + if asset.kind != UI_DESIGN_DOC_ASSET_KIND + || asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE || asset.local_path != workflow_relative_path(source, &page.page_id) { return Err(format!("页面 {} 的 UI workflow 资源身份冲突", page.page_id)); @@ -748,8 +750,8 @@ fn ensure_page_ui_resource( let registered = register_local_asset_at( root, &relative_path, - "UI", - "application/json", + GameCreationAppAssetKind::UiDesignDoc, + UI_DESIGN_DOC_MEDIA_TYPE, "ui-workflow", GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Generated, @@ -852,6 +854,21 @@ fn ensure_page_ui_resource( Ok(ui_asset) } +/// 比较 sprite 资源身份时忽略 `metadata.asset_type`。 +/// +/// `asset_type` 是随 canonical kind 派生的展示串,不构成资源身份;身份判定只看真正影响 +/// 渲染的字段,避免展示字段变化造成资源冲突。 +fn same_sprite_asset_identity(current: &SpriteAsset, next: &SpriteAsset) -> bool { + if current.metadata.asset_type == next.metadata.asset_type { + return current == next; + } + let mut current = current.clone(); + let mut next = next.clone(); + current.metadata.asset_type = String::new(); + next.metadata.asset_type = String::new(); + current == next +} + fn install_page_component_assets( root: &Path, state: &mut crate::ui_editor::state::State, @@ -879,11 +896,11 @@ fn install_page_component_assets( .and_then(|name| name.to_str()) .unwrap_or("UI 素材") .to_string(), - asset_type: asset.kind.clone(), + asset_type: asset.kind.to_string(), }; sprite.path = asset.local_path.clone(); match state.sprite_assets.get(&asset_id) { - Some(current) if current != &sprite => { + Some(current) if !same_sprite_asset_identity(current, &sprite) => { return Err(format!( "UI 独立图片/图标 {} 与已有 State 资源冲突", asset.id @@ -1179,7 +1196,7 @@ fn update_page_manifest_stage(root: &Path, asset_id: &str, stage: &str) -> Resul .iter_mut() .find(|asset| asset.id == asset_id) .ok_or_else(|| format!("UI workflow 资源 {} 未登记", asset_id))?; - if asset.kind != "UI" || asset.media_type != "application/json" { + if asset.kind != UI_DESIGN_DOC_ASSET_KIND || asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE { return Err(format!("UI workflow 资源 {} 类型不匹配", asset_id)); } let next_kind = format!("ui-workflow.{stage}"); @@ -1462,7 +1479,7 @@ mod tests { fn fixture_asset( root: &Path, relative_path: &str, - kind: &str, + kind: GameCreationAppAssetKind, resource_id: &str, ) -> GameCreationAppAssetManifestEntry { register_local_asset_at( @@ -1605,8 +1622,18 @@ mod tests { let design_path = root.join("assets/home.png"); fixture_png(&source_path); fixture_png(&design_path); - let source = fixture_asset(root, "assets/ui-prototype.png", "ui-prototype", "source-ui"); - let design = fixture_asset(root, "assets/home.png", "ui-design", "design-home"); + let source = fixture_asset( + root, + "assets/ui-prototype.png", + GameCreationAppAssetKind::UiDesign, + "source-ui", + ); + let design = fixture_asset( + root, + "assets/home.png", + GameCreationAppAssetKind::UiDesign, + "design-home", + ); let page = |operation| UiWorkflowRunInput { operation, source_asset_id: source.id.clone(), @@ -1656,9 +1683,24 @@ mod tests { fixture_png(&root.join("assets/ui-prototype.png")); fixture_png(&root.join("assets/home.png")); fixture_png(&root.join("assets/start-button.png")); - let source = fixture_asset(root, "assets/ui-prototype.png", "ui-prototype", "source-ui"); - let design = fixture_asset(root, "assets/home.png", "ui-design", "design-home"); - let sprite = fixture_asset(root, "assets/start-button.png", "ui-icon", "start-button"); + let source = fixture_asset( + root, + "assets/ui-prototype.png", + GameCreationAppAssetKind::UiDesign, + "source-ui", + ); + let design = fixture_asset( + root, + "assets/home.png", + GameCreationAppAssetKind::UiDesign, + "design-home", + ); + let sprite = fixture_asset( + root, + "assets/start-button.png", + GameCreationAppAssetKind::Icon, + "start-button", + ); let font_path = root.join("assets/ui-font.ttf"); fs::copy( Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../public/fusion-pixel.ttf"), @@ -1668,7 +1710,7 @@ mod tests { let font = register_local_asset_at( root, "assets/ui-font.ttf", - "font", + GameCreationAppAssetKind::Font, "font/ttf", "workflow-test", GameCreationAppAssetSource { diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 16fb765b0..caf12c2a5 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -18,12 +18,14 @@ import { type GameCreationAgentCapabilityDescriptor, type GameCreationAgentRunTrace, type GameCreationAgentToolCallTrace, + type GameCreationAppAssetKind, type GameCreationAppCommandDescriptor, type GameCreationAppLimitedRunCommandDescriptor, type GameCreationAppManifest, type GameCreationAppPermission, type GameCreationAppPreviewState, type GameCreationAppPreviewStatus, + parseGameCreationAppAssetKind, } from '../../../packages/shared/src/contracts/gameCreationApp'; import { AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD, @@ -1396,7 +1398,7 @@ export function App({ UploadLocalAssetResult[] >([]); const [assetLocalPath, setAssetLocalPath] = useState('assets/hero.png'); - const [assetKind, setAssetKind] = useState('asset'); + const [assetKind, setAssetKind] = useState('unknown'); const [assetMediaType, setAssetMediaType] = useState( 'application/octet-stream', ); @@ -5657,7 +5659,7 @@ export function App({ } const [ localPath, - kind = 'asset', + kind = 'unknown', mediaType = 'application/octet-stream', ] = prompt.slice('/asset-register '.length).trim().split(/\s+/); if (!localPath) { @@ -5677,7 +5679,12 @@ export function App({ ]); return; } - queuePendingCommand({ id: 'asset.register', localPath, kind, mediaType }); + queuePendingCommand({ + id: 'asset.register', + localPath, + kind: parseGameCreationAppAssetKind(kind, 'chat.asset-register.kind'), + mediaType, + }); setMessages((current) => [ ...current, { role: 'assistant', text: `准备登记项目资产:${localPath}` }, @@ -6133,7 +6140,10 @@ export function App({ canvasProjectId, canvasAssetId: canvasAssetObjectId ? '' : canvasAssetId, canvasAssetObjectId, - kind: kind ?? 'asset', + kind: parseGameCreationAppAssetKind( + kind ?? 'unknown', + 'chat.canvas-asset-import.kind', + ), mediaType: mediaType ?? 'application/octet-stream', }); setMessages((current) => [ @@ -10398,7 +10408,7 @@ export function App({ void executeAssetRegister( nextProjectPath, localPath, - assetKind, + parseGameCreationAppAssetKind(assetKind, 'ui.asset-register.kind'), assetMediaType, assetSourceKind, canvasProjectId, @@ -10411,7 +10421,7 @@ export function App({ async function executeAssetRegister( nextProjectPath: string, localPath: string, - kind: string, + kind: GameCreationAppAssetKind, mediaType: string, sourceKind: string, canvasProjectId: string, @@ -10538,7 +10548,10 @@ export function App({ void executeCanvasAssetImport( nextProjectPath, localPath, - assetKind, + parseGameCreationAppAssetKind( + assetKind, + 'ui.canvas-asset-import.kind', + ), assetMediaType, canvasProjectId, resourceId, @@ -10551,7 +10564,7 @@ export function App({ async function executeCanvasAssetImport( nextProjectPath: string | null, localPath: string, - kind: string, + kind: GameCreationAppAssetKind, mediaType: string, canvasProjectId: string, resourceId: string, diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index dd40ab154..61f1d5fde 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -2,6 +2,7 @@ import type { GameCreationAgentRunTrace, GameCreationAgentToolCallTrace, GameCreationAppAgentGroup, + GameCreationAppAssetKind, GameCreationAppCommandDescriptor, GameCreationAppManifest, GameCreationAppTaskStatus, @@ -856,7 +857,7 @@ export interface LocalProjectFileEntry { export type ProjectAssetDraft = { localPath: string; - kind: string; + kind: GameCreationAppAssetKind; mediaType: string; }; @@ -1201,7 +1202,7 @@ export type PendingCommand = | { id: 'asset.register'; localPath: string; - kind: string; + kind: GameCreationAppAssetKind; mediaType: string; } | { @@ -1263,7 +1264,7 @@ export type PendingCommand = canvasProjectId: string; canvasAssetId: string; canvasAssetObjectId?: string; - kind: string; + kind: GameCreationAppAssetKind; mediaType: string; } | { diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx b/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx index 17bcfdb48..6dff48d17 100644 --- a/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx @@ -6,6 +6,7 @@ import { open as openNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { X } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; +import type { GameCreationAppAssetKind } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { loadEditorAssetLibrary, resolveClientAssetReadUrl, @@ -58,7 +59,7 @@ type LocalProjectFilesResponse = { files?: LocalProjectFile[] }; type ImportAssetResponse = { id: string; localPath: string; - assetKind?: string | null; + assetKind?: GameCreationAppAssetKind | null; }; type GenericImportResponse = { assets: ImportAssetResponse[] }; diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts b/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts index b8c80e344..42b5e1754 100644 --- a/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts @@ -1,10 +1,15 @@ import type { FileManagerFile } from '@cubone/react-file-manager'; import type { ComponentType, ReactNode } from 'react'; +import { + type GameCreationAppAssetKind, + parseGameCreationAppAssetKind, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; + export type ImportedAsset = { id: string; localPath: string; - assetKind: string | null; + assetKind: GameCreationAppAssetKind | null; }; export type AssetSource = 'local' | 'remote'; @@ -29,7 +34,7 @@ export type RemoteAssetCandidate = { assetId: string; name: string; objectKey: string; - assetKind: string | null; + assetKind: GameCreationAppAssetKind | null; sizeBytes: number; previewUrl?: string; }; @@ -104,7 +109,7 @@ export type ManifestAsset = { id: string; localPath: string; mediaType: string; - kind?: string; + kind?: GameCreationAppAssetKind; source?: { kind?: string }; }; @@ -352,7 +357,13 @@ export function buildRemoteFolderFiles( assetId: asset.assetId || asset.objectKey || asset.imageSrc || fileName, name: baseName, objectKey: asset.objectKey || asset.imageSrc || fileName, - assetKind: asset.assetKind ?? null, + assetKind: + asset.assetKind == null + ? null + : parseGameCreationAppAssetKind( + asset.assetKind, + 'asset-importer.remote.assetKind', + ), sizeBytes: asset.size ?? 0, previewUrl: asset.previewUrl, }; @@ -372,7 +383,7 @@ export function buildRemoteFolderFiles( asset.imageSrc || 'platform-image', localPath, - assetKind: asset.assetKind ?? null, + assetKind: asset.assetKind == null ? null : candidate.assetKind, }, }); } diff --git a/apps/ai-game-creator-shell/src/features/project-summary/projectArtifactSummaries.ts b/apps/ai-game-creator-shell/src/features/project-summary/projectArtifactSummaries.ts index 2181c5aac..ce4906830 100644 --- a/apps/ai-game-creator-shell/src/features/project-summary/projectArtifactSummaries.ts +++ b/apps/ai-game-creator-shell/src/features/project-summary/projectArtifactSummaries.ts @@ -311,7 +311,7 @@ export function inferProjectFileAssetDraft( }; } if (extension === 'json') { - return { localPath, kind: 'data', mediaType: 'application/json' }; + return { localPath, kind: 'document', mediaType: 'application/json' }; } if (extension === 'html') { return { localPath, kind: 'document', mediaType: 'text/html' }; @@ -319,7 +319,10 @@ export function inferProjectFileAssetDraft( if (['txt', 'md', 'csv'].includes(extension)) { return { localPath, kind: 'document', mediaType: 'text/plain' }; } - return { localPath, kind: 'asset', mediaType: 'application/octet-stream' }; + // 判不出内容类型就写 `unknown`(→「待归类」),与 Rust 侧 `uploaded_asset_kind` 同口径: + // `/asset-register` 的 kind 会原样进 `register_local_asset` 的严格解析,写 `data` / `asset` + // 这类非 canonical 值只会被判成"认不出"并留痕,不能靠它表达"这是数据 / 这是资产"。 + return { localPath, kind: 'unknown', mediaType: 'application/octet-stream' }; } export function projectAssetDraftCommand(draft: ProjectAssetDraft) { diff --git a/apps/ai-game-creator-shell/src/features/project-summary/projectAssetSummaries.ts b/apps/ai-game-creator-shell/src/features/project-summary/projectAssetSummaries.ts index 713f93189..3afb4fde7 100644 --- a/apps/ai-game-creator-shell/src/features/project-summary/projectAssetSummaries.ts +++ b/apps/ai-game-creator-shell/src/features/project-summary/projectAssetSummaries.ts @@ -1,6 +1,8 @@ import { type GameCreationAppAssetSourceKind, type GameCreationAppManifest, + isGameCreationAppAssetAudioKind, + isGameCreationAppAssetVisualKind, selectGameCreationAppReadyTasks, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { taskRowsFromManifest } from '../agent-runtime'; @@ -102,12 +104,9 @@ export function isProjectAudioAsset( asset: GameCreationAppManifest['assets'][number], ) { const mediaType = asset.mediaType.toLowerCase(); - const kind = asset.kind.toLowerCase(); return ( mediaType.startsWith('audio/') || - kind === 'audio' || - kind === 'sound-effect' || - kind === 'background-music' + isGameCreationAppAssetAudioKind(asset.kind) ); } @@ -115,17 +114,11 @@ export function isProjectVisualAsset( asset: GameCreationAppManifest['assets'][number], ) { const mediaType = asset.mediaType.toLowerCase(); - const kind = asset.kind.toLowerCase(); return ( mediaType.startsWith('image/') || mediaType.startsWith('video/') || mediaType === 'application/vnd.genarrative.image-sequence' || - kind === 'image' || - kind === 'sprite' || - kind === 'icon' || - kind === 'ui' || - kind === 'background' || - kind === 'character-animation' + isGameCreationAppAssetVisualKind(asset.kind) ); } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index e6a54d3fa..28250a85c 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -56,6 +56,7 @@ import { type GameCreationAppAssetManifestEntry, gameCreationAppAssetTags, type GameIterationVersion, + isGameCreationAppUiDesignDocAsset, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { resolveTauriInvoke } from '../../app/tauri'; import { @@ -1283,7 +1284,6 @@ function ResourcePickerThumbnail({ const [previewUrl, setPreviewUrl] = useState(null); const [failed, setFailed] = useState(false); const mediaType = asset?.mediaType.toLowerCase() ?? ''; - const kind = asset?.kind.toLowerCase() ?? ''; useEffect(() => { setPreviewUrl(null); @@ -1330,7 +1330,7 @@ function ResourcePickerThumbnail({ if (mediaType.startsWith('image/')) { return