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 e0a81ff29..bf49cdf5c 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 @@ -3872,19 +3872,25 @@ 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 只接受**图片族**成员, - // 未声明、认不出或非图片族成员(音频 / 视频 / 文档 / 字体 / 代码…)时按图片收口。 - // 只排除 `Unknown` 是不够的:`source_subtype` 是外部入参,传 `background-music` 就会把 - // 一张 PNG 登记成音频 kind,和本函数的存在理由直接冲突。 - let source_kind = input + // 该接口只把栅格源登记成 manifest 图片资产:源 subtype 只接受**图片族**成员。 + // 未声明/空白时按没有类型信息处理为 `image`;认不出或传入非图片族成员 + // (音频 / 视频 / 文档 / 字体 / 代码…)直接失败,避免静默掩盖调用方错误。 + let source_kind = match input .source_subtype .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) - .map(|value| GameCreationAppAssetKind::parse_with_context(value, "resource-edit.normalize")) - .filter(|kind| *kind != GameCreationAppAssetKind::Unknown) - .filter(|kind| { - matches!( + { + 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 !matches!( kind, GameCreationAppAssetKind::Image | GameCreationAppAssetKind::Scene @@ -3894,9 +3900,15 @@ pub(crate) fn normalize_local_project_raster_resource_at( | GameCreationAppAssetKind::IconSpritesheet | GameCreationAppAssetKind::IconSpec | GameCreationAppAssetKind::UiDesign - ) - }) - .unwrap_or(GameCreationAppAssetKind::Image); + ) { + return Err(format!( + "sourceSubtype 必须是图片族 kind,不能使用:{}", + kind.as_str() + )); + } + kind + } + }; let asset = GameCreationAppAssetManifestEntry { id: asset_id.clone(), kind: source_kind, @@ -9112,7 +9124,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(), }; @@ -9144,6 +9156,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, @@ -9181,7 +9205,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()) @@ -9244,7 +9268,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()) diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 3932e945a..73fad134d 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -8,14 +8,14 @@ - **TS 生成路径**:`GameCreationAppAssetKind` 由 ts-rs 生成到 `packages/shared/src/contracts/generated/GameCreationAppAssetKind.ts`(不再是 `apps/ai-game-creator-shell/src/contracts/generated/`);`packages/shared/src/contracts/gameCreationApp.ts` 直接 re-export 该 union,运行期 kind 列表只有一份 `GAME_CREATION_APP_ASSET_KINDS`(穷举 `Record` 保证不会与生成 union 分叉)。重新生成:`cargo test --locked -p shared-contracts --features ts-bindings export_bindings --manifest-path server-rs/Cargo.toml`,之后 `git diff` 必须为空。两张生成目录的忽略规则同步登记在 `.prettierignore` 与 `.eslintrc.cjs`。 - **严格解析只有一个入口**:`GameCreationAppAssetKind::parse_with_context(value, context)` 是唯一公开解析入口(`FromStr`、serde、所有外部边界都走它),内部严格匹配是私有 `match_canonical()`;先前那批易混名字(`from_str_lossy()`、公开的 `from_str_or_unknown()`)都已删除,认不出 canonical 值只有这一处收口 + 留痕。口径是等值匹配——不 trim、不 lowercase、不查别名、不迁移;`"UI"`、`"ui"`、`" image "`、`"art-spritesheet-slice"` 一律收口成 `unknown`(分类落 `unclassified`)。**接受 unknown 是显式决定**:留痕日志给出原始串与上下文,供回查仍在写 legacy kind 的代码;不写兼容、不做迁移。 - **登记边界同口径**:外部登记 kind(`commands::register_local_asset`、`import_canvas_asset_at`、`sync_canvas_project_assets_at`、平台导入)统一走 `assets::registration_asset_kind()`——空白入参按「没有信息」落中性 `image`(→「待归类」),非空但认不出的值走严格解析收口成 `Unknown` 并留痕。三条边界不再各写一份 trim/兜底分支。 -- **登记内容的 kind 必须与内容相符**:`normalize_local_project_raster_resource_at` 的源 subtype 只接受图片族成员(`image / scene / character / character-animation / icon / icon-spritesheet / icon-spec / ui-design`),音频、视频、文档、字体、代码等一律按 `image` 登记——`source_subtype` 是公开入参,只排除 `Unknown` 会让一张 PNG 被登记成 `background-music`。派生资源写回 manifest 时不得主动写 `Unknown`:Agent 回执派生物是 markdown 文本,按 `Text => Document` 同口径落 `document`。 +- **登记内容的 kind 必须与内容相符**:`normalize_local_project_raster_resource_at` 的源 subtype 只接受图片族成员(`image / scene / character / character-animation / icon / icon-spritesheet / icon-spec / ui-design`)。未提供或空白时按没有类型信息落 `image`;音频、视频、文档、字体、代码等非图片族值,以及未知字符串,均明确报错,不再静默改成 `image`,以暴露调用方错误。派生资源写回 manifest 时不得主动写 `Unknown`:Agent 回执派生物是 markdown 文本,按 `Text => Document` 同口径落 `document`。 - **切片残留判据只看路径**:`register_existing_platform_art_slices_at` 判定「已有半成品切片登记」时只看 `assets/art-spritesheet-slices/` 前缀,不再附带 `kind == Icon`——历史切片刻写的是非 canonical kind,严格解析后是 `unknown`,再带 kind 判据会漏掉这些登记并静默跳过回填。 - **sprite 身份不含展示串**:UI 工作流比较 sprite 资源身份时忽略 `metadata.asset_type`(它随 canonical kind 派生),只看真正影响渲染的字段;否则对既有 State 重跑工作流会误报「与已有 State 资源冲突」。 - **TS 侧判据也只留一份**:`isGameCreationAppUiDesignDocAsset(asset)` 是「`kind == ui-design-doc` 且 `mediaType == application/json`」的唯一定义,资源画布入口、UI 编辑器桥接、资源引用缩略图三处统一调用它。 - **严格口径的已知代价**:既有项目 manifest 里若存的是 legacy kind(`"UI"`、`"ui-prototype"`、`"art-spritesheet"`、`"game-background"`…),读入后就是 `unknown`,依赖 kind 等值比较的运行门禁会按「缺少该资源」处理。这是「不迁移、不 fallback」的必然结果,由项目决策接受;若将来要迁就存量数据,必须单独立项(一次性迁移或读侧白名单),不得把别名表加回解析路径。 - **留痕走 `app_log!`**:`shared-contracts` 不再依赖 `tracing`(`kind-observability` feature 删除),改为暴露可注册回调 `set_non_canonical_asset_kind_reporter()`;AGC 壳在 `main()` 里注册成 `app_log!`,日志同时含**原始输入串**与调用上下文,用于回查还有谁在写 legacy kind。TS 侧对应 `parseGameCreationAppAssetKind()` 的 `console.warn`。 - **分类映射**:`GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND` 改为按 `GameCreationAppAssetKind` 变体穷举(含 `unknown → unclassified`),与 `GameCreationAppAssetKind::ALL` 的对齐由单测 `asset_category_mapping_covers_every_kind` 守住;TS 侧同表按生成 union 穷举。 -- **平台/画板词汇表**:`AGENT_RUNTIME_CANVAS_ASSET_KINDS` 与 `platform_art_asset_manifest_kind()` 仍是「画板词汇 → manifest kind」的唯一映射点(保留),但认不出的原值改为留痕收口;对外 API 的 `EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS` 白名单保持原样,属 API 兼容面,不代表客户端 kind 归一规则。 +- **画布生成 kind 共用枚举**:`canvas.asset_generate` 的 `assetKind` 白名单直接复用 `GameCreationAppAssetKind::CANVAS_ASSET_KINDS`(当前为 `image / icon-spec / ui-design / icon-spritesheet`),工具 schema、prompt 和执行前校验均从该枚举派生,不再维护 `&[&str]` 字符串目录。平台适配层若仍需要旧的外部请求字面值,只能在发送边界显式映射;manifest 始终写入枚举的 canonical 值。对外 API 的 `EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS` 白名单保持原样,属 API 兼容面,不代表客户端 kind 归一规则。 - **版本素材替换**:`subtypeEqual` 改为枚举相等(不再经别名字符串归一),`categoryEqual` 仍用 `game_creation_app_asset_effective_category` 的读时口径。 ## 2026-09-15 GameCreationApp 资源 kind 枚举化(当前权威口径)