From 0510cd8d9cf5c51d3a8c5e9a0e13a8a71ab690d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 16:19:00 +0800 Subject: [PATCH] =?UTF-8?q?AGC=20=E5=A3=B3=E6=8A=8A=E8=B5=84=E6=BA=90=20ki?= =?UTF-8?q?nd=20=E7=9A=84=E7=99=BB=E8=AE=B0=E4=B8=8E=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E7=BB=9F=E4=B8=80=E5=88=B0=E4=B8=A5=E6=A0=BC?= =?UTF-8?q?=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - assets.rs:新增 `registration_asset_kind()`,空白入参落中性 `image`、非空认不出严格收口成 `Unknown` 并留痕;`import_canvas_asset_at` 改走该函数。 - commands.rs:`imported_platform_asset_kind` 与 `register_local_asset` 一并委托给 `registration_asset_kind()`,删掉两处各写一遍的 trim/严格解析分支。 - agent/generation/canvas_generation.rs:切片残留登记判据改为只看 `assets/art-spritesheet-slices/` 路径——历史切片刻的 kind 是非 canonical 值,严格解析后收口成 `unknown`,再带 kind 判据会漏掉半成品登记并静默跳过回填;补平台词汇映射用例钉住别名与未映射值落点。 - project/resource_editor.rs:栅格归一化的源 subtype 收口到图片族成员(音频/视频/文档/字体等一律按图片登记),Agent 回执派生物写回 `Document` 而不再主动写 `Unknown`;补派生 kind 用例。 - ui_editor/workflow.rs:新增 `same_sprite_asset_identity()`,比较 sprite 身份时忽略随 kind 派生的 `metadata.asset_type`,避免对既有 State 重跑工作流误报资源冲突。 --- .../src/agent/generation/canvas_generation.rs | 56 +++++++++++++++++-- .../src-tauri/src/assets.rs | 18 +++++- .../src-tauri/src/commands.rs | 16 +++--- .../src-tauri/src/project/resource_editor.rs | 45 ++++++++++++++- .../src-tauri/src/ui_editor/workflow.rs | 19 ++++++- 5 files changed, 137 insertions(+), 17 deletions(-) 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 ee971314c..4e86ef224 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 @@ -7013,11 +7013,14 @@ pub(in crate::agent) fn register_existing_platform_art_slices_at( .clone() .filter(|value| !value.trim().is_empty()) .ok_or_else(|| "旧项目图集源资源缺少 sourceResourceId".to_string())?; + // 判据只看路径:`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.kind == GameCreationAppAssetKind::Icon - && asset - .local_path - .starts_with("assets/art-spritesheet-slices/") + asset + .local_path + .starts_with("assets/art-spritesheet-slices/") }); let receipt_path = @@ -7908,6 +7911,51 @@ mod canvas_generation_tests { } } + /// 平台请求词汇 → manifest kind 的**唯一**映射点:逐条别名、trim 与未映射值的落点都必须钉住。 + /// + /// 未映射值不能悄悄变成某个具体 kind:它走严格解析,认不出就是 `Unknown`(→「待归类」), + /// 同时由 reporter 把原始串写进日志——这条用例保证"不猜"。 + #[test] + fn platform_art_asset_manifest_kind_maps_platform_vocabulary_explicitly() { + use GameCreationAppAssetKind as Kind; + for (platform_kind, expected) in [ + ("image", Kind::Image), + ("game-art", Kind::Image), + ("character", Kind::Character), + ("character-art", Kind::Character), + ("spec", Kind::IconSpec), + ("icon-spec", Kind::IconSpec), + ("ui-prototype", Kind::UiDesign), + ("ui-design", Kind::UiDesign), + ("art-spritesheet", Kind::IconSpritesheet), + ("icon-spritesheet", Kind::IconSpritesheet), + ("publication-material", Kind::PublicationMaterial), + ("game-background", Kind::Scene), + ("scene", Kind::Scene), + // 平台词汇这一步会 trim(与 `normalize_platform_art_asset_generation_kind` 同口径)。 + (" ui-prototype ", Kind::UiDesign), + ] { + assert_eq!( + platform_art_asset_manifest_kind(platform_kind), + expected, + "{platform_kind} 的映射必须显式钉住" + ); + } + // canonical 成员即使不在上面的平台别名表里也照常解析(严格等值)。 + assert_eq!( + platform_art_asset_manifest_kind("font"), + GameCreationAppAssetKind::Font + ); + // 认不出的词汇不猜、不兜底成具体 kind。 + for platform_kind in ["", "UI", "ui", "mystery", "unknown-kind"] { + assert_eq!( + platform_art_asset_manifest_kind(platform_kind), + GameCreationAppAssetKind::Unknown, + "{platform_kind} 不在映射表内,必须落 unknown 而不是被猜成某个 kind" + ); + } + } + #[test] fn generation_kind_catalog_binds_art_spritesheet_to_a_spec_board_prompt() { // 放行 art-spritesheet 必须真的走到图集提示词与图集请求合同, 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 3115963c2..781f6448f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -538,6 +538,22 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> GameCreationAppAsse } } +/// 登记边界的 kind 解析:空白入参按「没有信息」处理,落中性 `image`(→「待归类」); +/// 非空但认不出的原值严格收口成 `Unknown`(同样「待归类」)并把原始串交给 reporter 留痕。 +/// +/// 三条外部登记边界必须同口径:平台导入(`commands::imported_platform_asset_kind`)、 +/// 画板导入(`sync_canvas_project_assets_at`)、外部 `register_local_asset`。 +/// 只做严格等值匹配——不 trim 归一、不查别名、不做迁移。 +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, @@ -686,7 +702,7 @@ pub(crate) fn import_canvas_asset_at( if resource_id.is_none() && asset_object_id.is_none() { return Err("画板资源 ID 和资产对象 ID 至少需要一个".to_string()); } - let kind = GameCreationAppAssetKind::parse_with_context(kind.trim(), "canvas.asset_import"); + let kind = registration_asset_kind(kind, "canvas.asset_import"); register_local_asset_at( root, 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 c2121b56e..66803b1fe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -2167,7 +2167,7 @@ pub(crate) fn register_local_asset( register_local_asset_at( root, local_path.trim(), - GameCreationAppAssetKind::parse_with_context(kind.trim(), "asset.register"), + crate::assets::registration_asset_kind(&kind, "asset.register"), media_type.trim(), source_kind.trim(), GameCreationAppAssetSource { @@ -4189,13 +4189,13 @@ pub(crate) fn import_local_project_assets_for_agent( /// /// kind 只有一条口径:严格解析,不做别名归一、不做迁移(`Kind.ALL` 是唯一词汇表)。 fn imported_platform_asset_kind(platform_kind: Option<&str>) -> GameCreationAppAssetKind { - match platform_kind.map(str::trim).filter(|kind| !kind.is_empty()) { - // 平台是外部系统,但 kind 口径**只有一条**:严格解析 + 非 canonical 原值留痕 - // (回调已由壳层注册成 `app_log!`,见 `main.rs`)。这里不再做别名收口, - // 认不出的值就是 `Unknown` → 「待归类」,原值只出现在日志里。 - Some(kind) => GameCreationAppAssetKind::parse_with_context(kind, "platform.asset_kind"), - None => GameCreationAppAssetKind::Image, - } + // 平台是外部系统,但 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` 查询权威素材、换签下载并登记到当前项目。模型只提交 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 b1feb6446..afad5dd7b 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 @@ -338,7 +338,12 @@ impl ResourceEditSourceKind { fn manifest_kind(self) -> GameCreationAppAssetKind { match self { Self::Manifest(kind) => kind, - Self::ProjectVersion | Self::AgentResultText => GameCreationAppAssetKind::Unknown, + // Agent 回执派生物是 markdown 文本,与 `Text => Document` 同口径:写盘只写正式成员, + // 不能主动写 `Unknown`(那会把"派生物是文档"的事实换成"认不出的资源")。 + Self::AgentResultText => GameCreationAppAssetKind::Document, + // 版本编辑不走 manifest 资产写回通道(版本有独立提交路径),此分支不可达; + // 真走到这里也只表示"没有 manifest kind"。 + Self::ProjectVersion => GameCreationAppAssetKind::Unknown, } } } @@ -3849,8 +3854,10 @@ 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]); - // 该接口只把栅格源登记成 manifest 图片资产:源 subtype 只接受正式枚举成员, - // 未声明或非图片族成员时按图片收口。 + // 该接口只把栅格源登记成 manifest 图片资产:源 subtype 只接受**图片族**成员, + // 未声明、认不出或非图片族成员(音频 / 视频 / 文档 / 字体 / 代码…)时按图片收口。 + // 只排除 `Unknown` 是不够的:`source_subtype` 是外部入参,传 `background-music` 就会把 + // 一张 PNG 登记成音频 kind,和本函数的存在理由直接冲突。 let source_kind = input .source_subtype .as_deref() @@ -3858,6 +3865,19 @@ pub(crate) fn normalize_local_project_raster_resource_at( .filter(|value| !value.is_empty()) .map(|value| GameCreationAppAssetKind::parse_with_context(value, "resource-edit.normalize")) .filter(|kind| *kind != GameCreationAppAssetKind::Unknown) + .filter(|kind| { + matches!( + kind, + GameCreationAppAssetKind::Image + | GameCreationAppAssetKind::Scene + | GameCreationAppAssetKind::Character + | GameCreationAppAssetKind::CharacterAnimation + | GameCreationAppAssetKind::Icon + | GameCreationAppAssetKind::IconSpritesheet + | GameCreationAppAssetKind::IconSpec + | GameCreationAppAssetKind::UiDesign + ) + }) .unwrap_or(GameCreationAppAssetKind::Image); let asset = GameCreationAppAssetManifestEntry { id: asset_id.clone(), @@ -6279,6 +6299,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"); 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 076b2f5ed..562a12a52 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 @@ -854,6 +854,23 @@ fn ensure_page_ui_resource( Ok(ui_asset) } +/// 比较 sprite 资源身份时忽略 `metadata.asset_type`。 +/// +/// `asset_type` 是随 canonical kind 派生的展示串(`SpriteAssetMetadata` 参与 `PartialEq`), +/// 而迁移前的 State 里存的是当时的 manifest 原始 kind(`ui-prototype` / `art-spritesheet` 等)。 +/// 它参与比较会让"对既有 State 重跑工作流"误报「与已有 State 资源冲突」,所以身份判定只看 +/// 真正影响渲染的字段;展示串随 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, @@ -885,7 +902,7 @@ fn install_page_component_assets( }; 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