diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 118c7f9e2..3c333133f 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -113,6 +113,11 @@ const allowedUncalledTauriCommands = [ 'chat_with_game_creator_agent', 'check_ui_editor_font_glyph_coverage', 'create_ui_design_resource', + // 图片类生成的同步变体:GUI 已改为 `start_local_project_asset_generation` + 项目内任务账本 + // (提交即返回、后台生成)。这条命令**没有生产调用方**,只有 Rust 集成测试 + // (`src/tests/project.rs`)与 `commands.rs` 单测在调;待后续批次删除,或改为转调 + // `start_local_project_asset_generation`。 + 'generate_local_project_asset', 'open_game_creator_launcher_window', 'open_game_creator_workspace_window', 'read_direct_project_conversation', 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 99177a4f4..2bd540024 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 @@ -2205,6 +2205,19 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at( .await } +/// standalone 图片生成的**精确动作身份材料**。 +/// +/// 这份材料既是动作指纹(`actionFingerprint`)的来源,也是 durable 输出槽身份 +/// (`runId`,见 [`standalone_platform_art_generation_runtime_context`])的来源: +/// 同一个精确动作必须落到同一个槽与同一份账本(复用 `operationId`,不二次 POST), +/// 而任何输入不同(提示词、输出路径、比例、尺寸、类型、标签、严格切片)都是另一个 +/// 动作,必须各自独立成槽,才能在同一项目里同时在途。 +/// +/// **字段集合与取值方式必须与升级前逐字节一致**:升级前遗留账本里持久化的 +/// `actionFingerprint` 就是这个材料的历史哈希,改动材料会让旧账本无法按精确动作被 +/// 识别与迁移(见 `adopt_legacy_standalone_platform_art_generation_runtime_state_at`)。 +/// 已知边界:`slice_count` 不进身份(与升级前一致),仅切片数不同的两条图集请求仍落到 +/// 同一槽,第二条在账本请求正文校验处失败关闭,不会二次 POST。 #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct StandalonePlatformArtGenerationFingerprintMaterial<'a> { @@ -2218,6 +2231,21 @@ struct StandalonePlatformArtGenerationFingerprintMaterial<'a> { require_slices: bool, } +/// 把输出路径收口成稳定的旧槽材料:空路径与未指定路径都落到 `(automatic-output)`, +/// 其余按项目内相对路径规范化。这与升级前的槽材料逐字节一致,只用于定位旧账本。 +fn legacy_standalone_platform_art_generation_output_slot( + options: &PlatformArtAssetGenerationOptions, +) -> Result { + Ok(options + .output_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(normalize_relative_path) + .transpose()? + .unwrap_or_else(|| "(automatic-output)".to_string())) +} + fn standalone_platform_art_generation_runtime_context( prompt: &str, options: &PlatformArtAssetGenerationOptions, @@ -2228,33 +2256,23 @@ fn standalone_platform_art_generation_runtime_context( } else { "manual-canvas-asset-generate" }; - let output_slot = options - .output_path - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(normalize_relative_path) - .transpose()? - .unwrap_or_else(|| "(automatic-output)".to_string()); - let slot_bytes = serde_json::to_vec(&serde_json::json!({ - "outputPath": output_slot, - "requireSlices": require_slices, - })) - .map_err(|error| format!("序列化 standalone 图片生成输出槽失败:{error}"))?; - let run_id = format!("slot-{:x}", Sha256::digest(slot_bytes)); + let identity_bytes = serde_json::to_vec(&StandalonePlatformArtGenerationFingerprintMaterial { + prompt, + output_path: options.output_path.as_deref(), + aspect_ratio: &options.aspect_ratio, + image_size: &options.image_size, + asset_kind: &options.asset_kind, + asset_label: &options.asset_label, + replace_existing: options.replace_existing, + require_slices, + }) + .map_err(|error| format!("序列化 standalone 图片生成动作身份失败:{error}"))?; + let action_fingerprint = format!("{:x}", Sha256::digest(&identity_bytes)); + // durable 输出槽身份就是这个精确动作的稳定唯一身份:同项目内不同动作 + // (不同 prompt / 素材名 / 参数)各自独立成槽,可以同时在途;重复提交同一精确动作 + // 命中同一槽与同一账本,因此仍然复用原 operationId,不二次 POST。 + let run_id = format!("slot-{action_fingerprint}"); let identity = format!("{agent_id}:{run_id}"); - let fingerprint_bytes = - serde_json::to_vec(&StandalonePlatformArtGenerationFingerprintMaterial { - prompt, - output_path: options.output_path.as_deref(), - aspect_ratio: &options.aspect_ratio, - image_size: &options.image_size, - asset_kind: &options.asset_kind, - asset_label: &options.asset_label, - replace_existing: options.replace_existing, - require_slices, - }) - .map_err(|error| format!("序列化 standalone 图片生成动作指纹失败:{error}"))?; Ok(PlatformArtGenerationRuntimeContext { agent_id: agent_id.to_string(), task_id: identity.clone(), @@ -2262,10 +2280,25 @@ fn standalone_platform_art_generation_runtime_context( run_id, source: "tauri-command".to_string(), action_id: identity, - action_fingerprint: format!("{:x}", Sha256::digest(fingerprint_bytes)), + action_fingerprint, }) } +/// 升级前的槽身份材料:只由 `{outputPath, requireSlices}` 派生,因此同一项目里 +/// 所有图片类生成共用一个槽。这个函数只用于定位升级前遗留的账本,不属于新的身份规则。 +fn legacy_standalone_platform_art_generation_run_id( + options: &PlatformArtAssetGenerationOptions, + require_slices: bool, +) -> Result { + let output_slot = legacy_standalone_platform_art_generation_output_slot(options)?; + let slot_bytes = serde_json::to_vec(&serde_json::json!({ + "outputPath": output_slot, + "requireSlices": require_slices, + })) + .map_err(|error| format!("序列化 standalone 图片生成旧输出槽失败:{error}"))?; + Ok(format!("slot-{:x}", Sha256::digest(slot_bytes))) +} + fn resolve_standalone_platform_art_generation_result( root: &Path, runtime_context: Option<&PlatformArtGenerationRuntimeContext>, @@ -2459,6 +2492,19 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at( root, runtime_context, )?; + // 一次性兼容:升级前的槽身份只由 {outputPath, requireSlices} 派生,升级后同一精确 + // 动作会指向新路径。若旧槽里的账本仍属于本次精确动作,就在任何远端 POST 前把它迁移 + // 到新身份路径,继续复用原 operationId;属于其他动作的旧账本原样保留,由对应动作 + // 自己迁移,既不阻塞新动作也不丢弃已受理的计费操作。 + if super::external_generation_state::is_standalone_platform_art_generation_runtime_context( + runtime_context, + ) { + super::external_generation_state::adopt_legacy_standalone_platform_art_generation_runtime_state_at( + root, + runtime_context, + &legacy_standalone_platform_art_generation_run_id(options, require_slices)?, + )?; + } { // Direct Codex chat persists the user turn concurrently with the first // platform-art request. Both operations are short-lived project writes; @@ -7787,79 +7833,6 @@ mod canvas_generation_tests { ColorType, ImageEncoder, }; - #[test] - fn standalone_generation_binds_complete_request_to_stable_output_slot() { - let options = PlatformArtAssetGenerationOptions { - 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_label: "手工背景".to_string(), - replace_existing: true, - slice_count: None, - }; - let ordinary = - standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) - .expect("ordinary standalone context"); - let repeated = - standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) - .expect("repeat standalone context"); - let strict = - standalone_platform_art_generation_runtime_context("完整生成提示词", &options, true) - .expect("strict standalone context"); - assert_eq!(ordinary, repeated); - assert_ne!(ordinary.run_id, strict.run_id); - assert_eq!(ordinary.source, "tauri-command"); - assert_eq!(strict.source, "tauri-command"); - assert_eq!( - ordinary.task_id, - format!("{}:{}", ordinary.agent_id, ordinary.run_id) - ); - assert_eq!(ordinary.task_id, ordinary.session_id); - assert_eq!(ordinary.task_id, ordinary.action_id); - - let different_prompt = - standalone_platform_art_generation_runtime_context("另一个生成提示词", &options, false) - .expect("different prompt context"); - assert_eq!(ordinary.run_id, different_prompt.run_id); - assert_ne!( - ordinary.action_fingerprint, - different_prompt.action_fingerprint - ); - - let mut changed_options = Vec::new(); - let mut changed = options.clone(); - changed.output_path = Some("assets/another-art.png".to_string()); - changed_options.push(changed); - let mut changed = options.clone(); - changed.aspect_ratio = "1:1".to_string(); - changed_options.push(changed); - let mut changed = options.clone(); - changed.image_size = "1K".to_string(); - changed_options.push(changed); - let mut changed = options.clone(); - changed.asset_kind = "ui-prototype".to_string(); - changed_options.push(changed); - let mut changed = options.clone(); - changed.asset_label = "另一个标签".to_string(); - changed_options.push(changed); - let mut changed = options.clone(); - changed.replace_existing = false; - changed_options.push(changed); - for changed in changed_options { - let context = standalone_platform_art_generation_runtime_context( - "完整生成提示词", - &changed, - false, - ) - .expect("changed option context"); - assert_ne!( - ordinary.action_fingerprint, context.action_fingerprint, - "every option field must participate in the action fingerprint" - ); - } - } - #[test] fn generation_kind_catalog_normalizes_spec_onto_the_verified_icon_spec() { // 目录就是两条调用路径共同的可生成集合,必须逐字固定。 @@ -8145,9 +8118,12 @@ mod canvas_generation_tests { let second = tokio::time::timeout( Duration::from_secs(2), crate::assets::with_external_editor_api_credentials(second_credentials, async move { + // 同一个精确动作(同一 prompt + 同一 options):必须命中同一个 durable 输出槽, + // 在任何远端 POST 前被拒;不同动作可以同时在途,见 + // `concurrent_distinct_standalone_generations_both_succeed_with_one_post_each`。 generate_platform_art_asset_with_options_at( &second_root, - "不同的第二个手工请求", + "第一个手工请求", &[], &options, ) @@ -8326,6 +8302,688 @@ mod canvas_generation_tests { ); } + #[test] + fn standalone_generation_binds_each_exact_request_to_its_own_stable_slot() { + let options = PlatformArtAssetGenerationOptions { + 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_label: "手工背景".to_string(), + replace_existing: true, + slice_count: None, + }; + let ordinary = + standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) + .expect("ordinary standalone context"); + let repeated = + standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) + .expect("repeat standalone context"); + let strict = + standalone_platform_art_generation_runtime_context("完整生成提示词", &options, true) + .expect("strict standalone context"); + assert_eq!(ordinary, repeated); + assert_ne!(ordinary.run_id, strict.run_id); + assert_eq!(ordinary.source, "tauri-command"); + assert_eq!(strict.source, "tauri-command"); + assert_eq!( + ordinary.task_id, + format!("{}:{}", ordinary.agent_id, ordinary.run_id) + ); + assert_eq!(ordinary.task_id, ordinary.session_id); + assert_eq!(ordinary.task_id, ordinary.action_id); + // 槽身份就是这条精确动作的稳定唯一身份,不再是"按输出路径合并"的共享槽。 + assert_eq!( + ordinary.run_id, + format!("slot-{}", ordinary.action_fingerprint), + "durable 输出槽身份必须等于该精确动作的身份" + ); + + // 不同提示词是两条不同动作:必须各自独立成槽,才能在同一项目里同时在途。 + let different_prompt = + standalone_platform_art_generation_runtime_context("另一个生成提示词", &options, false) + .expect("different prompt context"); + assert_ne!( + ordinary.run_id, different_prompt.run_id, + "不同 prompt 不得共用同一个 durable 输出槽" + ); + assert_ne!( + ordinary.action_fingerprint, + different_prompt.action_fingerprint + ); + + // 同一条动作重复构造必须逐字节稳定(幂等复用 operationId 的前提)。 + assert_eq!( + different_prompt, + standalone_platform_art_generation_runtime_context("另一个生成提示词", &options, false) + .expect("repeat different prompt context") + ); + + let mut changed_options = Vec::new(); + let mut changed = options.clone(); + changed.output_path = Some("assets/another-art.png".to_string()); + changed_options.push(changed); + let mut changed = options.clone(); + changed.aspect_ratio = "1:1".to_string(); + changed_options.push(changed); + let mut changed = options.clone(); + changed.image_size = "1K".to_string(); + changed_options.push(changed); + let mut changed = options.clone(); + changed.asset_kind = "ui-prototype".to_string(); + changed_options.push(changed); + let mut changed = options.clone(); + changed.asset_label = "另一个标签".to_string(); + changed_options.push(changed); + let mut changed = options.clone(); + changed.replace_existing = false; + changed_options.push(changed); + for changed in changed_options { + let context = standalone_platform_art_generation_runtime_context( + "完整生成提示词", + &changed, + false, + ) + .expect("changed option context"); + assert_ne!( + ordinary.action_fingerprint, context.action_fingerprint, + "every option field must participate in the action fingerprint" + ); + assert_ne!( + ordinary.run_id, context.run_id, + "every option field must also move the durable output slot" + ); + } + + // 旧槽公式仍可重现:它只用于定位升级前遗留的账本,必须与新身份区分开。 + let legacy = legacy_standalone_platform_art_generation_run_id(&options, false) + .expect("legacy output slot"); + assert_eq!( + legacy, + legacy_standalone_platform_art_generation_run_id(&options, false) + .expect("repeat legacy output slot") + ); + assert_ne!(legacy, ordinary.run_id); + assert_ne!( + legacy, + legacy_standalone_platform_art_generation_run_id(&options, true) + .expect("strict legacy output slot") + ); + } + + #[test] + fn distinct_standalone_actions_hold_independent_durable_output_slots() { + let temporary = tempfile::tempdir().expect("create independent slot project"); + let root = temporary.path(); + init_local_game_project_at(root, "manual-independent-slots", "独立输出槽测试") + .expect("init independent slot project"); + let options = PlatformArtAssetGenerationOptions { + output_path: None, + asset_kind: "icon-spec".to_string(), + asset_label: "图标规范".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }; + let first_prompt = build_platform_art_asset_prompt("第一个手工请求", &[], &options); + let second_prompt = build_platform_art_asset_prompt("第二个手工请求", &[], &options); + let first = + standalone_platform_art_generation_runtime_context(&first_prompt, &options, false) + .expect("first standalone slot context"); + let second = + standalone_platform_art_generation_runtime_context(&second_prompt, &options, false) + .expect("second standalone slot context"); + assert_ne!(first.run_id, second.run_id); + + let first_guard = + super::external_generation_state::acquire_durable_platform_art_generation_guard( + root, &first, + ) + .expect("acquire first standalone slot") + .expect("standalone context requires a durable slot guard"); + let second_guard = + super::external_generation_state::acquire_durable_platform_art_generation_guard( + root, &second, + ) + .expect("a different exact action must not conflict with an in-flight slot") + .expect("standalone context requires a durable slot guard"); + let repeated_same_action = + super::external_generation_state::acquire_durable_platform_art_generation_guard( + root, &first, + ) + .err() + .expect("the same exact action must still conflict before any remote POST"); + assert!( + repeated_same_action.contains("任何远端 POST 前拒绝并发请求"), + "{repeated_same_action}" + ); + drop(first_guard); + drop(second_guard); + assert!( + super::external_generation_state::acquire_durable_platform_art_generation_guard( + root, &first, + ) + .expect("reacquire released standalone slot") + .is_some() + ); + } + + /// 并发夹具:两条 POST 必须同时到达才放行,用来证明两条不同动作真的同时在途。 + #[derive(Default)] + struct ConcurrentPostBarrier { + arrived: std::sync::Mutex, + released: std::sync::Condvar, + both_in_flight: std::sync::atomic::AtomicBool, + } + + impl ConcurrentPostBarrier { + fn arrive(&self) { + let mut arrived = self.arrived.lock().expect("lock concurrent POST arrivals"); + *arrived += 1; + if *arrived >= 2 { + self.both_in_flight + .store(true, std::sync::atomic::Ordering::SeqCst); + self.released.notify_all(); + return; + } + let (arrived, _) = self + .released + .wait_timeout(arrived, Duration::from_secs(10)) + .expect("wait for the second concurrent POST"); + assert!( + *arrived >= 2, + "两条不同动作的 POST 必须同时在途,否则第一条已经串行等待了第二条" + ); + } + + fn both_in_flight(&self) -> bool { + self.both_in_flight + .load(std::sync::atomic::Ordering::SeqCst) + } + } + + fn serve_concurrent_standalone_generation_request( + stream: &mut std::net::TcpStream, + base_url: &str, + barrier: &ConcurrentPostBarrier, + post_index: &std::sync::atomic::AtomicUsize, + posts: &std::sync::Mutex>, + ) { + let request = read_test_http_request(stream); + if request.starts_with("POST /api/external/v1/editor/images/generations ") { + let index = post_index.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + posts + .lock() + .expect("record concurrent generation posts") + .push(request.clone()); + barrier.arrive(); + write_test_json_response( + stream, + "202 Accepted", + &serde_json::json!({ + "data": { + "operationId": format!("concurrent-operation-{index}"), + "status": "queued", + "pollAfterMs": 250, + } + }), + ); + return; + } + if let Some(operation_id) = request + .split_whitespace() + .nth(1) + .and_then(|path| path.strip_prefix("/api/external/v1/generations/")) + .map(str::to_string) + { + write_test_json_response( + stream, + "200 OK", + &serde_json::json!({ + "data": { + "operationId": operation_id, + "status": "completed", + "pollAfterMs": 0, + "result": { + "resource": { + "resourceId": format!("resource-{operation_id}"), + // durable 账本只允许相对媒体路径或 objectKey, + // 下载再经 read-url 换签,与真实平台链路一致。 + "objectKey": format!("manual-concurrent-{operation_id}.png"), + } + } + } + }), + ); + return; + } + if request.starts_with("GET /api/external/v1/assets/read-url?") { + let object_key = request + .split_whitespace() + .nth(1) + .and_then(|path| path.split("objectKey=").nth(1)) + .map(str::to_string) + .expect("read-url fixture must carry an objectKey"); + write_test_json_response( + stream, + "200 OK", + &serde_json::json!({ + "read": {"signedUrl": format!("{base_url}/{object_key}")} + }), + ); + return; + } + if request.starts_with("GET /manual-concurrent-concurrent-operation-") { + let png = rgba_test_png(u8::MAX).bytes; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + png.len() + ) + .expect("write concurrent generation png headers"); + stream + .write_all(&png) + .expect("write concurrent generation png body"); + return; + } + if request.starts_with("GET /api/external/v1/editor/projects ") { + // 项目绑定已由 `install_test_external_project_binding` 预置,远端只需回认同一组身份。 + write_test_json_response( + stream, + "200 OK", + &serde_json::json!({ + "data": {"projects": [{ + "projectId": "manual-test-canvas", + "title": "并发手工生成画布", + }]} + }), + ); + return; + } + if request.starts_with("GET /api/external/v1/editor/assets/library ") { + write_test_json_response( + stream, + "200 OK", + &serde_json::json!({ + "data": {"library": {"folders": [{ + "folderId": "manual-test-assets", + "label": "并发手工生成素材", + }]}} + }), + ); + return; + } + panic!("unexpected concurrent standalone generation fixture request: {request}"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_distinct_standalone_generations_both_succeed_with_one_post_each() { + let temporary = tempfile::tempdir().expect("create concurrent generation project"); + let root = temporary.path(); + init_local_game_project_at(root, "manual-concurrent-generations", "并发手工生成测试") + .expect("init concurrent generation project"); + write_project_permission_policy_at( + root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow concurrent standalone generation"); + + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind concurrent generation fixture"); + listener + .set_nonblocking(true) + .expect("set concurrent generation fixture nonblocking"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let api_key = "manual-concurrent-key"; + install_test_external_project_binding(root, &base_url, api_key); + + let barrier = std::sync::Arc::new(ConcurrentPostBarrier::default()); + let post_index = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let posts = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let server_base_url = base_url.clone(); + let server_barrier = barrier.clone(); + let server_post_index = post_index.clone(); + let server_posts = posts.clone(); + let server_stop = stop.clone(); + let server = std::thread::spawn(move || { + let mut handlers = Vec::new(); + loop { + if server_stop.load(std::sync::atomic::Ordering::SeqCst) { + break; + } + match listener.accept() { + Ok((mut stream, _)) => { + let base_url = server_base_url.clone(); + let barrier = server_barrier.clone(); + let post_index = server_post_index.clone(); + let posts = server_posts.clone(); + handlers.push(std::thread::spawn(move || { + serve_concurrent_standalone_generation_request( + &mut stream, + &base_url, + &barrier, + &post_index, + &posts, + ); + })); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + } + Err(error) => { + panic!("accept concurrent standalone generation request: {error}") + } + } + } + for handler in handlers { + handler.join().expect("join concurrent fixture handler"); + } + }); + + // 两条动作都用「自动输出」(outputPath 为空),这正是工具栏除首次图标规范外的常态: + // 升级前它们共用同一个 (automatic-output) 槽,因此第二条必然被拒;升级后各自成槽。 + let first_options = PlatformArtAssetGenerationOptions { + output_path: None, + asset_kind: "icon-spec".to_string(), + asset_label: "并发素材一".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }; + let second_options = PlatformArtAssetGenerationOptions { + output_path: None, + asset_kind: "icon-spec".to_string(), + asset_label: "并发素材二".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }; + let first_credentials = crate::assets::external_editor_api_credentials_for_test( + base_url.clone(), + api_key.to_string(), + ); + let second_credentials = crate::assets::external_editor_api_credentials_for_test( + base_url.clone(), + api_key.to_string(), + ); + let first_root = root.to_path_buf(); + let second_root = root.to_path_buf(); + let first = tokio::spawn(async move { + crate::assets::with_external_editor_api_credentials(first_credentials, async move { + generate_platform_art_asset_with_options_at( + &first_root, + "第一个并发手工请求", + &[], + &first_options, + ) + .await + }) + .await + }); + let second = tokio::spawn(async move { + crate::assets::with_external_editor_api_credentials(second_credentials, async move { + generate_platform_art_asset_with_options_at( + &second_root, + "第二个并发手工请求", + &[], + &second_options, + ) + .await + }) + .await + }); + let first = tokio::time::timeout(Duration::from_secs(30), first) + .await + .expect("first concurrent generation must finish") + .expect("join first concurrent generation"); + let second = tokio::time::timeout(Duration::from_secs(30), second) + .await + .expect("second concurrent generation must finish") + .expect("join second concurrent generation"); + let first = first.expect("first standalone generation must succeed"); + let second = second.expect("second standalone generation must succeed"); + + assert!( + barrier.both_in_flight(), + "两条不同手工生成必须同时在途,两条 POST 必须同时到达远端" + ); + assert_eq!( + post_index.load(std::sync::atomic::Ordering::SeqCst), + 2, + "每条精确动作只允许一次远端 POST" + ); + let posts = posts.lock().expect("read concurrent generation posts"); + assert_eq!(posts.len(), 2); + assert!( + posts.iter().any(|post| post.contains("第一个并发手工请求")), + "{posts:#?}" + ); + assert!( + posts.iter().any(|post| post.contains("第二个并发手工请求")), + "{posts:#?}" + ); + drop(posts); + assert_ne!(first.resource_id, second.resource_id); + let installed = std::fs::read_dir(root.join("assets/canvas-generated")) + .expect("read concurrent generation output directory") + .map(|entry| { + entry + .expect("read concurrent generation output entry") + .file_name() + .to_string_lossy() + .to_string() + }) + .collect::>(); + assert_eq!( + installed.len(), + 2, + "两条并发生成必须各自落地一个输出文件:{installed:#?}" + ); + for resource_id in [&first.resource_id, &second.resource_id] { + let resource_id = resource_id + .as_deref() + .expect("fixture returns a resourceId"); + assert!( + installed + .iter() + .any(|file_name| file_name.contains(resource_id)), + "并发生成结果必须按自己的 resourceId 落盘:{resource_id}: {installed:#?}" + ); + } + stop.store(true, std::sync::atomic::Ordering::SeqCst); + server.join().expect("join concurrent fixture"); + } + + #[test] + fn legacy_output_slot_ledger_is_adopted_by_the_same_exact_action_only() { + let temporary = tempfile::tempdir().expect("create legacy slot project"); + let root = temporary.path(); + init_local_game_project_at(root, "manual-legacy-slot", "旧输出槽兼容测试") + .expect("init legacy slot project"); + let options = PlatformArtAssetGenerationOptions { + output_path: None, + asset_kind: "icon-spec".to_string(), + asset_label: "图标规范".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }; + let prompt = build_platform_art_asset_prompt("旧槽兼容手工请求", &[], &options); + let context = standalone_platform_art_generation_runtime_context(&prompt, &options, false) + .expect("current standalone context"); + let legacy_run_id = legacy_standalone_platform_art_generation_run_id(&options, false) + .expect("legacy standalone run id"); + assert_ne!(legacy_run_id, context.run_id); + let legacy_identity = format!("{}:{legacy_run_id}", context.agent_id); + let legacy_context = PlatformArtGenerationRuntimeContext { + task_id: legacy_identity.clone(), + session_id: legacy_identity.clone(), + run_id: legacy_run_id.clone(), + action_id: legacy_identity, + ..context.clone() + }; + assert!( + super::external_generation_state::is_standalone_platform_art_generation_runtime_context( + &legacy_context + ), + "升级前的 slot- 槽身份必须继续被接受,不得 panic 或直接失败关闭" + ); + let access = ExternalEditorBindingAccess::for_developer( + "https://editor.example.test", + "legacy-slot-key", + ) + .expect("create legacy slot binding access"); + let (legacy_state, created) = prepare_platform_art_generation_runtime_state( + root, + &legacy_context, + "/api/external/v1/editor/images/generations", + "manual-test-canvas", + &prompt, + &serde_json::json!({ + "prompt": prompt, + "kind": "spec", + "projectId": "manual-test-canvas", + "assetFolderId": "manual-test-assets", + "referenceImageSrcs": [] + }), + &access, + ) + .expect("prepare legacy slot ledger"); + assert!(created); + let legacy_state = mark_platform_art_generation_runtime_accepted( + root, + legacy_state, + "legacy-slot-operation", + 1_500, + ) + .expect("mark legacy slot operation accepted"); + let legacy_idempotency_key = + platform_art_generation_runtime_idempotency_key(&legacy_state).to_string(); + assert_eq!( + read_platform_art_generation_runtime_state(root, &legacy_context) + .expect("旧格式账本必须仍然可读") + .as_ref() + .and_then( + super::external_generation_state::platform_art_generation_runtime_operation_id_for_test + ), + Some("legacy-slot-operation") + ); + + assert!( + super::external_generation_state::adopt_legacy_standalone_platform_art_generation_runtime_state_at( + root, + &context, + &legacy_run_id, + ) + .expect("adopt legacy slot ledger"), + "同一精确动作的旧槽账本必须被迁移到新身份路径" + ); + let migrated = read_platform_art_generation_runtime_state(root, &context) + .expect("read migrated ledger") + .expect("migrated ledger exists"); + assert_eq!( + super::external_generation_state::platform_art_generation_runtime_operation_id_for_test( + &migrated + ), + Some("legacy-slot-operation") + ); + assert_eq!( + platform_art_generation_runtime_idempotency_key(&migrated), + legacy_idempotency_key + ); + assert_eq!( + super::external_generation_state::platform_art_generation_runtime_run_id_for_test( + &migrated + ), + context.run_id + ); + assert_eq!( + super::external_generation_state::platform_art_generation_runtime_action_fingerprint_for_test( + &migrated + ), + context.action_fingerprint + ); + assert!( + !game_creator_agent_runtime_external_generation_exists( + root, + &context.agent_id, + &legacy_run_id + ), + "迁移后旧路径上的账本必须已清理" + ); + assert!( + !super::external_generation_state::adopt_legacy_standalone_platform_art_generation_runtime_state_at( + root, + &context, + &legacy_run_id, + ) + .expect("second adoption is a no-op"), + "重复迁移必须是幂等空操作" + ); + + // 另一个精确动作的旧槽账本不得被本次动作消费、改写或删除。 + let other_options = PlatformArtAssetGenerationOptions { + output_path: None, + asset_kind: "icon-spec".to_string(), + asset_label: "另一个图标规范".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }; + let other_prompt = + build_platform_art_asset_prompt("另一个旧槽手工请求", &[], &other_options); + let other_context = standalone_platform_art_generation_runtime_context( + &other_prompt, + &other_options, + false, + ) + .expect("other standalone context"); + let other_legacy_run_id = + legacy_standalone_platform_art_generation_run_id(&other_options, false) + .expect("other legacy standalone run id"); + let other_legacy_identity = format!("{}:{other_legacy_run_id}", other_context.agent_id); + let other_legacy_context = PlatformArtGenerationRuntimeContext { + task_id: other_legacy_identity.clone(), + session_id: other_legacy_identity.clone(), + run_id: other_legacy_run_id.clone(), + action_id: other_legacy_identity, + ..other_context.clone() + }; + prepare_platform_art_generation_runtime_state( + root, + &other_legacy_context, + "/api/external/v1/editor/images/generations", + "manual-test-canvas", + &other_prompt, + &serde_json::json!({ + "prompt": other_prompt, + "kind": "spec", + "projectId": "manual-test-canvas", + "assetFolderId": "manual-test-assets", + "referenceImageSrcs": [] + }), + &access, + ) + .expect("prepare other legacy slot ledger"); + assert!( + !super::external_generation_state::adopt_legacy_standalone_platform_art_generation_runtime_state_at( + root, + &context, + &other_legacy_run_id, + ) + .expect("other action legacy slot is not adopted"), + "属于其他精确动作的旧槽账本不得被本次动作迁移" + ); + assert!( + game_creator_agent_runtime_external_generation_exists( + root, + &other_context.agent_id, + &other_legacy_run_id + ), + "属于其他精确动作的旧槽账本必须原样保留" + ); + assert!( + read_platform_art_generation_runtime_state(root, &other_legacy_context) + .expect("other legacy ledger stays readable") + .is_some() + ); + } + #[tokio::test] async fn external_canvas_context_is_scoped_by_account_and_local_project_id() { let temporary = tempfile::tempdir().expect("create account binding project"); 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 a6075cda0..03b0dcac1 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 @@ -1074,6 +1074,102 @@ pub(in crate::agent) fn remove_platform_art_generation_runtime_state_at( } } +/// 一次性兼容:升级前 standalone 槽身份只由 `{outputPath, requireSlices}` 派生, +/// 同一项目所有图片类生成共用一个槽;升级后槽身份按精确动作派生,路径随之变化。 +/// +/// 若旧槽路径上的账本仍然属于本次精确动作(`agentId` 与 `actionFingerprint` 都与 +/// 当前上下文一致),就在项目写锁内把它迁移到新身份路径:保留原 `idempotencyKey` +/// 与 `operationId`,避免同一精确动作在升级后二次 POST 计费。旧账本属于其他动作时 +/// 原样保留(不迁移、不删除、不阻塞),由对应动作自己的请求迁移。 +/// +/// 返回 `Ok(false)` 表示没有需要迁移的旧账本。任何身份无法安全解释的情形都失败关闭。 +pub(super) fn adopt_legacy_standalone_platform_art_generation_runtime_state_at( + root: &Path, + context: &PlatformArtGenerationRuntimeContext, + legacy_run_id: &str, +) -> Result { + if !is_standalone_platform_art_generation_runtime_context(context) + || legacy_run_id == context.run_id + || !is_lowercase_sha256(legacy_run_id.strip_prefix("slot-").unwrap_or_default()) + { + return Ok(false); + } + // 与账本创建互斥:迁移必须在同一把项目写锁内完成,否则两个调用可能同时把同一份 + // 旧账本迁移到新路径,或与新建账本互相覆盖。 + let _claim_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "canvas.asset_generate.runtime.claim", + )?; + if game_creator_agent_runtime_external_generation_exists( + root, + &context.agent_id, + &context.run_id, + ) { + // 新身份账本已经存在:旧账本不属于本次动作的权威状态,保持两边各自的身份。 + return Ok(false); + } + let legacy_relative_path = + platform_art_generation_runtime_relative_path(&context.agent_id, legacy_run_id); + let Some(legacy_state) = + read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &legacy_relative_path, + "External Editor 生成账本", + PLATFORM_ART_GENERATION_RUNTIME_MAX_BYTES, + )? + else { + return Ok(false); + }; + if legacy_state.agent_id != context.agent_id + || legacy_state.run_id != legacy_run_id + || legacy_state.action_fingerprint != context.action_fingerprint + { + // 旧槽里是另一个精确动作的账本:它仍归那个动作所有,本次调用不得消费、改写或删除它。 + return Ok(false); + } + let legacy_identity = format!("{}:{legacy_run_id}", context.agent_id); + let legacy_context = PlatformArtGenerationRuntimeContext { + task_id: legacy_identity.clone(), + session_id: legacy_identity.clone(), + run_id: legacy_run_id.to_string(), + action_id: legacy_identity, + ..context.clone() + }; + let Some(mut migrated) = read_platform_art_generation_runtime_state(root, &legacy_context)? + else { + return Ok(false); + }; + migrated.run_id = context.run_id.clone(); + migrated.task_id = context.task_id.clone(); + migrated.session_id = context.session_id.clone(); + migrated.action_id = context.action_id.clone(); + migrated.updated_at = unix_timestamp(); + write_platform_art_generation_runtime_state(root, &migrated)?; + remove_platform_art_generation_runtime_state_at(root, &context.agent_id, legacy_run_id)?; + Ok(true) +} + +#[cfg(test)] +pub(super) fn platform_art_generation_runtime_operation_id_for_test( + state: &PlatformArtGenerationRuntimeState, +) -> Option<&str> { + state.operation_id.as_deref() +} + +#[cfg(test)] +pub(super) fn platform_art_generation_runtime_run_id_for_test( + state: &PlatformArtGenerationRuntimeState, +) -> &str { + &state.run_id +} + +#[cfg(test)] +pub(super) fn platform_art_generation_runtime_action_fingerprint_for_test( + state: &PlatformArtGenerationRuntimeState, +) -> &str { + &state.action_fingerprint +} + #[cfg(test)] pub(crate) fn write_platform_art_generation_runtime_accepted_for_test( root: &Path, 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 new file mode 100644 index 000000000..91a9eae42 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -0,0 +1,755 @@ +//! 栏目画布「图片类素材生成」的**后台任务账本**。 +//! +//! 背景:`generate_local_project_asset` 一次调用最长要等 35 分钟,面板必须能在提交后立刻关闭, +//! 任务本身继续在后台跑完并把结果写回项目。所以生成不再由「一次 IPC 同步等待」承载,而是: +//! +//! 1. `start_local_project_asset_generation` 校验入参、落一条 `queued` 记录、立刻返回记录; +//! 2. 真正生成在 `tauri::async_runtime::spawn` 出来的后台任务里跑,复用既有 +//! `generate_platform_art_asset_with_options_at`(幂等账本、计费、manifest 登记、本地预览 +//! 全部还是那一条通道,这里不复制任何生成逻辑); +//! 3. `list_local_project_asset_generations` 读回账本,前端轮询它拿状态与阶段文案。 +//! +//! **阶段文案由本模块拥有**(`phase_detail`):前端只渲染后端给的字符串,不自己造百分比或 +//! 假阶段。这也是「进度可见」这条验收判据的落点。 +//! +//! 账本落在**项目内** `.agent/runtime/asset-generation-tasks/tasks.json`(复用既有 agent runtime +//! sidecar 读写原语:临时文件 + rename 替换),所以重开项目后仍能看到历史任务。进程重启时 +//! 还在 `queued` / `running` 的记录不可能再有人推进,读账本时按「上次运行中断」收口;收口结论 +//! **要跟 manifest 交叉核对**:能在清单里找到这次请求的目标素材就按已完成收口(生成通道是先写 +//! manifest 再返回的,所以「素材已登记、账本还停在 running」的窗口里崩溃是真会发生的),核不了 +//! 就不把话说死,不假装它还在跑、也不谎报「生成未完成」。 + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::agent::{ + generate_platform_art_asset_with_options_at, read_agent_runtime_json_sidecar_with_max_bytes, + write_agent_runtime_json_sidecar_with_max_bytes, PlatformArtAssetGenerationOptions, +}; +use crate::commands::prepare_local_project_asset_generation; +use crate::project::{enforce_project_permission_policy, read_existing_manifest_for_project}; + +pub(crate) const ASSET_GENERATION_TASK_SCHEMA_VERSION: &str = "agc-asset-generation-task.v1"; +pub(crate) const ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH: &str = + ".agent/runtime/asset-generation-tasks/tasks.json"; +pub(crate) const ASSET_GENERATION_TASK_LEDGER_MAX_BYTES: usize = 1024 * 1024; +/// 账本保留的记录上限:只留最近的任务,旧记录按时间淘汰,避免账本无限增长。 +pub(crate) const ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS: usize = 50; +pub(crate) const ASSET_GENERATION_TASK_ID_MAX_CHARS: usize = 128; + +pub(crate) const ASSET_GENERATION_TASK_STATUS_QUEUED: &str = "queued"; +pub(crate) const ASSET_GENERATION_TASK_STATUS_RUNNING: &str = "running"; +pub(crate) const ASSET_GENERATION_TASK_STATUS_COMPLETED: &str = "completed"; +pub(crate) const ASSET_GENERATION_TASK_STATUS_FAILED: &str = "failed"; + +const ASSET_GENERATION_TASK_PHASE_QUEUED: &str = "排队中。"; +const ASSET_GENERATION_TASK_PHASE_RUNNING: &str = "正在生成。"; +const ASSET_GENERATION_TASK_PHASE_COMPLETED: &str = "生成已完成。"; +/// 中断收口:清单里已经能查到这次请求的目标素材 → 按事实收口为已完成。 +const ASSET_GENERATION_TASK_PHASE_INTERRUPTED_SETTLED: &str = + "上次运行中断,但目标素材已登记(已完成)。"; +/// 中断收口:请求指定了精确落点,而清单里没有该素材 → 这次写入确实没落地。 +const ASSET_GENERATION_TASK_PHASE_INTERRUPTED_INCOMPLETE: &str = + "上次运行中断,目标素材未登记,生成未完成。"; +/// 中断收口:没有可核对的目标标识(或清单读不到)→ 不下结论。 +const ASSET_GENERATION_TASK_PHASE_INTERRUPTED_UNKNOWN: &str = + "上次运行中断,状态未知(可能已完成)。"; +const ASSET_GENERATION_TASK_INTERRUPTED_SETTLED_ERROR: &str = + "应用退出时生成任务仍在进行,已按清单确认目标素材登记"; +const ASSET_GENERATION_TASK_INTERRUPTED_INCOMPLETE_ERROR: &str = + "应用退出时生成任务仍在进行,目标素材未登记"; +const ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR: &str = + "应用退出时生成任务仍在进行,未能在清单里确认结果"; + +/// 一条生成任务的权威记录。字段名与前端一一对应(camelCase)。 +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AssetGenerationTaskRecord { + pub(crate) task_id: String, + pub(crate) project_id: String, + pub(crate) kind: String, + pub(crate) asset_name: String, + pub(crate) status: String, + /// 阶段文案:**由后端拥有**,前端只渲染。 + pub(crate) phase_detail: String, + /// 这次请求指定的精确落点(`outputPath`)。 + /// + /// 它是中断收口时**唯一可核对的目标标识**:有它就能拿 manifest 的 `localPath` 做精确匹配, + /// 判出「素材已登记 → 已完成」还是「落点没有素材 → 未落地」。没有它(绝大多数入口不指定 + /// 落点)就只能报「状态未知」。旧账本没有这个字段,按 `None` 读。 + #[serde(default)] + pub(crate) output_path: Option, + pub(crate) created_at_millis: u64, + pub(crate) started_at_millis: Option, + pub(crate) finished_at_millis: Option, + /// 生成成功时的 manifest 资源 id,前端据此定位新卡。 + pub(crate) asset_id: Option, + pub(crate) error: Option, +} + +impl AssetGenerationTaskRecord { + fn is_terminal(&self) -> bool { + matches!( + self.status.as_str(), + ASSET_GENERATION_TASK_STATUS_COMPLETED | ASSET_GENERATION_TASK_STATUS_FAILED + ) + } +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct AssetGenerationTaskLedger { + #[serde(default)] + schema_version: String, + #[serde(default)] + tasks: Vec, +} + +/// 账本读写与「本进程还活着哪些任务」共用一把锁:一次读-改-写必须是原子的,否则两个后台 +/// 任务同时收尾会互相覆盖。 +static ASSET_GENERATION_TASK_LOCK: Mutex<()> = Mutex::new(()); +static ASSET_GENERATION_TASK_LIVE_IDS: OnceLock>> = OnceLock::new(); + +fn live_task_ids() -> &'static Mutex> { + ASSET_GENERATION_TASK_LIVE_IDS.get_or_init(|| Mutex::new(BTreeSet::new())) +} + +fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or_default() +} + +fn lock_ledger() -> Result, String> { + ASSET_GENERATION_TASK_LOCK + .lock() + .map_err(|_| "生成任务账本锁已损坏".to_string()) +} + +fn read_ledger(root: &Path) -> Result, String> { + let ledger = read_agent_runtime_json_sidecar_with_max_bytes::( + root, + ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH, + "生成任务账本", + ASSET_GENERATION_TASK_LEDGER_MAX_BYTES, + )?; + Ok(ledger.map(|ledger| ledger.tasks).unwrap_or_default()) +} + +fn write_ledger(root: &Path, tasks: &[AssetGenerationTaskRecord]) -> Result<(), String> { + let ledger = AssetGenerationTaskLedger { + schema_version: ASSET_GENERATION_TASK_SCHEMA_VERSION.to_string(), + tasks: tasks.to_vec(), + }; + write_agent_runtime_json_sidecar_with_max_bytes( + root, + ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH, + "生成任务账本", + &ledger, + ASSET_GENERATION_TASK_LEDGER_MAX_BYTES, + ) +} + +/// 只保留最近 `ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS` 条:按创建时间排序取尾部。 +fn trim_ledger(tasks: &mut Vec) { + if tasks.len() <= ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS { + return; + } + tasks.sort_by(|left, right| { + left.created_at_millis + .cmp(&right.created_at_millis) + .then_with(|| left.task_id.cmp(&right.task_id)) + }); + let overflow = tasks.len() - ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS; + tasks.drain(0..overflow); +} + +/// manifest 的 `localPath` → 资产 id。 +/// +/// 只用来做**精确落点**的交叉核对,所以按同一个口径归一化两侧的路径串(去空白、反斜杠折成 +/// 正斜杠);不做模糊匹配、不按素材名猜,避免把另一次生成的产物算到这条任务上。 +fn registered_asset_ids_by_local_path(root: &Path) -> Option> { + let manifest = read_existing_manifest_for_project(root).ok()?; + Some( + manifest + .assets + .iter() + .map(|asset| (normalize_local_path(&asset.local_path), asset.id.clone())) + .collect(), + ) +} + +fn normalize_local_path(path: &str) -> String { + path.trim().replace('\\', "/") +} + +/// 中断收口用到的 manifest 交叉核对口径。 +/// +/// `None` = 这次读不到清单(项目还没初始化 / 读盘失败),此时任何记录都不能下结论。 +type RegisteredAssetIdsByLocalPath = Option>; + +/// 进程重启后把没人推进的记录收口,并把这次修复写回账本。 +/// +/// 判据是「本进程的 live 集合里没有它」:本进程派发的任务在 `start` 里先登记 live 再落账本, +/// 所以账本里非终态且不 live 的记录只可能来自上一次运行。 +/// +/// **收口结论要跟清单核对**:生成通道是先写 manifest 再返回的,所以「素材已经登记、账本还停在 +/// `running`」的窗口里崩溃是真会发生的;只看 live 集合会把这种任务谎报成「生成未完成」。三种结论: +/// +/// - 请求指定了精确落点、且清单里已有该落点 → 按事实收口为**已完成**(带上 assetId); +/// - 请求指定了精确落点、清单里没有 → 这次写入确实没落地,收口为失败并说明「目标素材未登记」; +/// - 没有精确落点(或清单读不到)→ 收口为失败但**不下结论**,文案是「状态未知(可能已完成)」。 +fn repair_interrupted_tasks( + tasks: &mut [AssetGenerationTaskRecord], + registered: &RegisteredAssetIdsByLocalPath, +) -> bool { + let live = live_task_ids() + .lock() + .map(|ids| ids.clone()) + .unwrap_or_default(); + let mut repaired = false; + for task in tasks.iter_mut() { + if task.is_terminal() || live.contains(&task.task_id) { + continue; + } + let registered_asset = task + .output_path + .as_deref() + .map(normalize_local_path) + .filter(|path| !path.is_empty()) + .and_then(|path| registered.as_ref().map(|assets| assets.get(&path).cloned())); + match registered_asset { + Some(Some(asset_id)) => { + task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_INTERRUPTED_SETTLED.to_string(); + task.asset_id = Some(asset_id); + task.error = Some(ASSET_GENERATION_TASK_INTERRUPTED_SETTLED_ERROR.to_string()); + } + Some(None) => { + task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_INTERRUPTED_INCOMPLETE.to_string(); + task.error = Some(ASSET_GENERATION_TASK_INTERRUPTED_INCOMPLETE_ERROR.to_string()); + } + None => { + task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_INTERRUPTED_UNKNOWN.to_string(); + task.error = Some(ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR.to_string()); + } + } + task.finished_at_millis = Some(now_millis()); + repaired = true; + } + repaired +} + +/// 读账本:顺带把「上次运行中断」的任务收口(能核对的按事实收口,核不了的只报状态未知)。 +pub(crate) fn list_local_project_asset_generation_tasks( + root: &Path, +) -> Result, String> { + let _guard = lock_ledger()?; + let mut tasks = read_ledger(root)?; + let registered = registered_asset_ids_by_local_path(root); + if repair_interrupted_tasks(&mut tasks, ®istered) { + write_ledger(root, &tasks)?; + } + Ok(tasks) +} + +fn update_task( + root: &Path, + task_id: &str, + mutate: F, +) -> Result +where + F: FnOnce(&mut AssetGenerationTaskRecord), +{ + let _guard = lock_ledger()?; + let mut tasks = read_ledger(root)?; + let record = tasks + .iter_mut() + .find(|task| task.task_id == task_id) + .ok_or_else(|| format!("生成任务不存在:{task_id}"))?; + mutate(record); + let snapshot = record.clone(); + write_ledger(root, &tasks)?; + Ok(snapshot) +} + +/// 校验前端给的本地任务 id:它同时是幂等身份,必须是非空单行短字符串。 +fn asset_generation_task_id(task_id: &str) -> Result { + let task_id = task_id.trim(); + if task_id.is_empty() { + return Err("生成任务 id 不能为空".to_string()); + } + if task_id.chars().count() > ASSET_GENERATION_TASK_ID_MAX_CHARS + || task_id.chars().any(char::is_control) + { + return Err("生成任务 id 超出安全边界".to_string()); + } + Ok(task_id.to_string()) +} + +/// 落一条排队记录。调用方保证 task id 在本进程内唯一。 +pub(crate) fn begin_local_project_asset_generation_task( + root: &Path, + project_id: &str, + task_id: &str, + task_kind: &str, + 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(), + asset_name: asset_name.to_string(), + status: ASSET_GENERATION_TASK_STATUS_QUEUED.to_string(), + phase_detail: ASSET_GENERATION_TASK_PHASE_QUEUED.to_string(), + output_path: output_path.map(str::to_string), + created_at_millis: now_millis(), + started_at_millis: None, + finished_at_millis: None, + asset_id: None, + error: None, + }; + let _guard = lock_ledger()?; + let mut tasks = read_ledger(root)?; + if let Some(existing) = tasks.iter().find(|task| task.task_id == record.task_id) { + if !existing.is_terminal() { + return Err(format!("生成任务 id 已在进行中:{}", record.task_id)); + } + } + tasks.retain(|task| task.task_id != record.task_id); + tasks.push(record.clone()); + trim_ledger(&mut tasks); + write_ledger(root, &tasks)?; + Ok(record) +} + +fn remove_live_task_id(task_id: &str) { + if let Ok(mut ids) = live_task_ids().lock() { + ids.remove(task_id); + } +} + +/// 后台执行:状态与阶段文案的每一次流转都由这里写账本。 +async fn run_local_project_asset_generation_task( + root: PathBuf, + task_id: String, + prompt: String, + options: PlatformArtAssetGenerationOptions, +) { + if update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string(); + task.started_at_millis = Some(now_millis()); + }) + .is_err() + { + remove_live_task_id(&task_id); + return; + } + let outcome = generate_platform_art_asset_with_options_at(&root, &prompt, &[], &options).await; + match outcome { + Ok(generated) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string(); + task.asset_id = Some(generated.asset.id.clone()); + task.finished_at_millis = Some(now_millis()); + task.error = None; + }); + } + Err(error) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); + task.phase_detail = format!("生成失败:{error}"); + task.error = Some(error.clone()); + task.finished_at_millis = Some(now_millis()); + }); + } + } + remove_live_task_id(&task_id); +} + +/// 提交即返回:校验入参 → 落排队记录 → 派发后台任务 → 返回记录。 +/// +/// 入参收口完全复用 `prepare_local_project_asset_generation`(与同步命令同一份白名单与边界), +/// 生成本身仍是 `generate_platform_art_asset_with_options_at`,本命令不复制任何生成逻辑。 +#[tauri::command] +pub(crate) async fn start_local_project_asset_generation( + project_path: String, + project_id: String, + task_id: String, + kind: String, + prompt: String, + aspect_ratio: Option, + image_size: Option, + asset_name: Option, + output_path: Option, +) -> Result { + let task_id = asset_generation_task_id(&task_id)?; + let request = prepare_local_project_asset_generation( + &project_path, + &kind, + &prompt, + aspect_ratio.as_deref(), + image_size.as_deref(), + asset_name.as_deref(), + output_path.as_deref(), + )?; + enforce_project_permission_policy(&request.root, "canvas.asset_generate")?; + enforce_project_permission_policy(&request.root, "asset.register")?; + let asset_label = request.options.asset_label.clone(); + let asset_kind = request.options.asset_kind.clone(); + let record = begin_local_project_asset_generation_task( + &request.root, + &project_id, + &task_id, + &asset_kind, + &asset_label, + request.options.output_path.as_deref(), + )?; + // 先登记 live 再派发:`list` 只把「非终态且不 live」的记录判为上次运行的残留。 + if let Ok(mut ids) = live_task_ids().lock() { + ids.insert(task_id.clone()); + } + let root = request.root.clone(); + tauri::async_runtime::spawn(run_local_project_asset_generation_task( + root, + task_id, + request.prompt, + request.options, + )); + Ok(record) +} + +/// 读回项目内账本(重开项目后仍能看到历史任务)。 +#[tauri::command] +pub(crate) fn list_local_project_asset_generations( + project_path: String, +) -> Result, String> { + let project_path = project_path.trim(); + if project_path.is_empty() { + return Err("项目路径不能为空".to_string()); + } + let root = Path::new(project_path); + // 与相邻的 manifest 读命令同口径:合法的读 command id 是 `asset.list`(`asset.read` 不在 + // 契约的 command 列表里,写进 `denied_commands` 也不可能命中 → 门禁恒不生效)。 + enforce_project_permission_policy(root, "asset.list")?; + list_local_project_asset_generation_tasks(root) +} + +#[cfg(test)] +mod asset_generation_task_tests { + use super::*; + use crate::assets::register_local_asset_at; + use crate::project::{ + init_local_game_project_at, read_manifest_for_project, write_project_permission_policy_at, + }; + use shared_contracts::game_creation_app::{ + GameCreationAppAssetSource, GameCreationAppAssetSourceKind, + }; + + fn temp_project_root(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_nanos()) + .unwrap_or_default(); + let root = std::env::temp_dir().join(format!( + "agc-asset-generation-tasks-{label}-{}-{nonce}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("temp project root"); + root + } + + /// 已初始化的项目:中断收口要读 manifest 做交叉核对,所以这几条用例必须有真清单。 + fn initialized_project_root(label: &str) -> PathBuf { + let root = temp_project_root(label); + init_local_game_project_at(&root, "project-1", "生成任务账本测试").expect("init project"); + root + } + + /// 登记一个素材到 manifest 并返回它的资产 id(中断收口按 `localPath` 精确匹配)。 + fn register_fixture_asset(root: &Path, local_path: &str) -> String { + let absolute_path = root.join(local_path); + if let Some(parent) = absolute_path.parent() { + std::fs::create_dir_all(parent).expect("create fixture asset parent"); + } + std::fs::write(&absolute_path, b"png-bytes").expect("write fixture asset"); + register_local_asset_at( + root, + local_path, + "icon-spec", + "image/png", + "generated", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: Some("fixture-generation".to_string()), + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) + .expect("register fixture asset"); + read_manifest_for_project(root) + .expect("read manifest after registration") + .assets + .iter() + .find(|asset| asset.local_path == local_path) + .expect("registered asset is present") + .id + .clone() + } + + fn begin(root: &Path, task_id: &str) -> AssetGenerationTaskRecord { + begin_local_project_asset_generation_task( + root, + "project-1", + task_id, + "image", + "AI 图", + None, + ) + .expect("begin task") + } + + fn begin_with_output( + root: &Path, + task_id: &str, + output_path: &str, + ) -> AssetGenerationTaskRecord { + begin_local_project_asset_generation_task( + root, + "project-1", + task_id, + "icon-spec", + "图标规范", + Some(output_path), + ) + .expect("begin task with output path") + } + + #[test] + fn started_task_is_queued_with_backend_owned_phase_detail() { + let root = temp_project_root("queued"); + let record = begin(&root, "task-queued"); + assert_eq!(record.status, ASSET_GENERATION_TASK_STATUS_QUEUED); + assert_eq!(record.phase_detail, ASSET_GENERATION_TASK_PHASE_QUEUED); + assert_eq!(record.project_id, "project-1"); + assert_eq!(record.kind, "image"); + assert_eq!(record.asset_name, "AI 图"); + assert!(record.started_at_millis.is_none()); + assert!(record.asset_id.is_none()); + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].task_id, "task-queued"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn ledger_lives_in_project_so_tasks_survive_a_reopen() { + let root = temp_project_root("durable"); + begin(&root, "task-durable"); + let ledger = root.join(ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH); + assert!(ledger.is_file(), "账本必须落在项目内的相对路径上"); + let reopened = list_local_project_asset_generation_tasks(&root).expect("reopen"); + assert_eq!(reopened.len(), 1); + assert_eq!(reopened[0].task_id, "task-durable"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn restarting_the_process_reports_unknown_state_when_the_result_cannot_be_checked() { + // 项目没初始化 → 读不到 manifest → 没有可核对的目标标识:不许断言「生成未完成」。 + let root = temp_project_root("interrupted-unknown"); + begin(&root, "task-interrupted"); + // 模拟「上一条进程留下的非终态记录」:live 集合里没有它(本测试进程从未 start 过它)。 + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_FAILED); + assert_eq!( + listed[0].phase_detail, + ASSET_GENERATION_TASK_PHASE_INTERRUPTED_UNKNOWN + ); + assert_eq!( + listed[0].error.as_deref(), + Some(ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR) + ); + assert!(listed[0].finished_at_millis.is_some()); + // 修复要写回账本,第二次读到的仍是同一条终态记录。 + let again = list_local_project_asset_generation_tasks(&root).expect("list again"); + assert_eq!(again[0].status, ASSET_GENERATION_TASK_STATUS_FAILED); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn interrupted_task_whose_output_is_already_registered_settles_as_completed() { + // 生成通道先写 manifest 再返回,所以「素材已登记、账本还停在 running」的窗口里崩溃是 + // 真会发生的:这种任务必须按事实收口为已完成,而不是谎报「生成未完成」。 + let root = initialized_project_root("interrupted-settled"); + let asset_id = register_fixture_asset(&root, "assets/art-spec.png"); + begin_with_output(&root, "task-settled", "assets/art-spec.png"); + + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_COMPLETED); + assert_eq!( + listed[0].phase_detail, + ASSET_GENERATION_TASK_PHASE_INTERRUPTED_SETTLED + ); + assert_eq!(listed[0].asset_id.as_deref(), Some(asset_id.as_str())); + assert!(listed[0].finished_at_millis.is_some()); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn interrupted_task_whose_output_was_never_registered_is_reported_as_incomplete() { + let root = initialized_project_root("interrupted-incomplete"); + begin_with_output(&root, "task-incomplete", "assets/never-written.png"); + + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_FAILED); + assert_eq!( + listed[0].phase_detail, + ASSET_GENERATION_TASK_PHASE_INTERRUPTED_INCOMPLETE + ); + assert_eq!( + listed[0].error.as_deref(), + Some(ASSET_GENERATION_TASK_INTERRUPTED_INCOMPLETE_ERROR) + ); + assert!(listed[0].asset_id.is_none()); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn interrupted_task_without_an_output_slot_never_claims_the_generation_failed() { + // 自动落点(绝大多数入口):清单里无法精确定位这次请求的产物 → 只报状态未知。 + let root = initialized_project_root("interrupted-no-output"); + register_fixture_asset(&root, "assets/art-spec.png"); + begin(&root, "task-no-output"); + + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_FAILED); + assert_eq!( + listed[0].phase_detail, + ASSET_GENERATION_TASK_PHASE_INTERRUPTED_UNKNOWN + ); + assert!(listed[0].asset_id.is_none()); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn permission_policy_can_deny_the_generation_task_ledger_read() { + // 读门禁必须用契约里真实存在的 command id(`asset.list`)。写成 `asset.read` 这种不在 + // 命令表里的名字时,`denied_commands` 永远不可能命中 → 门禁恒不生效。 + let root = initialized_project_root("denied-read"); + let mut policy = crate::ProjectPermissionPolicy::default(); + policy.denied_commands.push("asset.list".to_string()); + write_project_permission_policy_at(&root, policy).expect("write permission policy"); + + let error = list_local_project_asset_generations(root.to_string_lossy().into_owned()) + .expect_err("denied ledger read must fail closed"); + assert_eq!(error, "项目权限策略拒绝执行:asset.list"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn live_tasks_are_not_settled_while_they_are_still_running() { + let root = temp_project_root("live"); + begin(&root, "task-live"); + live_task_ids() + .lock() + .expect("live ids") + .insert("task-live".to_string()); + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED); + remove_live_task_id("task-live"); + let settled = list_local_project_asset_generation_tasks(&root).expect("list settled"); + assert_eq!(settled[0].status, ASSET_GENERATION_TASK_STATUS_FAILED); + assert_eq!( + settled[0].phase_detail, + ASSET_GENERATION_TASK_PHASE_INTERRUPTED_UNKNOWN + ); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn completing_a_task_records_the_manifest_asset_id_and_keeps_it() { + let root = temp_project_root("completed"); + begin(&root, "task-completed"); + let updated = update_task(&root, "task-completed", |task| { + task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string(); + task.asset_id = Some("asset-9".to_string()); + task.finished_at_millis = Some(now_millis()); + }) + .expect("complete task"); + assert_eq!(updated.asset_id.as_deref(), Some("asset-9")); + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_COMPLETED); + assert_eq!(listed[0].asset_id.as_deref(), Some("asset-9")); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn a_second_task_with_the_same_id_is_rejected_while_the_first_is_in_flight() { + let root = temp_project_root("duplicate"); + begin(&root, "task-dup"); + let error = begin_local_project_asset_generation_task( + &root, + "project-1", + "task-dup", + "image", + "AI 图", + None, + ) + .expect_err("duplicate in-flight task"); + assert_eq!(error, "生成任务 id 已在进行中:task-dup"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn task_ids_are_bounded_single_line_values() { + assert_eq!( + asset_generation_task_id(" ").expect_err("empty id"), + "生成任务 id 不能为空" + ); + assert_eq!( + asset_generation_task_id("带\n换行").expect_err("control char"), + "生成任务 id 超出安全边界" + ); + assert_eq!( + asset_generation_task_id(&"x".repeat(ASSET_GENERATION_TASK_ID_MAX_CHARS + 1)) + .expect_err("oversized id"), + "生成任务 id 超出安全边界" + ); + assert_eq!( + asset_generation_task_id(" task-1 ").expect("trimmed id"), + "task-1" + ); + } + + #[test] + fn ledger_keeps_only_the_most_recent_records() { + let root = temp_project_root("trim"); + for index in 0..(ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS + 5) { + begin(&root, &format!("task-{index:03}")); + } + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed.len(), ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS); + assert!( + listed.iter().any(|task| task.task_id + == format!("task-{:03}", ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS + 4)), + "最新一条必须保留" + ); + std::fs::remove_dir_all(&root).ok(); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs index c7c4313e1..edbdeb1ab 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs @@ -26,6 +26,12 @@ pub(crate) struct LocalProjectImagePreview { pub(crate) byte_len: u64, pub(crate) pixel_width: u32, pub(crate) pixel_height: u32, + /// 这张图是否**真的**带 alpha 通道,判据见 [`detect_raster_image_has_alpha`]。 + /// + /// 资源卡只按它决定要不要铺棋盘格底:`data-preview-kind` 只说明「走图片预览分支」, + /// 与这张图有没有透明像素无关 —— 无条件铺底会让「AI 把棋盘格画进像素里」的不透明图 + /// 与卡面棋盘格叠成两套,验收时无法区分「真透明底」与「假棋盘格」。 + pub(crate) has_alpha: bool, pub(crate) data_url: String, } @@ -102,12 +108,17 @@ pub(crate) fn load_local_project_image_preview_with_cancellation( false, )?; cancellation.check()?; + // 头部级 alpha 判据:只读签名与头部标志(PNG 还会按 chunk 头跳过数据体找 `tRNS`), + // 不做熵解码、不做逐像素扫描,成本不随像素数增长,因此大图与「AI 把棋盘格画进图里」 + // 的不透明图都不会因此变慢。 + let has_alpha = detect_raster_image_has_alpha(&image.bytes, image.media_type); Ok(LocalProjectImagePreview { path: image.relative_path.clone(), media_type: image.media_type.to_string(), byte_len: image.byte_len, pixel_width: image.pixel_width, pixel_height: image.pixel_height, + has_alpha, data_url: image.data_url_with_cancellation(cancellation)?, }) } @@ -420,6 +431,84 @@ fn detect_raster_image_dimensions(bytes: &[u8], media_type: &str) -> Option<(u32 } } +/// 头部级 alpha 判据:这张图**有没有 alpha 通道 / 透明像素**,只看签名与头部标志 +/// (PNG 还会按 chunk 头跳过数据体找 `tRNS`)。 +/// +/// 为什么必须是头部级而不是像素级:资源卡预览按 8 MiB / 8192 边长 / 3270 万像素上限读取, +/// 逐像素扫描意味着对每张卡都做一次全量 RGBA 解码(真机单栏 51 张、单张均值 591 KB), +/// 成本与「卡面装饰底」的收益完全不成比例;而 alpha 是否存在在容器头部就是确定信息。 +/// +/// 判据(保守方向一致:判不出就当作不透明,宁可不铺棋盘格): +/// - PNG:颜色类型 4(灰度 + alpha)/ 6(真彩 + alpha);0 / 2 / 3 本身没有 alpha 通道, +/// 但可以用 `tRNS` 声明透明色,因此还要在第一个 `IDAT` 之前找一次 `tRNS`; +/// - WebP:扩展格式 `VP8X` 的 flags 第 4 位、无损 `VP8L` 位流头的 `alpha_is_used` 位; +/// 简单有损 `VP8 ` 不带 alpha 通道(带 alpha 的有损 WebP 一定走 `VP8X` + `ALPH`); +/// - JPEG:没有 alpha 通道,恒不透明(也绝不为了判 alpha 去扫它的段)。 +fn detect_raster_image_has_alpha(bytes: &[u8], media_type: &str) -> bool { + match media_type { + "image/png" => detect_png_has_alpha(bytes), + "image/webp" => detect_webp_has_alpha(bytes), + _ => false, + } +} + +fn detect_png_has_alpha(bytes: &[u8]) -> bool { + // 签名 8 字节 + IHDR 长度 4 + "IHDR" 4 + 宽 4 + 高 4 + 位深 1 + 颜色类型 1 = 26。 + if bytes.len() < 26 || &bytes[12..16] != b"IHDR" { + return false; + } + if matches!(bytes[25], 4 | 6) { + return true; + } + png_has_transparency_chunk(bytes) +} + +/// 按 chunk 头前进并查找 `tRNS`:只读 8 字节 chunk 头并按长度跳过数据体,不做 zlib 解压。 +fn png_has_transparency_chunk(bytes: &[u8]) -> bool { + let mut offset = 8usize; + loop { + let Some(header_end) = offset.checked_add(8) else { + return false; + }; + if header_end > bytes.len() { + return false; + } + let chunk_type = &bytes[offset + 4..header_end]; + // `tRNS` 必须出现在第一个 `IDAT` 之前;碰到 `IDAT` / `IEND` 就没有再往下扫的意义。 + if chunk_type == b"tRNS" { + return true; + } + if chunk_type == b"IDAT" || chunk_type == b"IEND" { + return false; + } + let chunk_len = + u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap_or([0_u8; 4])) as usize; + let Some(next) = header_end + .checked_add(chunk_len) + .and_then(|value| value.checked_add(4)) + else { + return false; + }; + if next <= offset || next > bytes.len() { + return false; + } + offset = next; + } +} + +fn detect_webp_has_alpha(bytes: &[u8]) -> bool { + if bytes.len() < 16 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WEBP" { + return false; + } + match &bytes[12..16] { + // `VP8X` 的 flags 第 4 位(0x10)就是 alpha 标志(第 20 字节)。 + b"VP8X" => bytes.get(20).is_some_and(|flags| flags & 0x10 != 0), + // `VP8L` 位流头第 28 位是 `alpha_is_used`,落在第 25 个字节(下标 24)的 0x10 位。 + b"VP8L" => bytes.len() >= 25 && bytes[24] & 0x10 != 0, + _ => false, + } +} + #[derive(Clone, Copy)] enum TiffByteOrder { LittleEndian, @@ -745,6 +834,74 @@ mod tests { .expect("valid 1x1 png") } + /// PNG 的「签名 + IHDR」头。判据只读这一段的位深 / 颜色类型,因此后续 chunk 由用例自行拼。 + fn png_header(color_type: u8) -> Vec { + png_header_with_size(color_type, 1, 1) + } + + fn png_header_with_size(color_type: u8, width: u32, height: u32) -> Vec { + let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec(); + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&width.to_be_bytes()); + ihdr.extend_from_slice(&height.to_be_bytes()); + ihdr.push(8); + ihdr.push(color_type); + ihdr.extend_from_slice(&[0, 0, 0]); + push_png_chunk(&mut bytes, b"IHDR", &ihdr); + bytes + } + + /// 追加一个结构合法(长度、类型、CRC 位置正确)但数据体可以是任意字节的 PNG chunk。 + /// alpha 判据不消费 CRC,因此这里填零;正因数据体不必是合法 deflate 流,它同时能证明 + /// 判据没有解码像素。 + fn push_png_chunk(bytes: &mut Vec, kind: &[u8; 4], data: &[u8]) { + bytes.extend_from_slice( + &u32::try_from(data.len()) + .expect("chunk length") + .to_be_bytes(), + ); + bytes.extend_from_slice(kind); + bytes.extend_from_slice(data); + bytes.extend_from_slice(&[0, 0, 0, 0]); + } + + /// 扩展格式 WebP(`VP8X`):`flags` 第 4 位(0x10)是 alpha 标志。 + fn webp_vp8x(flags: u8) -> Vec { + let mut bytes = b"RIFF".to_vec(); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(b"WEBP"); + bytes.extend_from_slice(b"VP8X"); + bytes.extend_from_slice(&10_u32.to_le_bytes()); + bytes.push(flags); + bytes.extend_from_slice(&[0, 0, 0]); + bytes.extend_from_slice(&[0, 0, 0]); + bytes.extend_from_slice(&[0, 0, 0]); + bytes + } + + /// 无损 WebP(`VP8L`):位流头第 28 位是 `alpha_is_used`,落在下标 24 的 0x10 位。 + fn webp_vp8l(has_alpha: bool) -> Vec { + let mut bytes = b"RIFF".to_vec(); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(b"WEBP"); + bytes.extend_from_slice(b"VP8L"); + bytes.extend_from_slice(&5_u32.to_le_bytes()); + bytes.push(0x2f); + bytes.extend_from_slice(&[0, 0, 0, if has_alpha { 0x10 } else { 0 }]); + bytes + } + + /// 简单有损 WebP(`VP8 `):容器上没有 alpha 通道;带 alpha 的有损 WebP 一定走 + /// `VP8X` 扩展格式(+ `ALPH` chunk)。 + fn webp_vp8_simple() -> Vec { + let mut bytes = b"RIFF".to_vec(); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(b"WEBP"); + bytes.extend_from_slice(b"VP8 "); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes + } + fn jpeg_bytes(width: u16, height: u16, app1_payload: Option<&[u8]>) -> Vec { let mut bytes = vec![0xff, 0xd8]; if let Some(payload) = app1_payload { @@ -840,6 +997,138 @@ mod tests { assert_eq!(preview.media_type, "image/png"); assert_eq!(preview.byte_len, png_bytes().len() as u64); assert!(preview.data_url.starts_with("data:image/png;base64,")); + // 这份 fixture 是 PNG colorType 4(灰度 + alpha),因此预览必须报「有 alpha」—— + // 资源卡据此才铺棋盘格底。 + assert!(preview.has_alpha); + } + + #[test] + fn png_alpha_follows_color_type_and_transparency_chunk() { + let color_type_alpha = |color_type: u8| { + let mut bytes = png_header(color_type); + push_png_chunk(&mut bytes, b"IDAT", &[0, 0, 0]); + push_png_chunk(&mut bytes, b"IEND", &[]); + detect_raster_image_has_alpha(&bytes, "image/png") + }; + + // 颜色类型 4(灰度 + alpha)与 6(真彩 + alpha)才带 alpha 通道。 + assert!(color_type_alpha(4), "colorType 4 应判为有 alpha"); + assert!(color_type_alpha(6), "PNG-32(colorType 6)应判为有 alpha"); + // 0 / 2 / 3 本身没有 alpha 通道:这是「AI 把棋盘格画进像素里」那张不透明 PNG 的形状。 + assert!(!color_type_alpha(0), "colorType 0 不应判为有 alpha"); + assert!( + !color_type_alpha(2), + "PNG-24(colorType 2)不应判为有 alpha" + ); + assert!( + !color_type_alpha(3), + "colorType 3 无 tRNS 时不应判为有 alpha" + ); + // 未定义的颜色类型失败关闭为「不透明」,不能把坏文件当成透明。 + assert!(!color_type_alpha(7), "未定义 colorType 不应判为有 alpha"); + + // 灰度 / 真彩 / 调色板可以靠 tRNS 声明透明色,那也是真透明 PNG,必须铺棋盘格。 + for color_type in [0_u8, 2, 3] { + let mut bytes = png_header(color_type); + push_png_chunk(&mut bytes, b"tRNS", &[0]); + push_png_chunk(&mut bytes, b"IDAT", &[0, 0, 0]); + push_png_chunk(&mut bytes, b"IEND", &[]); + assert!( + detect_raster_image_has_alpha(&bytes, "image/png"), + "colorType {color_type} + tRNS 也是真透明 PNG" + ); + } + + // tRNS 规范上必须在 IDAT 之前:出现在之后不再继续扫 chunk(成本有界)。 + let mut late_trns = png_header(3); + push_png_chunk(&mut late_trns, b"IDAT", &[0, 0, 0]); + push_png_chunk(&mut late_trns, b"tRNS", &[0]); + push_png_chunk(&mut late_trns, b"IEND", &[]); + assert!(!detect_raster_image_has_alpha(&late_trns, "image/png")); + } + + #[test] + fn jpeg_and_webp_alpha_follow_container_flags() { + // JPEG 没有 alpha 通道:恒不透明(也绝不为了判 alpha 去解码扫描段)。 + assert!(!detect_raster_image_has_alpha( + &jpeg_bytes(40, 20, None), + "image/jpeg" + )); + // 扩展格式 VP8X 的 flags 第 4 位就是 alpha 标志。 + assert!(detect_raster_image_has_alpha( + &webp_vp8x(0x10), + "image/webp" + )); + assert!(!detect_raster_image_has_alpha( + &webp_vp8x(0x00), + "image/webp" + )); + // 只有 ICC(0x20)/ EXIF(0x08)等其它标志时不是 alpha。 + assert!(!detect_raster_image_has_alpha( + &webp_vp8x(0x28), + "image/webp" + )); + // 无损 VP8L 的 alpha_is_used 位。 + assert!(detect_raster_image_has_alpha( + &webp_vp8l(true), + "image/webp" + )); + assert!(!detect_raster_image_has_alpha( + &webp_vp8l(false), + "image/webp" + )); + // 简单有损格式不带 alpha 通道。 + assert!(!detect_raster_image_has_alpha( + &webp_vp8_simple(), + "image/webp" + )); + + // 头部被截断时失败关闭为「不透明」,且不得 panic。 + let truncated_webp = webp_vp8x(0x10); + assert!(!detect_raster_image_has_alpha( + &truncated_webp[..18], + "image/webp" + )); + let truncated_png = png_header(6); + assert!(!detect_raster_image_has_alpha( + &truncated_png[..20], + "image/png" + )); + } + + #[test] + fn alpha_judgement_never_decodes_pixels() { + // 4096×4096 的 PNG-32:真按像素解码要 64 MiB 缓冲,而下面的 IDAT 数据体不是合法 + // deflate 流(全零),任何真正的解码器都会失败。判据只看头部,所以这里必须成功, + // 并且仍然判 has_alpha=true —— 这就是「不做全量解码」的可执行证据。 + let root = tempfile::tempdir().expect("temp root"); + fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir"); + let mut bytes = png_header_with_size(6, 4_096, 4_096); + push_png_chunk(&mut bytes, b"IDAT", &[0x00, 0x00, 0x00, 0x00]); + push_png_chunk(&mut bytes, b"IEND", &[]); + fs::write(root.path().join("assets/ui/large.png"), &bytes).expect("large image"); + + let preview = load_local_project_image_preview(root.path(), "assets/ui/large.png") + .expect("header-only preview"); + + assert_eq!(preview.pixel_width, 4_096); + assert_eq!(preview.byte_len, bytes.len() as u64); + assert!(preview.has_alpha); + } + + #[test] + fn image_preview_serializes_alpha_flag_for_the_shell() { + let root = tempfile::tempdir().expect("temp root"); + fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir"); + fs::write(root.path().join("assets/ui/prototype.png"), png_bytes()).expect("image"); + + let preview = load_local_project_image_preview(root.path(), "assets/ui/prototype.png") + .expect("load project preview"); + + // 前端按 camelCase 读 `hasAlpha`(`ProjectResourceCardPreviewTransportPayload`); + // 字段名或大小写改了会让资源卡永远退回纯色底,所以这里钉住 IPC 契约。 + let serialized = serde_json::to_value(&preview).expect("serialize preview"); + assert_eq!(serialized["hasAlpha"], serde_json::json!(true)); } #[test] 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 4d5030c2a..5d93e78bc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -244,6 +244,7 @@ macro_rules! app_log { // 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。 mod agent; mod agent_native_tools; +mod asset_generation_tasks; mod assets; mod browser; mod builtin_plugins; @@ -289,6 +290,7 @@ mod windows; use agent::*; use agent_native_tools::*; +use asset_generation_tasks::*; use assets::*; use browser::*; use cli::*; @@ -2736,6 +2738,8 @@ fn main() { ensure_ui_design_resource_for_prototype, generate_platform_art_asset, generate_local_project_asset, + start_local_project_asset_generation, + list_local_project_asset_generations, open_canvas_project, get_game_creation_agent_capabilities, get_limited_local_commands, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceTypePanel.css b/apps/ai-game-creator-shell/src/features/project-workspace/resourceTypePanel.css new file mode 100644 index 000000000..fb9260aa6 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceTypePanel.css @@ -0,0 +1,130 @@ +/* + * 「设置素材类型」面板的弹窗骨架、纵向单选列表与信息浮层的类型入口。 + * + * 单独一个文件而不是塞进 styles.css:与「编辑素材标签」面板当初同样的理由 —— + * 这份样式只服务本次的素材类型入口,与工作台其它区块没有共享选择器,独立文件让改动 + * 边界更清楚,也不会与同一时段其它 Agent 在 styles.css 里的编辑互相踩。 + * + * 骨架沿用「编辑素材标签」那套三段式契约(`auto / minmax(0, 1fr)`):标题常驻、 + * 中间一行可压缩、`max-height` 兜住上界。类型面板没有底部按钮,所以只有两行。 + */ +.game-resource-type-dialog { + width: min(480px, 100%); + max-height: min(720px, calc(100dvh - 40px)); + grid-template-rows: auto minmax(0, 1fr); +} + +/* + * body 分三段:提示 / 选项列表 / 错误提示。 + * + * `min-height: 0` 是网格项能被 `1fr` 压缩的前提;**滚动不在这里**——滚动权交给选项列表 + * (见下),否则往下滚时素材名和错误提示会跟着跑掉,用户看不到"改的是哪件素材、为什么失败"。 + */ +.game-resource-type-body { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 10px; + min-height: 0; +} + +/* + * 类型选项之上的一句短提示。只说这一屏要选什么,不写规则说明或开发解释。 + */ +.game-resource-type-hint { + margin: 0; + color: var(--platform-text-base); + font-size: 12px; +} + +/* + * 纵向单选列表(`role="radiogroup"`):**一行一个选项**。 + * + * 之前 6 项横排在一条里(`PlatformSegmentedTabs` 的 3~6 列网格),窄屏上互相叠字读不出来。 + * 单列网格 + 按行流向是"每项一行、互不重叠"的充分条件:只声明一列,6 个子元素必然上下排 6 行, + * 不存在两项挤一行的可能。选项多时列表自己滚(`max-height` + `overflow-y: auto`), + * 面板不会被撑高。移动端优先:360px 宽的窄屏同样是这一套声明(没有按宽度改列数的媒体查询)。 + */ +.game-resource-type-options { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-auto-flow: row; + align-content: start; + gap: 6px; + min-height: 0; + max-height: min(320px, 40dvh); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +/* + * 单个选项行:复用共享的 `PlatformNavigableListItem` 骨架(w-full / flex / text-left / + * 圆角 / 悬停 / 焦点环都由它给),这里只补"整行可点 + 明确选中态"的表现。 + * + * `width/min-width` 显式写出来,不依赖共享件里的 Tailwind `w-full`:这一行是不是满宽 + * 决定了"一项一行"能不能成立,不能挂在另一份文件的工具类上。 + * `min-height: 44px` 是移动端点击热区下限;`overflow-wrap` 让长选项名在窄屏换行而不是溢出。 + */ +.game-resource-type-option { + width: 100%; + min-width: 0; + min-height: 44px; + padding: 8px 12px; + border: 1px solid var(--platform-subpanel-border); + background: rgb(255 255 255 / 62%); + color: var(--platform-text-base); + font-size: 13px; + font-weight: 600; + overflow-wrap: anywhere; +} + +.game-resource-type-option:hover:not(:disabled) { + border-color: var(--platform-surface-hover-border); +} + +/* + * 选中态完全由 `aria-checked="true"` 驱动:视觉与读屏读的是同一个属性,不会各说一套。 + * + * 选择器显式提权到 (0,3,0) 以上:共享列表行自带的 `.platform-navigable-list-item:hover:not(:disabled)` + * 也是 (0,3,0),只写 `.game-resource-type-option[aria-checked='true']`((0,2,0))会在悬停时 + * 被它的底色顶掉;带 `:hover:not(:disabled)` 的那条 (0,5,0) 保证选中行悬停时也不变色。 + */ +.game-resource-type-options .game-resource-type-option[aria-checked='true'], +.game-resource-type-options + .game-resource-type-option[aria-checked='true']:hover:not(:disabled) { + border-color: var(--platform-warm-border); + background: var(--platform-warm-bg); + color: var(--platform-text-strong); +} + +.game-resource-type-error { + margin: 0; + color: #b3261e; + font-size: 11px; +} + +/* + * 第二入口:信息浮层「分类」行右侧的入口按钮。 + * + * 放在 `dd` **外面**:信息字段的读取口径(`dt` / `dd` 文本逐行比对)在两处共用, + * 把按钮塞进 `dd` 会让分类值变成「角色与对象设置」这类拼接文案。 + */ +.game-resource-info-field-action { + align-self: start; + margin-left: auto; + padding: 0 6px; + border: 1px solid var(--platform-subpanel-border); + border-radius: 8px; + background: transparent; + color: var(--platform-text-base); + font-size: 11px; + line-height: 20px; + cursor: pointer; +} + +.game-resource-info-field-action:hover, +.game-resource-info-field-action:focus-visible { + border-color: var(--platform-surface-hover-border); + background: var(--platform-warm-bg); + color: var(--platform-text-strong); +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx index 89402805e..5c36c3d56 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx @@ -22,24 +22,47 @@ export type ResourceCanvasAssetGenerationSubmitInput = { imageSize: string; }; -export type ResourceCanvasAssetGenerationPanelViewProps = { - action: ResourceCanvasAssetToolAction; - onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => Promise; - onClose: () => void; +/** 提交面板的草稿:点击即关闭之后,只有「即时失败」重开时才需要把这份草稿带回来。 */ +export type ResourceCanvasAssetGenerationPanelDraft = { + prompt: string; + assetName: string; + aspectRatio: string; + imageSize: string; }; -function assetGenerationErrorMessage(error: unknown) { - if (typeof error === 'string' && error.trim()) return error; - if (error instanceof Error && error.message) return error.message; - return '生成素材失败'; -} +export type ResourceCanvasAssetGenerationPanelViewProps = { + action: ResourceCanvasAssetToolAction; + /** + * 上一次「点击瞬间就失败」带回来的草稿。 + * + * 面板点击即关闭,草稿只活在组件里;重开时由宿主把它传回来,用户改完就能重试。 + */ + draft?: ResourceCanvasAssetGenerationPanelDraft; + /** 上一次即时失败的原因;重开时直接以 `role="alert"` 呈现。 */ + error?: string | null; + /** + * 提交回调:**同步返回**,面板不等它的结果。 + * + * 受理失败要不要把面板带回来由宿主决定(只有「从未被后端受理」的即时失败才重开), + * 面板自己不持有任何在途状态。 + */ + onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => void; + onClose: () => void; +}; /** * 栏目画布底部工具栏的图片类生成浮层(生成图片 / 生成规范 / 生成角色形象 / 生成图标素材 / * 生成 UI 设计图共用)。 * * 形态是独立弹层(`ThemedModal`,与既有「生成素材」面板同一套宿主 chrome),不在任何现有 - * 面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责,面板只持有草稿与失败状态。 + * 面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责。 + * + * **点击「生成」即关闭面板**:不等 IPC、不等排队、不等生成结束,用户立刻回到画布。所以面板里 + * 不存在「排队中。」「正在生成。」「提交中…」这类阶段文案——阶段文案的唯一去处是画布上的 + * 「生成任务」侧栏与工具栏提示条。关闭**不等于**取消:任务照常在后台跑完并把结果写回项目。 + * + * 只有「点击瞬间就失败」(校验 / 权限拒绝 / IPC 立即报错,即后端从未受理)时,宿主才会带着 + * `draft` 与 `error` 把面板重新打开,用户可以直接改后重试。 * * 比例 / 尺寸选项来自网页端美术画布的纯模型(`ImageCanvasGenerationModel.ts`)经本地 IPC * 白名单收窄后的子集:网页端面板会渲染 `4:3`,而本地通道明确拒绝它,照搬就是一个点了必 @@ -48,57 +71,52 @@ function assetGenerationErrorMessage(error: unknown) { */ export function ResourceCanvasAssetGenerationPanelView({ action, + draft, + error: initialError, onSubmit, onClose, }: ResourceCanvasAssetGenerationPanelViewProps) { - const [prompt, setPrompt] = useState(''); - const [assetName, setAssetName] = useState(action.assetName); - const [aspectRatio, setAspectRatio] = useState(action.aspectRatio); - const [imageSize, setImageSize] = useState(action.imageSize); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); + const [prompt, setPrompt] = useState(draft?.prompt ?? ''); + const [assetName, setAssetName] = useState( + draft?.assetName ?? action.assetName, + ); + const [aspectRatio, setAspectRatio] = useState( + draft?.aspectRatio ?? action.aspectRatio, + ); + const [imageSize, setImageSize] = useState( + draft?.imageSize ?? action.imageSize, + ); + const [error, setError] = useState(initialError ?? null); // 提示词上限复用资源编辑模型的同一份口径:图片类入口默认 32000,与 Rust // `LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS` 一致,不在面板里另抄常量。 const promptMaxLength = resourceEditPromptMaxLength('image-reference'); - const canSubmit = - !submitting && prompt.trim().length > 0 && assetName.trim().length > 0; + const canSubmit = prompt.trim().length > 0 && assetName.trim().length > 0; - async function submit(event: FormEvent) { + function submit(event: FormEvent) { event.preventDefault(); const normalizedPrompt = prompt.trim(); const normalizedAssetName = assetName.trim(); - if (!normalizedPrompt || !normalizedAssetName || submitting) { + if (!normalizedPrompt || !normalizedAssetName) { return; } - setSubmitting(true); setError(null); - try { - await onSubmit({ - kind: action.assetKind, - prompt: normalizedPrompt, - assetName: normalizedAssetName, - aspectRatio, - imageSize, - }); - } catch (submitError) { - // 成功路径由宿主卸载面板;失败保留草稿,用户可直接用同一份输入重试。 - setError(assetGenerationErrorMessage(submitError)); - } finally { - setSubmitting(false); - } + // 点击即关闭:不等 IPC、不等排队、不等生成结束。失败要不要把面板带回来由宿主决定 + // (只有「从未被后端受理」的即时失败才重开并带回草稿),面板不持有在途状态。 + onSubmit({ + kind: action.assetKind, + prompt: normalizedPrompt, + assetName: normalizedAssetName, + aspectRatio, + imageSize, + }); + onClose(); } return ( { - if (!submitting) { - onClose(); - } - }} + onClose={onClose} panelClassName="game-approval-dialog game-resource-generation-dialog" >
@@ -108,7 +126,6 @@ export function ResourceCanvasAssetGenerationPanelView({ + ) : null} + + ); +} + +/** + * 画布上的「生成任务」侧栏(常驻、可折叠、非模态)。 + * + * 形态对齐网页端美术画布的任务侧栏:贴边的独立 `
- + ); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceTypePanel.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceTypePanel.tsx new file mode 100644 index 000000000..3a7144160 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceTypePanel.tsx @@ -0,0 +1,238 @@ +import '../../features/project-workspace/resourceTypePanel.css'; + +import { Check } from 'lucide-react'; +import { + type KeyboardEvent as ReactKeyboardEvent, + useRef, + useState, +} from 'react'; + +import { PlatformNavigableListItem } from '../../../../../packages/shared/src/components/PlatformNavigableListItem'; +import { + GAME_CREATION_APP_ASSET_CATEGORIES, + type GameCreationAppAssetCategory, + gameCreationAppAssetCategory, + type GameCreationAppAssetManifestEntry, + gameCreationAppAssetTags, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { ThemedModal } from '../../components/modal/ThemedModal'; +import { resourceReferenceCategoryLabel } from '../../features/project-workspace/resourceReferences'; +import { projectAssetCommandErrorMessage } from './projectAssetCommandErrorMessage'; +import { resourceAssetDisplayName } from './resourceAssetDisplayName'; + +type UpdateLocalProjectResourceClassificationResult = { + asset: GameCreationAppAssetManifestEntry; + committedProjectRevision: number; +}; + +/** + * 素材类型(功能分类)选项 = 合法分类枚举 × 既有中文展示名。 + * + * 展示名只从 `resourceReferenceCategoryLabel`(筛选与栏目的同一份口径)取, + * 不在业务页另写一张译名表 —— 面板说「角色与对象」而栏目说别的,用户会以为是两个东西。 + */ +const RESOURCE_TYPE_CATEGORY_OPTIONS = GAME_CREATION_APP_ASSET_CATEGORIES.map( + (category) => ({ + id: category, + label: resourceReferenceCategoryLabel(category), + }), +); + +function resourceTypeErrorMessage(error: unknown) { + // 项目身份 / 版本 CAS 拒绝翻成用户可读中文,其余原样透出; + // 与重命名、删除、标签共用同一份映射。 + return projectAssetCommandErrorMessage(error, '设置素材类型失败'); +} + +type ResourceTypePanelProps = { + projectPath: string; + projectId: string; + asset: GameCreationAppAssetManifestEntry; + onClose: () => void; + onSaved: (result: UpdateLocalProjectResourceClassificationResult) => void; +}; + +/** + * 「设置素材类型」面板:素材类型(功能分类)的独立入口,与「编辑素材标签」彻底分家。 + * + * 拆开的理由是原设计的动作语义错位 —— 类型 chip 曾长在标签弹窗里,点它只改本地 state, + * 而全弹窗唯一的保存入口是标签的「添加」。于是「改类型」必须借道一个语义上是"加标签"的 + * 按钮,只选类型就直接关窗(点遮罩 / Esc / ×)则改动静默丢失。 + * + * 本面板把动作压成一步:**选中即落盘**,不再有也只不需要任何标签动作。 + * + * 三个口径要点: + * 1. **显示**用读显示口径 `gameCreationAppAssetCategory`:它与画布栏目、资源卡角标同一份 + * 读数,用户看到的选中项恰好就是他看到的那一栏。 + * 2. **写回**用用户当次点的那个值,且只写这一个字段;`tags` 逐字回传 + * `gameCreationAppAssetTags(asset)`(落盘原值),不使用任何读时自愈口径 + * —— 改类型不许顺手改标签,也不许把自愈出来的值写回去。 + * 3. **没碰过就不写**:面板本身不产生"打开即写"或"关闭时补写",没有用户动作就没有写入。 + * 另一半对照(用户主动选了就必须写)由 `tests/resourceTypePanel.test.tsx` 钉住。 + */ +export function ResourceTypePanel({ + projectPath, + projectId, + asset, + onClose, + onSaved, +}: ResourceTypePanelProps) { + /** + * 保存在飞时先把用户点的那一项显出来(否则 await 期间面板像没反应)。 + * 写入失败就退回显示口径,不留一个"看起来成功"的选中态。 + */ + const [pendingCategory, setPendingCategory] = + useState(null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const activeCategory = pendingCategory ?? gameCreationAppAssetCategory(asset); + const optionsRef = useRef(null); + + /** + * 单选组的键盘口径:Tab 进组只停一次(roving tabindex,见下面的 `tabIndex`); + * 方向键在选项之间移动**焦点**,Enter/Space(` + +
+ {/* 只说这一屏要选什么,不写规则说明或开发解释。 */} +

选择这件素材所属的栏目

+ {/* + 纵向单选列表(`role="radiogroup"` + 每项 `role="radio"`): + 一行一个选项,不再横排成一条 —— 6 项挤在一行时窄屏会互相叠字。 + + 选中项就是这张卡当前所在的画布栏目;点任意一项即落盘(含点当前已选中的那一项: + 用户显式确认归属,不做隐式 no-op)。视觉选中态由 `aria-checked="true"` 驱动, + 与读屏读到的状态是同一个属性。 + */} +
+ {RESOURCE_TYPE_CATEGORY_OPTIONS.map((option, index) => { + const active = option.id === activeCategory; + return ( + + ); + })} +
+ {error ? ( +

+ {error} +

+ ) : null} +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 15fdad3a4..658a8b40a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -25,6 +25,7 @@ import { Info, Layers, LayoutGrid, + ListChecks, ListFilter, Maximize2, Minus, @@ -39,6 +40,7 @@ import { RotateCcw, Search, Settings2, + Shapes, SlidersHorizontal, Sparkles, Trash2, @@ -109,9 +111,23 @@ import { } from '../../features/project-workspace/resourceReferences'; import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker'; import { + type ResourceCanvasAssetGenerationPanelDraft, ResourceCanvasAssetGenerationPanelView, type ResourceCanvasAssetGenerationSubmitInput, } from '../../features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; +import { + createResourceCanvasAssetGenerationQueue, + mergeResourceCanvasAssetGenerationTasksWithRecords, + type ResourceCanvasAssetGenerationQueue, + type ResourceCanvasAssetGenerationSettlement, +} from '../../features/resource-canvas/resourceCanvasAssetGenerationQueue'; +import { + createResourceCanvasAssetGenerationTask, + type LocalProjectAssetGenerationTaskRecord, + type ResourceCanvasAssetGenerationTask, + resourceCanvasAssetGenerationTaskIsTerminal, +} from '../../features/resource-canvas/resourceCanvasAssetGenerationTaskModel'; +import { ResourceCanvasAssetGenerationTasksPanelView } from '../../features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView'; import { defaultResourceExportFileName, isResourceCanvasExportable, @@ -321,6 +337,7 @@ import { clampProjectResourceSectionZoom, projectResourceSectionZoomFromWheel, } from './resourceSectionHeightModel'; +import { ResourceTypePanel } from './ResourceTypePanel'; import { describeProjectResourceCanvasLayoutRead, type ProjectResourceCanvasLayoutReadReport, @@ -388,13 +405,6 @@ type DeriveLocalProjectResourceResult = { manifest: GameCreationAppManifest; }; -type LocalProjectAssetGenerationResult = { - id: string; - localPath: string; - absolutePath: string; - manifestPath: string; -}; - type PendingLocalProjectResourceEdit = { operationId: string; editKind: string; @@ -974,6 +984,18 @@ const ResourceCard = memo(function ResourceCard({ data-preview-error={ preview.status === 'failed' ? preview.error : undefined } + // 这张图是否**真的**带 alpha 通道(原生头部判据:PNG colorType 4/6 或 tRNS、 + // WebP alpha 标志;JPEG 恒 false)。棋盘格底只允许铺在真透明图上 —— + // 否则「AI 把棋盘格画进像素里」的不透明图会与卡面棋盘格叠成两套,验收时反而 + // 分不出哪张真透明。 + // + // 只写 `'true'`,不写 `'false'`:缺属性表示「还没读出来 / 判据说不透明 / 这条读取链路 + // 没有 alpha 判据」,三者必须同档(CSS 里只认 `'true'`),免得出现第三种中间态。 + data-preview-has-alpha={ + preview.status === 'loaded' && preview.preview.hasAlpha === true + ? 'true' + : undefined + } data-used-by-current-version={usedByCurrentVersion ? 'true' : undefined} // 替换血缘的稳定 DOM 判据(值都是 manifest 资产 id,不是显示名): // 「被替换掉的源素材」卡上给出替换它的那张卡的 id,「替换素材」卡上给出源素材的 id。 @@ -1556,6 +1578,56 @@ export default function ProjectDevelopmentView({ /** 工具栏图片类入口打开的生成浮层;同一时刻只允许一个。 */ const [resourceAssetGenerationAction, setResourceAssetGenerationAction] = useState(null); + /** + * 图片类生成任务的本地队列 + 后端账本视图。 + * + * 与 `resourceAssetGenerationAction`(只管「哪块提交表单开着」)分开持有:表单随时可以被关掉, + * 任务必须继续活在这份列表里。所以提交回调不读面板状态,队列也不依赖面板挂载。 + */ + const [resourceAssetGenerationTasks, setResourceAssetGenerationTasks] = + useState([]); + const resourceAssetGenerationTasksRef = useRef< + ResourceCanvasAssetGenerationTask[] + >([]); + /** + * 提交面板上一次提交的上下文。 + * + * 面板点击即关闭,草稿只活在组件里,所以「点击瞬间就失败」要把面板带回来时,得从这里取回 + * 那份草稿;`dispatchedImmediately` 用来区分「这次点击本来就该立刻派发」与「排在队列后面 + * 才派发」——只有前者才值得重开面板。 + */ + const resourceAssetGenerationPanelSubmissionRef = useRef<{ + taskId: string; + action: ResourceCanvasAssetToolAction; + draft: ResourceCanvasAssetGenerationPanelDraft; + dispatchedImmediately: boolean; + } | null>(null); + /** 即时失败重开提交面板时带回去的草稿与原因;正常打开时为 null。 */ + const [ + resourceAssetGenerationPanelReopen, + setResourceAssetGenerationPanelReopen, + ] = useState<{ + actionId: string; + draft: ResourceCanvasAssetGenerationPanelDraft; + error: string; + } | null>(null); + const [ + resourceAssetGenerationTasksPanelOpen, + setResourceAssetGenerationTasksPanelOpen, + ] = useState(false); + /** + * 「定位到素材」的聚焦请求序号。 + * + * 聚焦 effect(`resolveResourceFocusIntent` 那条链)的依赖全是画布自身状态,手动点一次定位 + * 不改其中任何一项 → effect 不会重跑,intent 永远没人消费、提示条停在中转文案上。所以每次 + * 点击都要推进这个序号,让「这次请求」成为一个真实的依赖变化。 + */ + const [ + resourceAssetGenerationFocusRequest, + setResourceAssetGenerationFocusRequest, + ] = useState(0); + /** 提示条文案的 ref 版:有界兜底要判断此刻是否还停在中转文案上。 */ + const resourceWorkbenchNoticeRef = useRef(''); const [resourceBottomToolbarUploading, setResourceBottomToolbarUploading] = useState(false); const [resourcePanelNotice, setResourcePanelNotice] = useState(''); @@ -1587,6 +1659,13 @@ export default function ProjectDevelopmentView({ useState(false); const [resourceClassificationAssetId, setResourceClassificationAssetId] = useState(null); + /** + * 正在设置素材类型(功能分类)的素材;与 `resourceClassificationAssetId`(标签)分开持有: + * 两块面板是两个独立入口,一块开着不该把另一块的宿主状态也算成开着。 + */ + const [resourceTypeAssetId, setResourceTypeAssetId] = useState( + null, + ); /** 正在重命名的素材;改名沿用分类面板同一条 manifest 重载路径。 */ const [resourceRenameAssetId, setResourceRenameAssetId] = useState< string | null @@ -1824,6 +1903,14 @@ export default function ProjectDevelopmentView({ const resourceCanvasHostGenerationPanelOpen = resourceGenerationOpen || resourceAssetGenerationAction !== null; + /** + * 「编辑素材标签」与「设置素材类型」两块面板**共用一个宿主浮层判据**: + * 它们都是 portal 到 body 的模态浮层,任何一块开着,点外部清焦点与画布自己的 Esc + * 都必须让位。分开两个字段会让"只开其中一块"时漏掉一半判据。 + */ + const resourceClassificationOverlayOpen = + resourceClassificationAssetId !== null || resourceTypeAssetId !== null; + useImageCanvasFloatingOptionDismiss({ isOpen: resolveResourceCanvasFloatingPanelDismissOpen({ isCanvasVisible: mode === 'resources' && !uiEditorRoute, @@ -1831,7 +1918,7 @@ export default function ProjectDevelopmentView({ hostOverlay: { isResourcePanelOpen: resourcePanelOpen, isGenerationPanelOpen: resourceCanvasHostGenerationPanelOpen, - isClassificationPanelOpen: resourceClassificationAssetId !== null, + isClassificationPanelOpen: resourceClassificationOverlayOpen, isRenameDialogOpen: resourceRenameAssetId !== null, isRecoveryPanelOpen: resourceRecoveryPanelOpen, }, @@ -1858,7 +1945,7 @@ export default function ProjectDevelopmentView({ hostOverlay: { isResourcePanelOpen: resourcePanelOpen, isGenerationPanelOpen: resourceCanvasHostGenerationPanelOpen, - isClassificationPanelOpen: resourceClassificationAssetId !== null, + isClassificationPanelOpen: resourceClassificationOverlayOpen, isRenameDialogOpen: resourceRenameAssetId !== null, isRecoveryPanelOpen: resourceRecoveryPanelOpen, }, @@ -1878,7 +1965,8 @@ export default function ProjectDevelopmentView({ isResourceCanvasFloatingPanelOpen, mode, resourceCanvasHostGenerationPanelOpen, - resourceClassificationAssetId, + // 判据用的是合并后的开关:两块分类面板任一开着都算宿主浮层打开。 + resourceClassificationOverlayOpen, resourcePanelOpen, resourceRecoveryPanelOpen, resourceRenameAssetId, @@ -2369,7 +2457,8 @@ export default function ProjectDevelopmentView({ topology: resourceLayoutTopology, initializationReady: resourceGraphInitializationReady, renderFallbackWhileBlocked: resourceGraphFailed, - rederiveAutomaticPositions: resourceGraphReady, + // 画布不再自动重派生:新增素材只补位,整张重排只能由「整理画布」显式发起。 + rederiveAutomaticPositions: false, }); const typeLayout = useProjectResourceCanvasLayout({ projectPath, @@ -2377,8 +2466,38 @@ export default function ProjectDevelopmentView({ mode: 'type', resources: resourcesWithCanvasCardSize, initializationReady: true, - rederiveAutomaticPositions: true, + rederiveAutomaticPositions: false, }); + /** + * 依赖视图的自动重派生只做一次:关系图首次就绪、且这一侧的 sidecar 已经读完的那一刻。 + * + * 旧口径把 `rederiveAutomaticPositions` 长期等于 `resourceGraphReady`——只要关系图是 + * 现成的,任何一次资源协调签名变化(新增一张素材、改一个标签、拓扑重建)都会丢掉全部 + * 自动坐标重排整张画布。现在改成一次性:首次就绪时用显式重派生把坐标对齐到最终拓扑, + * 之后一律只补新卡。用 ref 记"这一次已经做过",而不是让布尔长期为真;判据也放在 + * `dependencyLayout.ready` 之后就绪,避免"关系图先就绪、sidecar 后读完"时把这一次重算 + * 白白吃掉。按项目作用域记账:切排序 tab 不会重新武装它。 + */ + const dependencyRederiveScopeRef = useRef(null); + const dependencyLayoutReady = dependencyLayout.ready; + const rederiveDependencyLayout = dependencyLayout.rederiveNow; + useEffect(() => { + if (!resourceGraphReady || !dependencyLayoutReady) { + return; + } + const scopeKey = JSON.stringify([projectPath, manifest.projectId]); + if (dependencyRederiveScopeRef.current === scopeKey) { + return; + } + dependencyRederiveScopeRef.current = scopeKey; + rederiveDependencyLayout(); + }, [ + dependencyLayoutReady, + manifest.projectId, + projectPath, + rederiveDependencyLayout, + resourceGraphReady, + ]); const activeResourceLayout = sortMode === 'dependency' ? dependencyLayout : typeLayout; const resourceLayout = activeResourceLayout.layout; @@ -2936,6 +3055,14 @@ export default function ProjectDevelopmentView({ : null, [manifest.assets, resourceClassificationAssetId], ); + const resourceTypeAsset = useMemo( + () => + resourceTypeAssetId + ? (manifest.assets.find((asset) => asset.id === resourceTypeAssetId) ?? + null) + : null, + [manifest.assets, resourceTypeAssetId], + ); const resourceRenameAsset = useMemo( () => resourceRenameAssetId @@ -5108,6 +5235,72 @@ export default function ProjectDevelopmentView({ [advanceFocusGeneration, manifest.assets, openUiDesignEditor], ); + /** + * 新素材入库后自动聚焦:只认 `manifest.assets` 里**此前没见过**的 id。 + * + * 存量素材不是"刚生成":首次打开项目 / 切项目只登记基线,不然一进工作台就会跳到 + * 最后一张卡上。重命名不改 id,天然不触发。 + * + * 这里不自己造滚动或选中逻辑,只把意图交给既有的 `pendingResourceFocusRef` + + * `advanceFocusGeneration()` 裁决链:投影、两种布局落位、可见性、DOM 就绪都由那条链 + * 负责,被搜索条件挡住时也沿用现成的「清除搜索并定位」提示与动作。 + * + * 已有一条指向同一资源的聚焦意图时不再插手:显式生成链路(面板/工具栏生成)已经在 + * 提交时就挂好了意图,重复设置只会把它更精细的提示文案顶掉。 + */ + const seenManifestAssetIdsRef = useRef<{ + scopeKey: string; + ids: Set; + } | null>(null); + useEffect(() => { + const scopeKey = JSON.stringify([projectPath, manifest.projectId]); + const assetIds = manifest.assets.map((asset) => asset.id); + const previous = seenManifestAssetIdsRef.current; + if (!previous || previous.scopeKey !== scopeKey) { + seenManifestAssetIdsRef.current = { scopeKey, ids: new Set(assetIds) }; + return; + } + const addedIds = assetIds.filter((id) => !previous.ids.has(id)); + for (const id of assetIds) { + previous.ids.add(id); + } + if (addedIds.length === 0) { + return; + } + // 一次可能进多条(例如一次派生产出多个产物):聚焦清单里最后落地的那一条。 + const addedAssetId = addedIds[addedIds.length - 1]!; + const resourceId = `asset:${addedAssetId}`; + // 资源投影是同步的:这里查不到对应资源说明这条素材不会出现在画布上, + // 不为它挂聚焦意图,免得裁决链一直停在"等待投影"。 + if (!resources.some((resource) => resource.id === resourceId)) { + return; + } + if (pendingResourceFocusRef.current?.resourceId === resourceId) { + return; + } + const flowId = crypto.randomUUID(); + const focusGeneration = advanceFocusGeneration(); + activeFocusFlowIdRef.current = flowId; + pendingResourceFocusRef.current = { + flowId, + saveAttemptId: flowId, + sessionId: flowId, + draftId: flowId, + commitId: `asset-added:${addedAssetId}`, + projectPath, + projectId: manifest.projectId, + focusGeneration, + resourceId, + completed: false, + }; + }, [ + advanceFocusGeneration, + manifest.assets, + manifest.projectId, + projectPath, + resources, + ]); + useEffect(() => { if (uiEditorRoute) return; const completed = manifest.assets.find( @@ -5545,6 +5738,9 @@ export default function ProjectDevelopmentView({ } if (focusedCommitIdsRef.current.has(intent.commitId)) { pendingResourceFocusRef.current = null; + // 这条资源已经聚焦过了:把中转提示一并收掉,否则「生成资源已保存,正在同步资源与布局…」 + // 这类文字会永久留在提示条上。 + setResourceWorkbenchNotice(''); return; } intent.completed = true; @@ -5562,6 +5758,9 @@ export default function ProjectDevelopmentView({ manifest.projectId, projectPath, resources, + // 手动「定位到素材」不改画布任何状态,靠这个序号把「这次定位请求」变成真实的依赖变化; + // 少了它 effect 不会重跑,intent 永远没人消费。 + resourceAssetGenerationFocusRequest, selectResourceCanvasPage, typeLayout.layout.positions, typeLayout.settled, @@ -6645,94 +6844,397 @@ export default function ProjectDevelopmentView({ ); /** - * 工具栏图片类入口的生成:`generate_local_project_asset`。 + * 生成任务的宿主上下文快照。 * - * 收口口径与既有音频入口一致:结果落盘后由「配对读」取回权威 (revision, 清单) 交给 - * `onManifestChange`,走既有 manifest 刷新与资源投影链路;再用 `pendingResourceFocusRef` - * 定位新卡。这里不重算依赖图、不另写布局逻辑。 - * - * 失败后不清空面板:用户可以用同一份输入直接重试(本地命令没有请求身份,前端不假装 - * 它能幂等重放)。 + * 队列实例与提交回调都要跨渲染保持同一份身份(队列的「已有在途任务」标记是它的内部状态), + * 所以项目路径 / 清单回调 / 规范图判据只能从 ref 读当前值,不能被闭包冻在某一帧。 */ - const submitResourceAssetGeneration = useCallback( - async (input: ResourceCanvasAssetGenerationSubmitInput) => { - const action = resourceAssetGenerationAction; - if (!action) { + const resourceAssetGenerationContextRef = useRef({ + projectPath, + projectId: manifest.projectId, + hasIconSpecReference, + onManifestChange, + manifest, + resources, + activePageCategory, + }); + resourceAssetGenerationContextRef.current = { + projectPath, + projectId: manifest.projectId, + hasIconSpecReference, + onManifestChange, + manifest, + resources, + activePageCategory, + }; + resourceWorkbenchNoticeRef.current = resourceWorkbenchNotice; + /** 入口按钮与折叠把手上显示的在途数量:只数当前项目的未终态任务。 */ + const resourceAssetGenerationInFlightCount = + resourceAssetGenerationTasks.filter( + (task) => + task.projectId === manifest.projectId && + !resourceCanvasAssetGenerationTaskIsTerminal(task), + ).length; + + const replaceResourceAssetGenerationTask = useCallback( + (next: ResourceCanvasAssetGenerationTask) => { + const current = resourceAssetGenerationTasksRef.current; + const nextTasks = current.some((task) => task.taskId === next.taskId) + ? current.map((task) => (task.taskId === next.taskId ? next : task)) + : [...current, next]; + resourceAssetGenerationTasksRef.current = nextTasks; + setResourceAssetGenerationTasks(nextTasks); + }, + [], + ); + + /** + * 一条生成任务收尾后的宿主动作:成功落卡 / 失败给提示 / 即时失败把提交面板带回来。 + * + * 后端把生成结果与 manifest 登记都写完才把记录置为终态,所以这里只做「配对读 + 交给 + * `onManifestChange`」这条既有链路,再用 `pendingResourceFocusRef` 定位新卡;不重算依赖图、 + * 不另写布局逻辑。 + * + * 失败分两类:**后端从未受理**(`record === null`,且这次点击本来就该立刻派发)→ 把提交面板 + * 连草稿一起带回来,错误留在面板里;**受理之后才失败**(生成中失败 / 远端失败 / 轮询超时)→ + * 不重开面板,只在「生成任务」侧栏收口为失败并给一次提示条。 + */ + const handleResourceAssetGenerationSettlement = useCallback( + async (settlement: ResourceCanvasAssetGenerationSettlement) => { + const context = resourceAssetGenerationContextRef.current; + if (settlement.projectId !== context.projectId) { + // 任务可以在切换项目之后才收尾:这时候拿当前项目的路径去刷新清单是错的, + // 账本已经把结果写在它自己的项目里,这里不再动当前项目的状态。 return; } + const submission = resourceAssetGenerationPanelSubmissionRef.current; + if (submission?.taskId === settlement.taskId) { + resourceAssetGenerationPanelSubmissionRef.current = null; + if ( + settlement.status === 'failed' && + settlement.record === null && + submission.dispatchedImmediately + ) { + setResourceAssetGenerationPanelReopen({ + actionId: submission.action.id, + draft: submission.draft, + error: settlement.error ?? '生成素材失败', + }); + setResourceAssetGenerationAction(submission.action); + setResourceWorkbenchNotice(''); + return; + } + } + if (settlement.status !== 'completed' || !settlement.record?.assetId) { + setResourceWorkbenchNotice( + `生成素材失败:${settlement.error ?? '未知原因'}`, + ); + return; + } + const assetId = settlement.record.assetId; const invoke = window.__TAURI__?.core?.invoke; if (!invoke) { - throw new Error('生成素材需要在客户端内执行'); + setResourceWorkbenchNotice('生成结果需要在客户端内读取'); + return; + } + let fresh: Awaited< + ReturnType + > = null; + try { + fresh = await rereadAuthoritativeProjectManifestSnapshot({ + projectPath: context.projectPath, + projectId: context.projectId, + readRevision: async () => + ( + await invoke<{ revision: number }>( + 'get_local_game_project_revision', + { projectPath: context.projectPath }, + ) + ).revision, + readManifest: () => + invoke('get_local_game_manifest', { + projectPath: context.projectPath, + commandId: 'asset.list', + }), + }); + } catch (error) { + setResourceWorkbenchNotice( + error instanceof Error ? error.message : String(error), + ); + return; } - const flowId = crypto.randomUUID(); - const projectId = manifest.projectId; - const result = await withPlatformSessionRefresh(() => - invoke( - 'generate_local_project_asset', - { - projectPath, - kind: input.kind, - prompt: input.prompt, - aspectRatio: input.aspectRatio, - imageSize: input.imageSize, - assetName: input.assetName, - outputPath: resourceCanvasAssetGenerationOutputPath( - action, - hasIconSpecReference, - ), - }, - ), - ); - const fresh = await rereadAuthoritativeProjectManifestSnapshot({ - projectPath, - projectId, - readRevision: async () => - ( - await invoke<{ revision: number }>( - 'get_local_game_project_revision', - { projectPath }, - ) - ).revision, - readManifest: () => - invoke('get_local_game_manifest', { - projectPath, - commandId: 'asset.list', - }), - }); if (!fresh) { - throw new Error( + setResourceWorkbenchNotice( '生成结果已落盘,但清单与版本号未能配对读回,请重新打开项目后确认', ); + return; } - onManifestChange?.(projectPath, fresh.manifest, { + context.onManifestChange?.(context.projectPath, fresh.manifest, { projectId: fresh.projectId, revision: fresh.revision, source: fresh.source, - commitId: result.id, + commitId: assetId, }); + const flowId = `asset-generation:${settlement.taskId}`; activeFocusFlowIdRef.current = flowId; pendingResourceFocusRef.current = { flowId, - saveAttemptId: result.id, - sessionId: result.id, - draftId: result.id, - commitId: result.id, - projectPath, + saveAttemptId: assetId, + sessionId: assetId, + draftId: assetId, + commitId: assetId, + projectPath: context.projectPath, projectId: fresh.projectId, focusGeneration: focusGenerationRef.current, - resourceId: `asset:${result.id}`, + resourceId: `asset:${assetId}`, completed: false, }; setResourceWorkbenchNotice('生成资源已保存,正在同步资源与布局…'); - setResourceAssetGenerationAction(null); }, - [ - hasIconSpecReference, - manifest.projectId, - onManifestChange, - projectPath, - resourceAssetGenerationAction, - ], + [], + ); + + /** + * 生成任务队列:本地排队 + 后端账本轮询的唯一驱动器。 + * + * 队列实例跨渲染保持同一份,「已有在途任务」这个标记才不会因为一次渲染就丢掉。 + */ + const resourceAssetGenerationQueueRef = + useRef(null); + if (resourceAssetGenerationQueueRef.current === null) { + resourceAssetGenerationQueueRef.current = + createResourceCanvasAssetGenerationQueue({ + invoke: (command, args) => { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + return Promise.reject(new Error('生成素材需要在客户端内执行')); + } + return invoke(command, args); + }, + projectPath: () => + resourceAssetGenerationContextRef.current.projectPath, + refreshPlatformSession: withPlatformSessionRefresh, + listTasks: () => + resourceAssetGenerationTasksRef.current.filter( + (task) => + task.projectId === + resourceAssetGenerationContextRef.current.projectId, + ), + replaceTask: (next) => replaceResourceAssetGenerationTask(next), + onSettled: (settlement) => + handleResourceAssetGenerationSettlement(settlement), + }); + } + + /** + * 提交一条图片类生成任务。 + * + * 只做「入队」,生成由队列在后台派发与轮询。**同步返回**:提交面板在点击那一刻就自己关掉了, + * 不等受理、不等排队、不等生成;只有后端从未受理的即时失败才会由收尾回调把面板连草稿一起带回来。 + */ + const submitResourceAssetGeneration = useCallback( + ( + action: ResourceCanvasAssetToolAction, + input: ResourceCanvasAssetGenerationSubmitInput, + ) => { + const queue = resourceAssetGenerationQueueRef.current; + const context = resourceAssetGenerationContextRef.current; + if (!queue) { + setResourceWorkbenchNotice( + '生成任务队列尚未就绪,请重新打开项目后重试', + ); + return; + } + // 「这次点击本来就该立刻派发」:队列里没有在途任务时才是。排在队列后面才派发的任务即使 + // 提交失败,也不该把面板弹回来打断用户。 + const dispatchedImmediately = + !resourceAssetGenerationTasksRef.current.some( + (task) => + task.dispatched && + !resourceCanvasAssetGenerationTaskIsTerminal(task), + ); + const task = createResourceCanvasAssetGenerationTask({ + taskId: crypto.randomUUID(), + action, + prompt: input.prompt, + assetName: input.assetName, + aspectRatio: input.aspectRatio, + imageSize: input.imageSize, + outputPath: resourceCanvasAssetGenerationOutputPath( + action, + context.hasIconSpecReference, + ), + projectId: context.projectId, + nowMillis: Date.now(), + }); + resourceAssetGenerationPanelSubmissionRef.current = { + taskId: task.taskId, + action, + draft: { + prompt: input.prompt, + assetName: input.assetName, + aspectRatio: input.aspectRatio, + imageSize: input.imageSize, + }, + dispatchedImmediately, + }; + setResourceAssetGenerationPanelReopen(null); + setResourceAssetGenerationTasksPanelOpen(true); + setResourceWorkbenchNotice( + `已提交「${input.assetName}」,生成在后台继续,进度见「生成任务」`, + ); + // 终局由 `onSettled` 收口(成功落卡 / 失败收口 / 即时失败重开面板),这里只吞掉拒绝, + // 避免出现未处理的 Promise 拒绝。 + void queue.submit(task).catch(() => undefined); + }, + [], + ); + + /** + * 重开项目时恢复项目内的任务账本。 + * + * 账本落在项目内的 `.agent/runtime/asset-generation-tasks/`,所以历史任务(含上次运行中断 + * 的那些)在这里回到列表;后端已经把没人推进的记录收口为失败,前端不假装它还在跑。 + * + * 读不到账本(旧壳没有这条命令 / 权限拒绝 / 文件读坏 / 返回了非数组)时**保留本地列表**并把 + * 提示落到既有提示条上:静默返回会让用户以为「历史生成任务都不见了」,而抛出去会变成这个 + * effect 的未处理 Promise 拒绝。 + */ + useEffect(() => { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke || !projectPath.trim() || !manifest.projectId.trim()) { + return undefined; + } + const projectId = manifest.projectId; + let cancelled = false; + setResourceAssetGenerationTasksPanelOpen(false); + resourceAssetGenerationPanelSubmissionRef.current = null; + setResourceAssetGenerationPanelReopen(null); + void (async () => { + const reportUnavailable = () => { + if (!cancelled) { + setResourceWorkbenchNotice( + '生成任务列表读取失败,暂时无法恢复历史任务', + ); + } + }; + let records: LocalProjectAssetGenerationTaskRecord[]; + try { + const response = await invoke( + 'list_local_project_asset_generations', + { projectPath }, + ); + if (!Array.isArray(response)) { + reportUnavailable(); + return; + } + records = response; + } catch { + reportUnavailable(); + return; + } + if (cancelled) { + return; + } + const restored = mergeResourceCanvasAssetGenerationTasksWithRecords( + resourceAssetGenerationTasksRef.current.filter( + (task) => task.projectId === projectId, + ), + records, + ); + resourceAssetGenerationTasksRef.current = restored; + setResourceAssetGenerationTasks(restored); + })(); + return () => { + cancelled = true; + }; + }, [manifest.projectId, projectPath]); + + /** + * 「生成任务」面板里点一条已完成任务:复用既有聚焦链定位到它的素材卡。 + * + * **每次点击都必须终局化**:素材不在投影里 / 不在当前栏目 / 被搜索挡住 / 画布还在布局,四种情况 + * 各有结论,不允许留下悬而未决的 intent 与中转提示。所以这里做三件事: + * + * 1. 先按当前投影与清单判一次「这次点击有没有可定位的目标」——没有就直接给可执行结论, + * 连 intent 都不挂(挂上去也没人消费); + * 2. 有目标就挂 intent,并推进 `resourceAssetGenerationFocusRequest`:聚焦 effect 的依赖全是 + * 画布自身状态,不推进这个序号时 effect 不会重跑,点了等于没点(这正是「点了没反应且提示条 + * 永久停在中转文案」的根因); + * 3. 起一个有界兜底:3 秒后仍停在中转文案就收口成可执行提示,绝不把中转态留给用户。 + */ + const focusResourceAssetGenerationTask = useCallback( + (task: ResourceCanvasAssetGenerationTask) => { + const assetId = task.assetId; + if (!assetId) { + return; + } + const context = resourceAssetGenerationContextRef.current; + const resourceId = `asset:${assetId}`; + const target = context.resources.find( + (resource) => resource.id === resourceId, + ); + const locateNotice = '正在定位生成的素材…'; + if (!target) { + // 不在投影里:还没同步到画布,或者素材已经不在项目里。两种都当场给结论, + // 不放 intent 也不留中转提示。 + pendingResourceFocusRef.current = null; + setResourceWorkbenchNotice( + (context.manifest.assets ?? []).some((asset) => asset.id === assetId) + ? '素材已登记但尚未同步到画布,请稍候重试' + : '素材已不在项目里(可能已被删除)', + ); + return; + } + // 手动定位不能复用自动落卡那条 commitId:`focusedCommitIdsRef` 会把同一个 commitId 记为 + // 「已聚焦」,重复点同一条任务就会静默失效,所以这里用一次一点击的 flowId。 + const flowId = `asset-generation-focus:${task.taskId}:${Date.now()}`; + activeFocusFlowIdRef.current = flowId; + pendingResourceFocusRef.current = { + flowId, + saveAttemptId: assetId, + sessionId: assetId, + draftId: assetId, + commitId: flowId, + projectPath: context.projectPath, + projectId: context.projectId, + focusGeneration: focusGenerationRef.current, + resourceId, + completed: false, + }; + if (target.category !== context.activePageCategory) { + // 素材在别的栏目:先切过去(切栏目本身就是 effect 的依赖变化),再让聚焦链在那边定位。 + selectResourceCanvasPage(target.category); + } + setResourceWorkbenchNotice(locateNotice); + setResourceAssetGenerationFocusRequest((current) => current + 1); + window.setTimeout(() => { + if (focusedCommitIdsRef.current.has(flowId)) { + // 真的聚焦过了。 + return; + } + const pending = pendingResourceFocusRef.current; + if (pending?.flowId === flowId) { + if (resourceWorkbenchNoticeRef.current !== locateNotice) { + // 聚焦链已经给出别的结论(例如「被当前搜索条件隐藏」+「清除搜索并定位」)。 + return; + } + pendingResourceFocusRef.current = null; + setResourceWorkbenchNotice( + '未能定位到素材:画布可能仍在布局或素材暂不可见,请稍后重试', + ); + return; + } + if (resourceWorkbenchNoticeRef.current === '') { + // intent 被判 invalid(项目 / 画布已切换)时聚焦链会清掉 intent 与提示:手动点击 + // 不能静默丢弃,给一条能解释「为什么没动」的结论。 + setResourceWorkbenchNotice( + '定位请求已失效(项目或画布已切换),请重新点击定位', + ); + } + }, 3_000); + }, + [selectResourceCanvasPage], ); /** 工具栏「上传」:与资源面板上传同一条「上传 + 配对读清单」链路。 */ @@ -7060,6 +7562,50 @@ export default function ProjectDevelopmentView({ 生成素材 ) : null} + {/* + {/* + 「整理画布」是一枚资源动作,不是第三种排列方式:它排在「生成素材」之后、 + 「管理未完成编辑」之前,与其他资源动作同类相邻,并留在 + `game-workbench-view-actions` 动作区里——外观直接复用该容器既有的动作按钮 + 样式(有边圆角 + secondary 填充),与分段 pill 的模式切换一眼可分;因此不 + 新增任何 CSS。**不要放到这一行的行尾**:行尾会被读成“针对整个工具条”的动作。 + + 画布不再自动重排,整张整理只由这一次显式动作发起:丢掉全部自动坐标、按当前 + 资源与拓扑重算(手动拖过的卡原地不动)。进行中的保存/重算仍然由既有的 + 「保存中 / 布局已保存」状态位反馈。 + */} + + {/* + 「生成任务」入口:常驻可见,开合画布上的任务侧栏;有在途任务时带上数量, + 用户关掉侧栏后一眼就能看出还有几条在跑。 + */} + {pendingResourceEdits.length > 0 || pendingResourceEditsLoadState === 'failed' ? ( + ) : null} + {projects.length > 1 ? ( + + ) : null} + Supervisor} + onHomeOpen={() => undefined} + onProjectsOpen={() => undefined} + onManifestChange={(_path, nextManifest) => + setManifests((current) => { + const projectId = nextManifest.projectId; + return { ...current, [projectId]: nextManifest }; + }) + } + /> + + ); +} + +afterEach(() => { + delete window.__TAURI__; + vi.restoreAllMocks(); +}); + +describe('资源画布手动重排口径', () => { + it('hook:rederiveNow 按 rederive 策略重算自动坐标并写回一次', async () => { + const projectId = 'manual-rederive-project'; + const projectPath = '/tmp/manual-rederive-project'; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: args?.mode, + revision: 3, + positions: [ + { + resourceId: 'resource-b', + section: 'document', + x: 600, + y: 40, + manuallyPlaced: true, + }, + { + resourceId: 'resource-a', + section: 'document', + x: 900, + y: 900, + manuallyPlaced: false, + }, + ], + updatedAt: 300, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: args?.mode, + revision: 4, + positions: args?.positions, + updatedAt: 400, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { + core: { invoke }, + } as unknown as typeof window.__TAURI__; + + const resources: ResourceCanvasItem[] = ['resource-a', 'resource-b'].map( + (id) => ({ + id, + category: 'document' as ProjectResourceCanvasCategory, + subtype: 'agent-result', + label: id, + mediaType: 'text/markdown', + dependencyDepth: 0, + }), + ); + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'type', + resources, + rederiveAutomaticPositions: false, + }), + ); + + await waitFor(() => expect(result.current.ready).toBe(true)); + // 自动卡还停在落后坐标上:preserve 口径不会自己去纠正它。 + expect( + result.current.layout.positions.find( + (position) => position.resourceId === 'resource-a', + ), + ).toMatchObject({ x: 900, y: 900 }); + + await act(async () => { + result.current.rederiveNow(); + }); + + await waitFor(() => + expect( + invoke.mock.calls.filter( + ([command]) => + command === 'update_local_project_resource_canvas_layout', + ), + ).toHaveLength(1), + ); + const written = invoke.mock.calls.find( + ([command]) => command === 'update_local_project_resource_canvas_layout', + )?.[1]?.positions as ProjectResourceCanvasPosition[]; + expect(written).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'resource-a', + x: 0, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-b', + x: 600, + y: 40, + manuallyPlaced: true, + }), + ]), + ); + }); + + it('新素材入库后既有自动卡坐标逐值不变,新卡只补在末尾', async () => { + const tauri = installLayoutTauri({ + projectIdsByPath: { + '/tmp/manual-layout-project': 'manual-layout-project', + }, + }); + render( + , + ); + + await waitFor(() => expect(typeWrites(tauri).length).toBeGreaterThan(0)); + const before = typeWrites(tauri).at(-1)!; + expect( + before.positions.map((position) => position.resourceId), + ).not.toContain(NEW_RESOURCE_ID); + + fireEvent.click( + await screen.findByRole('button', { name: '测试:入库新素材' }), + ); + + await waitFor(() => + expect( + typeWrites(tauri).some((write) => + write.positions.some( + (position) => position.resourceId === NEW_RESOURCE_ID, + ), + ), + ).toBe(true), + ); + const after = typeWrites(tauri).find((write) => + write.positions.some( + (position) => position.resourceId === NEW_RESOURCE_ID, + ), + )!; + + // 核心判据:除新卡外全部既有坐标逐值不变(顺序、分区、手动标记都不许动)。 + expect( + after.positions.filter( + (position) => position.resourceId !== NEW_RESOURCE_ID, + ), + ).toEqual( + before.positions.filter( + (position) => position.resourceId !== NEW_RESOURCE_ID, + ), + ); + }); + + it('依赖画布只在关系图首次就绪时重算一次,之后新增素材不再重排', async () => { + const projectPath = '/tmp/manual-dependency-project'; + const tauri = installLayoutTauri({ + projectIdsByPath: { [projectPath]: 'manual-dependency-project' }, + layoutByScope: { + [`${projectPath}|dependency`]: [ + { + resourceId: 'asset:asset-art-b', + section: 'character', + x: 0, + y: 0, + manuallyPlaced: false, + }, + { + resourceId: 'asset:asset-art-a', + section: 'character', + x: 900, + y: 900, + manuallyPlaced: false, + }, + ], + }, + }); + render( + , + ); + + // 一次性重派生:关系图首次就绪后按最终拓扑把落后的自动坐标对齐一次。 + await waitFor(() => + expect( + dependencyWrites(tauri).some((write) => + write.positions.some( + (position) => + position.resourceId === 'asset:asset-art-a' && + (position.x !== 900 || position.y !== 900), + ), + ), + ).toBe(true), + ); + const before = dependencyWrites(tauri).at(-1)!; + expect( + before.positions.map((position) => position.resourceId), + ).not.toContain(NEW_RESOURCE_ID); + + fireEvent.click( + await screen.findByRole('button', { name: '测试:入库新素材' }), + ); + + await waitFor(() => + expect( + dependencyWrites(tauri).some((write) => + write.positions.some( + (position) => position.resourceId === NEW_RESOURCE_ID, + ), + ), + ).toBe(true), + ); + const after = dependencyWrites(tauri).find((write) => + write.positions.some( + (position) => position.resourceId === NEW_RESOURCE_ID, + ), + )!; + + expect( + after.positions.filter( + (position) => position.resourceId !== NEW_RESOURCE_ID, + ), + ).toEqual( + before.positions.filter( + (position) => position.resourceId !== NEW_RESOURCE_ID, + ), + ); + }); + + it('新素材入库后自动进入视口并被选中', async () => { + const tauri = installLayoutTauri({ + projectIdsByPath: { '/tmp/manual-focus-project': 'manual-focus-project' }, + }); + render( + , + ); + + await waitFor(() => expect(typeWrites(tauri).length).toBeGreaterThan(0)); + expect(selectedResourceIdsInDom()).toEqual([]); + + fireEvent.click( + await screen.findByRole('button', { name: '测试:入库新素材' }), + ); + + await waitFor(() => + expect(new Set(selectedResourceIdsInDom())).toEqual( + new Set([NEW_RESOURCE_ID]), + ), + ); + }); + + it('新素材被搜索条件挡住时走既有「清除搜索并定位」路径', async () => { + const tauri = installLayoutTauri({ + projectIdsByPath: { + '/tmp/manual-focus-hidden-project': 'manual-focus-hidden-project', + }, + }); + render( + , + ); + + await findResourceSelectButton('art-b.png'); + const search = openResourceFilterPanel(); + fireEvent.change(search, { target: { value: 'art-b' } }); + expect(selectedResourceIdsInDom()).toEqual([]); + + fireEvent.click(screen.getByRole('button', { name: '测试:入库新素材' })); + + expect( + await screen.findByText('新资源已保存,但被当前搜索条件隐藏'), + ).not.toBeNull(); + // 搜索条件只由显式动作清除,不静默改用户输入。 + expect(openResourceFilterPanel().value).toBe('art-b'); + fireEvent.click(screen.getByRole('button', { name: '清除搜索并定位' })); + + await waitFor(() => + expect(new Set(selectedResourceIdsInDom())).toEqual( + new Set([NEW_RESOURCE_ID]), + ), + ); + expect(tauri.unexpectedCommands).toEqual([]); + }); + + it('「整理画布」按 rederive 重算自动坐标、保留手动坐标,并给出一次可见反馈', async () => { + const projectPath = '/tmp/manual-rederive-button-project'; + const tauri = installLayoutTauri({ + projectIdsByPath: { + [projectPath]: 'manual-rederive-button-project', + }, + layoutByScope: { + [`${projectPath}|type`]: [ + { + resourceId: 'asset:asset-art-a', + section: 'character', + x: 600, + y: 40, + manuallyPlaced: true, + }, + { + resourceId: 'asset:asset-art-b', + section: 'character', + x: 800, + y: 800, + manuallyPlaced: false, + }, + ], + }, + }); + render( + , + ); + + await waitFor(() => + expect( + document.querySelector('[data-resource-id="asset:asset-art-b"]'), + ).not.toBeNull(), + ); + // 切到「类型」视图:两个排序模式各有一份 sidecar,按钮作用于当前生效的那一份。 + fireEvent.click(screen.getByRole('button', { name: '按类型' })); + // 打开项目 / 切排序 tab 这两步都不该重排:自动卡的落后坐标原样保留。 + expect(typeWrites(tauri)).toEqual([]); + + fireEvent.click(screen.getByRole('button', { name: '整理画布' })); + + await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1)); + expect(typeWrites(tauri)[0]!.positions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'asset:asset-art-b', + section: 'character', + x: 0, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'asset:asset-art-a', + section: 'character', + x: 600, + y: 40, + manuallyPlaced: true, + }), + ]), + ); + expect(await screen.findByText('布局已保存')).not.toBeNull(); + + // 已经整齐之后再按一次不产生第二次落盘:重算结果与当前坐标一致时不写(既有「截断关系图 + // 不得持久化自动布局」用例依赖同一条 `changed` 门)。 + fireEvent.click(screen.getByRole('button', { name: '整理画布' })); + await settleFocusChain(); + expect(typeWrites(tauri)).toHaveLength(1); + }); + + it('「整理画布」不属于「资源排列方式」这组模式切换,而是一枚资源动作按钮', async () => { + const projectPath = '/tmp/manual-layout-surface-project'; + const tauri = installLayoutTauri({ + projectIdsByPath: { [projectPath]: 'manual-layout-surface-project' }, + }); + render( + , + ); + + const sortGroup = await screen.findByRole('group', { + name: '资源排列方式', + }); + // 这组里只有两种排列方式:用户不该把「整理画布」读成第三种排列方式。 + expect( + within(sortGroup) + .getAllByRole('button') + .map((button) => button.getAttribute('aria-label')), + ).toEqual(['按依赖', '按类型']); + expect( + within(sortGroup).queryByRole('button', { name: '整理画布' }), + ).toBeNull(); + + // 它仍是同一行里的同一枚动作按钮,只是搬出了那个 group、也离开了行尾。 + const rederiveButton = screen.getByRole('button', { name: '整理画布' }); + expect(sortGroup.contains(rederiveButton)).toBe(false); + expect(rederiveButton.closest('.game-resource-sort-tabs')).toBeNull(); + const actionsRow = rederiveButton.closest('.game-workbench-view-actions'); + expect(actionsRow).not.toBeNull(); + + // 位置:不再是这一行的最后一个按钮(行尾会被读成"针对整个工具条"的动作), + // 紧跟「生成素材」,并且在排序组左侧。 + const rowButtons = Array.from(actionsRow!.querySelectorAll('button')); + expect(rowButtons.at(-1)).not.toBe(rederiveButton); + const rederiveIndex = rowButtons.indexOf(rederiveButton); + const sortGroupIndex = rowButtons.findIndex((button) => + sortGroup.contains(button), + ); + expect(rederiveIndex).toBeGreaterThanOrEqual(0); + expect(sortGroupIndex).toBeGreaterThanOrEqual(0); + expect(rederiveIndex).toBeLessThan(sortGroupIndex); + expect(rederiveButton.previousElementSibling).toBe( + screen.getByRole('button', { name: '生成素材' }), + ); + + // 语义没变:可点性只跟布局就绪绑定,布局读完后它就是可点的。 + await waitFor(() => + expect( + screen + .getByRole('button', { name: '整理画布' }) + .hasAttribute('disabled'), + ).toBe(false), + ); + }); + + it('首次打开项目与切项目都不触发新素材聚焦跳转', async () => { + const tauri = installLayoutTauri({ + projectIdsByPath: { + '/tmp/manual-open-project-a': 'manual-open-project-a', + '/tmp/manual-open-project-b': 'manual-open-project-b', + }, + }); + render( + , + ); + + await waitFor(() => + expect( + document.querySelector('[data-resource-id="asset:asset-a1"]'), + ).not.toBeNull(), + ); + // 布局读写先跑完,再给聚焦裁决链一次"要是会被误触发就已经触发"的机会。 + await waitFor(() => expect(typeWrites(tauri).length).toBeGreaterThan(0)); + await settleFocusChain(); + expect(selectedResourceIdsInDom()).toEqual([]); + + fireEvent.click(screen.getByRole('button', { name: '测试:切换项目' })); + + await waitFor(() => + expect( + tauri.layoutReads.some( + (read) => read.projectPath === '/tmp/manual-open-project-b', + ), + ).toBe(true), + ); + await waitFor(() => + expect( + document.querySelector('[data-resource-id="asset:asset-b1"]'), + ).not.toBeNull(), + ); + await waitFor(() => + expect( + typeWrites(tauri).some( + (write) => write.projectPath === '/tmp/manual-open-project-b', + ), + ).toBe(true), + ); + await settleFocusChain(); + expect(selectedResourceIdsInDom()).toEqual([]); + }); + + it('资源协调签名仍把新增素材算作变化(重排判据没有被人为掐掉)', () => { + const single = createResourceSignature([ + { + id: 'asset:x', + category: 'character', + subtype: 'character', + label: 'x', + mediaType: 'image/png', + dependencyDepth: 0, + }, + ]); + const doubled = createResourceSignature([ + { + id: 'asset:x', + category: 'character', + subtype: 'character', + label: 'x', + mediaType: 'image/png', + dependencyDepth: 0, + }, + { + id: 'asset:y', + category: 'character', + subtype: 'character', + label: 'y', + mediaType: 'image/png', + dependencyDepth: 0, + }, + ]); + expect(single).not.toBe(doubled); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx b/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx new file mode 100644 index 000000000..b611522f5 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx @@ -0,0 +1,434 @@ +// @vitest-environment jsdom +/** + * 资源卡的棋盘格底必须由**这张图真实的 alpha** 决定,而不是「预览分支是图片」。 + * + * 现场缺陷(验收截图):所有 PNG/JPEG 卡一律铺 CSS 棋盘格,于是「真透明底」与 + * 「AI 把棋盘格画进像素里」在卡面上完全同形,验收时无法区分两者。 + * + * 本文件把整条链路钉住: + * 1. 真的渲染 `ProjectDevelopmentView`,用假的 `read_local_project_image_preview` + * 返回原生头部判据 `hasAlpha`,断言卡片根节点的 `data-preview-has-alpha`; + * 2. 把真实 DOM 上的 `data-preview-kind` / `data-preview-has-alpha` 喂给 + * `styles.css` 的声明级层叠求值,断言只有真透明卡片的 `.game-resource-card-visual` + * 最终生效声明里才有棋盘格(判据取 `background-size: 16px 16px` 与渐变层)。 + * + * 为什么不用 `getComputedStyle(visual).backgroundImage`:jsdom 不加载样式表,且本仓库 + * 测试环境的 cssstyle 解析不了渐变 —— 实测把 `background: linear-gradient(...)` 与 + * `background-image: linear-gradient(...)` 写进 `