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 ef66da32c..ff86236a5 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 @@ -1161,7 +1161,11 @@ fn bridge_registered_resource( "localPath": asset.local_path, "kind": asset.kind, "mediaType": asset.media_type, - "category": asset.category, + // Agent 与 UI 必须看到同一个口径:UI 栏目走 TS 的 `gameCreationAppAssetCategory` + // (落盘值 + 按 kind 派生 + 读时自愈),这里走 Rust 的同构实现。 + // 直接透传落盘 `asset.category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、 + // 在 Agent 侧读到 `unclassified`(真机 55 条分歧)。 + "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, @@ -3087,8 +3091,12 @@ mod tests { assert!(!serialized.contains("private.invalid")); } + /// 落盘分类是明确值时,Agent 侧原样透传。 + /// + /// (用例名从 `..._verbatim` 改成 `..._keeps_explicit...`:透传只在落盘值不是 + /// `unclassified` 时成立,落盘 `unclassified` 要按读显示口径自愈,见下一个用例。) #[test] - fn bridge_registered_resource_projects_manifest_classification_verbatim() { + fn bridge_registered_resource_keeps_explicit_manifest_classification() { let asset = GameCreationAppAssetManifestEntry { id: "spec-1".to_string(), kind: "spec".to_string(), @@ -3123,6 +3131,43 @@ mod tests { assert!(!serialized.contains("private model")); } + /// Agent 侧必须与 UI 看到同一个分类口径。 + /// + /// UI 栏目走 TS 的 `gameCreationAppAssetCategory`(落盘值 + 按 kind 派生 + 读时自愈), + /// Agent 走 Rust 的同构实现 `game_creation_app_asset_effective_category`。直接透传落盘 + /// `category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、在 Agent 侧读到 + /// `unclassified`——真机 122 条资产里有 55 条这样分叉。 + #[test] + fn bridge_registered_resource_projects_effective_category_not_raw_persisted_value() { + let asset = GameCreationAppAssetManifestEntry { + id: "ui-1".to_string(), + kind: "ui".to_string(), + media_type: "application/json".to_string(), + local_path: "assets/UI 设计 1.json".to_string(), + image_sequence_frames: None, + image_sequence_duration_ms: None, + category: + shared_contracts::game_creation_app::GameCreationAppAssetCategory::Unclassified, + tags: Vec::new(), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + }; + + let projection = bridge_registered_resource(&asset, false); + assert_eq!(projection["kind"], "ui"); + assert_eq!(projection["category"], "ui-interaction"); + } + #[test] fn bridge_success_warnings_are_redacted_before_serialization() { let root = tempfile::tempdir().expect("warning redaction root"); 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 c36311362..9dec92e39 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -27,9 +27,10 @@ use sha2::Digest; use shared_contracts::error_reports::ErrorReportLogInput; use shared_contracts::game_creation_app::{ game_creation_app_asset_category_for_kind, game_creation_app_asset_category_from_str, - new_game_creation_app_manifest, new_game_creation_app_seed_tasks, - normalize_game_creation_app_asset_tags, validate_game_iteration_versions, - GameCreationAgentArtifactTrace, GameCreationAgentCapabilityDescriptor, + game_creation_app_asset_effective_category, new_game_creation_app_manifest, + new_game_creation_app_seed_tasks, normalize_game_creation_app_asset_tags, + validate_game_iteration_versions, GameCreationAgentArtifactTrace, + GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep, GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace, GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource, diff --git a/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts b/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts index 45ee79150..61e8d89ee 100644 --- a/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts +++ b/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts @@ -10,6 +10,8 @@ import { canonicalGameCreationAppAssetKind, GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND, GAME_CREATION_APP_CANONICAL_ASSET_KINDS, + type GameCreationAppAssetCategory, + gameCreationAppAssetCategory, gameCreationAppAssetCategoryForKind, type GameCreationAppCanonicalAssetKind, } from '../../../packages/shared/src/contracts/gameCreationApp'; @@ -50,6 +52,54 @@ function rustCanonicalKinds(): string[] { return Array.from(body.matchAll(/"([^"]+)"/g)).map((match) => match[1]!); } +/** Rust 的 `GameCreationAppAssetCategory` 变体名 → kebab-case 线上值。 */ +function rustCategorySocketName(variant: string) { + return variant.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); +} + +/** 从 Rust 侧 `GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND` 里解析出 kind → 栏目表。 */ +function rustAssetCategoryByKind(): Map { + const source = readFileSync(RUST_CONTRACT_PATH, 'utf8'); + const start = source.indexOf( + 'pub const GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND', + ); + expect(start).toBeGreaterThan(-1); + // 该表有多种排版(单行、以及 kind 与变体各占一行的多行条目),先去空白再逐条取。 + const body = source + .slice(start, source.indexOf('];', start)) + .replace(/\s+/g, ''); + + const table = new Map(); + for (const entry of body.matchAll( + /\("([^"]+)",GameCreationAppAssetCategory::(\w+),?\)/g, + )) { + table.set(entry[1]!, rustCategorySocketName(entry[2]!)); + } + return table; +} + +/** + * 从 Rust 侧解析「有效分类」的决策矩阵。 + * + * 读时自愈(落盘 `unclassified` 而 kind 能派生出明确分类时采用派生值)在两侧必须只有 + * 一个口径:TS 侧 `gameCreationAppAssetCategory`、Rust 侧 + * `game_creation_app_asset_effective_category`(Agent 投影走这条)。矩阵是唯一真源, + * 写在 Rust 源码里、由 Rust 单测逐条断言,本文件解析同一份矩阵再喂给 TS 实现对照, + * 两侧各写一份的话会重新分叉。 + */ +function rustEffectiveCategoryContract(): Array<[string, string, string]> { + const source = readFileSync(RUST_CONTRACT_PATH, 'utf8'); + const start = source.indexOf('const EFFECTIVE_CATEGORY_CONTRACT'); + expect(start).toBeGreaterThan(-1); + const body = source.slice(start, source.indexOf('];', start)); + + const rows: Array<[string, string, string]> = []; + for (const row of body.matchAll(/\("([^"]*)",\s*"([^"]*)",\s*"([^"]*)"\)/g)) { + rows.push([row[1]!, row[2]!, row[3]!]); + } + return rows; +} + /** TS 侧的别名表(未导出,用 canonical 目录探测「非 canonical 输入被改写成什么」)。 */ const OBSERVED_ALIAS_INPUTS = [ 'game-background', @@ -100,6 +150,49 @@ describe('资源 kind 别名表跨语言一致性', () => { expect(canonical).toContain(canonicalGameCreationAppAssetKind(kind)); } }); + + test('两侧 canonical kind → 栏目表逐条一致', () => { + const rustTable = rustAssetCategoryByKind(); + expect([...rustTable.keys()].sort()).toEqual( + [...GAME_CREATION_APP_CANONICAL_ASSET_KINDS].sort(), + ); + + const mismatches: Array<{ kind: string; ts: string; rust: string }> = []; + for (const [kind, rustCategory] of rustTable) { + const tsCategory = + GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND[ + kind as GameCreationAppCanonicalAssetKind + ]; + if (tsCategory !== rustCategory) { + mismatches.push({ kind, ts: tsCategory, rust: rustCategory }); + } + } + expect(mismatches).toEqual([]); + }); + + test('读时自愈决策矩阵两侧一致(单一真源在 Rust 侧)', () => { + const rows = rustEffectiveCategoryContract(); + // 矩阵必须覆盖:落盘明确值、落盘 unclassified + kind 可明确分类、 + // 落盘 unclassified + 派生结果本来就是 unclassified、大写写侧字面量。 + expect(rows.length).toBeGreaterThanOrEqual(8); + + const mismatches: Array<{ + kind: string; + persisted: string; + expected: string; + ts: string; + }> = []; + for (const [kind, persisted, expected] of rows) { + const ts = gameCreationAppAssetCategory({ + kind, + category: persisted as GameCreationAppAssetCategory, + }); + if (ts !== expected) { + mismatches.push({ kind, persisted, expected, ts }); + } + } + expect(mismatches).toEqual([]); + }); }); describe('真机出现的 kind 归类口径', () => { diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index ce5906c9d..797b8c44c 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -503,6 +503,10 @@ pub struct GameCreationAppAssetManifestEntry { /// 历史 manifest 缺少 `category` / `tags` 时按 `kind` 派生默认值; /// 显式写入的合法分类必须原样保留,未知分类值按前向兼容退回 `kind` 派生。 +/// +/// 这里**刻意不做读时自愈**(落盘 `unclassified` 而 kind 能派生明确分类时改用派生值): +/// 反序列化结果就是落盘原值,「编辑标签」面板要靠它把落盘分类原样回写,否则用户只改标签 +/// 也会静默改分类。自愈只属于读显示口径,见 `game_creation_app_asset_effective_category`。 #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct GameCreationAppAssetManifestEntryWire { @@ -661,6 +665,34 @@ pub fn game_creation_app_asset_category_for_kind(kind: &str) -> GameCreationAppA .unwrap_or(GameCreationAppAssetCategory::Unclassified) } +/// manifest 资产的**有效分类**(读显示口径)。 +/// +/// 与 TS 侧 `gameCreationAppAssetCategory` 是同一套口径的两份实现,由 +/// `apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts` 解析 +/// `EFFECTIVE_CATEGORY_CONTRACT` 决策矩阵交叉钉住,两侧不许各写一份。 +/// +/// 规则:落盘 `category` 是权威值;**唯一例外**是落盘值 `unclassified` 而该资产 `kind` +/// 能派生出明确的非 `unclassified` 分类时采用派生值。这条例外用于自愈历史上被系统误写成 +/// `unclassified` 的存量(典型是 `kind:"ui"` / `kind:"UI"` 因 kind 不在 canonical 目录而 +/// 落到 `image -> unclassified`),不写迁移脚本且永久生效;`kind` 派生结果本身就是 +/// `unclassified` 的(`image / video / code / publication-material`)仍信任落盘值。 +/// +/// 注意分工:本函数只用于**读显示**(UI 栏目、Agent 投影)。回写 manifest 必须用落盘原值 +/// (`GameCreationAppAssetManifestEntry.category` 反序列化后就是落盘原值),否则「编辑标签」 +/// 面板会把自愈值写回去,把「只改标签」变成静默改分类。 +pub fn game_creation_app_asset_effective_category( + kind: &str, + persisted: GameCreationAppAssetCategory, +) -> GameCreationAppAssetCategory { + if persisted == GameCreationAppAssetCategory::Unclassified { + let derived_from_kind = game_creation_app_asset_category_for_kind(kind); + if derived_from_kind != GameCreationAppAssetCategory::Unclassified { + return derived_from_kind; + } + } + persisted +} + pub fn normalize_game_creation_app_asset_tags(tags: &[String]) -> Vec { let mut normalized = Vec::new(); for tag in tags { @@ -2128,6 +2160,43 @@ mod tests { ); } + /// 有效分类(读显示)决策矩阵:`(kind, 落盘 category, 有效 category)`。 + /// + /// **这是「落盘值 / kind 派生 / 读时自愈」三个口径的唯一真源**:本表由下面的 + /// `asset_effective_category_follows_the_shared_contract_matrix` 逐条断言, + /// 同时被 TS 侧 + /// `apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts` 解析出来喂给 + /// `gameCreationAppAssetCategory` 对照。两侧各写一份矩阵就会重新分叉成 + /// 「UI 显示 ui-interaction、Agent 读 unclassified」的两套口径。 + const EFFECTIVE_CATEGORY_CONTRACT: [(&str, &str, &str); 8] = [ + // 落盘值合法且明确 → 落盘值为准。 + ("character", "character", "character"), + ("ui", "scene", "scene"), + ("image", "audio", "audio"), + // 落盘 unclassified 且 kind 能派生明确分类 → 自愈成派生值。 + ("ui", "unclassified", "ui-interaction"), + ("character", "unclassified", "character"), + // kind 派生结果本身就是 unclassified → 信任落盘值。 + ("image", "unclassified", "unclassified"), + ("unknown-kind", "unclassified", "unclassified"), + ("video", "unclassified", "unclassified"), + ]; + + #[test] + fn asset_effective_category_follows_the_shared_contract_matrix() { + for (kind, persisted, expected) in EFFECTIVE_CATEGORY_CONTRACT { + let persisted = game_creation_app_asset_category_from_str(persisted) + .unwrap_or_else(|| panic!("矩阵里的落盘分类非法:{persisted}")); + let expected = game_creation_app_asset_category_from_str(expected) + .unwrap_or_else(|| panic!("矩阵里的期望分类非法:{expected}")); + assert_eq!( + game_creation_app_asset_effective_category(kind, persisted), + expected, + "有效分类矩阵不一致:kind={kind} persisted={persisted:?}" + ); + } + } + #[test] fn asset_manifest_entry_defaults_category_and_tags_for_legacy_payloads() { let character = asset_entry_from_json(asset_entry_json("character"));