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 400b47221..37e945977 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -467,6 +467,77 @@ pub(crate) fn external_editor_api_credentials_override_is_active() -> bool { active_external_editor_api_credentials().is_some() } +/// 上传素材落盘的 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 { + let media_type = media_type.trim().to_ascii_lowercase(); + let extension = Path::new(file_name) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + let extension = extension.as_str(); + if media_type.starts_with("audio/") + || matches!( + extension, + "mp3" | "wav" | "ogg" | "m4a" | "aac" | "flac" | "opus" + ) + { + "audio" + } else if media_type.starts_with("video/") || matches!(extension, "mp4" | "webm" | "mov") { + "video" + } else if media_type.starts_with("font/") + || matches!(extension, "ttf" | "otf" | "woff" | "woff2") + { + "document" + } else if media_type.starts_with("image/") + || matches!( + extension, + "png" | "jpg" | "jpeg" | "webp" | "gif" | "svg" | "avif" | "bmp" + ) + { + "image" + } else if matches!(media_type.as_str(), "text/html" | "text/css") + || media_type.contains("javascript") + || media_type.contains("typescript") + || matches!( + extension, + "html" | "htm" | "css" | "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx" + ) + { + "code" + } else if media_type.starts_with("text/") + || matches!(media_type.as_str(), "application/json" | "application/xml") + || matches!( + extension, + "md" | "markdown" + | "mdx" + | "txt" + | "json" + | "yaml" + | "yml" + | "toml" + | "csv" + | "ini" + | "conf" + | "xml" + ) + { + "document" + } else { + "asset" + } +} + pub(crate) fn upload_local_asset_at( root: &Path, file_name: &str, @@ -492,7 +563,7 @@ pub(crate) fn upload_local_asset_at( register_local_asset_entry( root, &relative_path, - "uploaded", + uploaded_asset_kind(file_name, media_type), media_type, "upload", GameCreationAppAssetSource { @@ -2051,6 +2122,45 @@ mod tests { } } + /// 上传素材的 `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` → 「待归类」),不猜具体类型。 + assert_eq!( + uploaded_asset_kind("unknown.bin", "application/octet-stream"), + "asset" + ); + 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] fn infer_file_extension_preserves_supported_local_resource_extensions() { assert_eq!( 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 9d07e6ed3..572c64abd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -2944,13 +2944,37 @@ mod agent_asset_import_tests { .local_path .starts_with("assets/uploads/local-")); assert!(first.assets[1].local_path.ends_with(".png")); - assert_eq!(first.assets[0].asset_kind.as_deref(), Some("ui")); + assert_eq!(first.assets[0].asset_kind.as_deref(), Some("image")); assert_eq!(first.assets[2].asset_kind.as_deref(), Some("audio")); assert_eq!(first.assets[3].asset_kind.as_deref(), Some("video")); assert_eq!(first.assets[4].asset_kind.as_deref(), Some("code")); assert_eq!(first.assets[5].asset_kind.as_deref(), Some("document")); 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"` 必须让本用例变红。 + let manifest: serde_json::Value = serde_json::from_str( + &fs::read_to_string(root.join(".agent/manifest.json")).expect("read imported manifest"), + ) + .expect("parse imported manifest"); + let imported_assets = manifest["assets"].as_array().expect("assets array"); + let png_rows = imported_assets + .iter() + .filter(|asset| asset["mediaType"] == "image/png") + .collect::>(); + assert_eq!(png_rows.len(), 2); + for asset in &png_rows { + assert_eq!(asset["kind"], "image", "{asset}"); + assert_eq!(asset["category"], "unclassified", "{asset}"); + } + assert!( + !imported_assets + .iter() + .any(|asset| asset["kind"] == "ui" || asset["category"] == "ui-interaction"), + "本地导入不得产出 `ui` / `ui-interaction`:{manifest}" + ); let revision_after_first = read_game_creator_agent_runtime_project_revision(root) .expect("read imported revision") .revision; @@ -3041,6 +3065,61 @@ mod agent_asset_import_tests { import_local_project_assets_for_agent(root, &["assets/broken.js".to_string()]).is_err() ); } + + /// 平台导入(账户素材库 / 网页项目画布)落盘的 `kind`:**有真实类型就用真实类型**, + /// 判不出才退回中性 `image`(→ 「待归类」),**永远不许回退成常量 `ui`**。 + /// + /// 变异验证:把 `imported_platform_asset_kind` 改成忽略入参、返回 `"ui"` 的实现, + /// 本用例必须变红(`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"); + assert_eq!( + imported_platform_asset_kind(Some("character-animation")), + "character-animation" + ); + 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"); + } + + /// 反查门禁:两处平台素材导入必须继续用 `imported_platform_asset_kind` 解析 `kind`, + /// 不允许回退成写死的字面量。 + /// + /// 断言的是**调用点原文**而不是函数被提及的次数——本测试自身也在同一文件里, + /// 按名字计数会把测试代码算进去,门禁就假绿了。 + #[test] + fn platform_asset_import_sites_resolve_kind_instead_of_hardcoding_it() { + let source = include_str!("commands.rs"); + for call in [ + "imported_platform_asset_kind(record.asset_kind", + "imported_platform_asset_kind(json_string_field(", + ] { + assert!( + source.contains(call), + "平台导入必须用平台给的 `assetKind` 解析 manifest `kind`,不得回退成常量:{call}" + ); + } + for hardcoded in [ + "register_local_asset_entry(\n root,\n &local_path,\n \"ui\",", + "register_local_asset_entry(\n root,\n &local_path,\n \"uploaded\",", + ] { + assert!( + !source.contains(hardcoded), + "平台导入不得再把 manifest `kind` 写成常量字面量:{hardcoded}" + ); + } + } } fn remote_asset_local_path(asset_id: &str, extension: &str) -> String { @@ -3585,9 +3664,17 @@ fn agent_local_project_file_type( "bmp" => bytes.starts_with(b"BM").then_some("image/bmp"), _ => None, }; + // 图片的登记 `kind` 只能是内容证据能支撑的中性值:图片就是 `image` + // (canonical `image` 派生 `unclassified`,落「待归类」,由用户/后续流程再定). + // + // 绝不许把 `ui` 当"图片的默认类型":`ui → ui-design → ui-interaction` 会让任意 + // PNG 钉死在「UI 交互」栏,而"是不是 UI 素材"跟"扩展名是不是 .png"毫无关系。 + // 这种错值还不可恢复——读时自愈只在落盘 `category` 是 `unclassified` 且该 `kind` + // 能派生出非 `unclassified` 分类时才生效,这里 `kind` 本身就是错的,自愈只会把 + // 错值放大成显式的 `ui-interaction`。 media_type.map(|media_type| AgentLocalProjectFileType { category: "image", - asset_kind: "ui", + asset_kind: "image", media_type, max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }) @@ -3887,6 +3974,26 @@ 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(), + } +} + /// 按账户素材 `assetId` 查询权威素材、换签下载并登记到当前项目。模型只提交 /// `assetId`,objectKey/URL/Token 始终由本函数在客户端内部解析。 pub(crate) async fn import_account_editor_assets_for_agent( @@ -4042,10 +4149,13 @@ pub(crate) async fn import_account_editor_assets_for_agent( "editor.project-canvas.agent-import", ), }; + // 账户素材记录自带 `assetKind`:它就是"这东西是什么"的权威来源,必须落进 manifest, + // 不许再用常量 `ui` 顶掉它(见 `imported_platform_asset_kind`)。 + let asset_kind = imported_platform_asset_kind(record.asset_kind.as_deref()); let registered = register_local_asset_entry( root, &local_path, - "ui", + &asset_kind, &media_type, "canvas", GameCreationAppAssetSource { @@ -4156,10 +4266,14 @@ pub(crate) async fn import_ui_editor_remote_assets( .and_then(|_| file.sync_all()) .map_err(|error| format!("写入平台素材失败:{error}"))?; drop(file); + // 平台响应里的 `assetKind` 同样是"这东西是什么"的权威来源,必须落进 manifest; + // 缺失才退回 `image`(待归类),绝不用常量 `ui`(见 `imported_platform_asset_kind`)。 + let asset_kind = + imported_platform_asset_kind(json_string_field(&asset, "assetKind").as_deref()); let registered = register_local_asset_entry( root, &local_path, - "ui", + &asset_kind, &media_type, "canvas", GameCreationAppAssetSource { 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 0a65be672..6a60b0e5a 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 @@ -1849,6 +1849,11 @@ fn upload_local_asset_writes_file_and_manifest_entry() { assert_eq!(manifest["assets"][0]["id"], result.id); assert_eq!(manifest["assets"][0]["mediaType"], "image/png"); assert_eq!(manifest["assets"][0]["source"]["kind"], "uploaded"); + // 落盘 `kind` 只能由内容证据推导:图片是 `image`(→ `unclassified` → 「待归类」)。 + // 不许写 `uploaded`(来源词当类型),更不许把 `ui` 当图片默认值——那会把任意上传图片 + // 钉死在「UI 交互」栏,且落盘 `category` 非 `unclassified` 后读时自愈救不回来。 + assert_eq!(manifest["assets"][0]["kind"], "image"); + assert_eq!(manifest["assets"][0]["category"], "unclassified"); assert_eq!(manifest["assets"][0]["localPath"], result.local_path); let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); assert!(agent_db.lines().any(|line| { @@ -1864,6 +1869,19 @@ fn upload_local_asset_writes_file_and_manifest_entry() { 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(), 2); + // 同一上传命令的音频素材必须归「音频」,不能被图片口径顶掉。 + assert_eq!(manifest["assets"][1]["kind"], "audio"); + assert_eq!(manifest["assets"][1]["category"], "audio"); + assert!( + !manifest["assets"] + .as_array() + .unwrap() + .iter() + .any(|asset| asset["kind"] == "uploaded" + || asset["kind"] == "ui" + || asset["category"] == "ui-interaction"), + "上传登记不得产出 `uploaded` / `ui` / `ui-interaction`:{manifest}" + ); fs::remove_dir_all(root).ok(); }