diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index fbe737fec..5f3f50505 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -86,7 +86,7 @@ maud = "0.27.0" libc = "0.2" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } [profile.dev] opt-level = 0 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 110b0b918..fc4843f6d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -43,9 +43,9 @@ pub mod tool; pub(crate) use art_manifest::*; use claude_code_cli::*; pub(crate) use claude_code_cli::{ - cancel_direct_claude_code_turn_at, direct_game_creator_claude_code_chat_at, - direct_game_creator_claude_code_home_chat, game_creator_claude_code_cli_route_error, - game_creator_claude_code_cli_version_identity, + cancel_direct_claude_code_turn_at, claude_code_failure_to_llm_error, + direct_game_creator_claude_code_chat_at, direct_game_creator_claude_code_home_chat, + game_creator_claude_code_cli_route_error, game_creator_claude_code_cli_version_identity, }; pub(crate) use codex_app_server::direct_game_creator_codex_chat_at; pub(crate) use codex_app_server::turn_error::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index 65b71e4d6..0ea85c3b7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -747,6 +747,46 @@ fn parse_usage(value: &serde_json::Value) -> Option }) } +/// 把 Claude Code/sidecar 的字符串失败投影到与 Codex app-server 相同的 LlmError 分类。 +/// +/// DirectProject 不能把上游 HTTP 错误统统包装成 Transport:那会把 429、401、5xx 等可识别 +/// 的上游事实显示成“执行通道已断开”。这里只认结构化的状态位置(`Request rejected (N)` / +/// `HTTP N`),其它文本继续保留为 Transport,避免凭关键词猜测。 +pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm::LlmError { + if let Some(status_code) = claude_code_failure_status_code(&detail) { + return platform_llm::LlmError::Upstream { + status_code, + message: detail, + }; + } + if detail.contains("Claude Agent SDK sidecar 回合超时") { + return platform_llm::LlmError::Timeout { attempts: 1 }; + } + if detail.contains("Claude Code 缺少最终回复") { + return platform_llm::LlmError::EmptyResponse; + } + if detail.contains("sidecar 输出不是有效 JSON") || detail.contains("stream-json 包含无效 JSON") + { + return platform_llm::LlmError::Deserialize(detail); + } + platform_llm::LlmError::Transport(detail) +} + +fn claude_code_failure_status_code(detail: &str) -> Option { + ["Request rejected (", "HTTP "].iter().find_map(|marker| { + let tail = detail.split_once(marker)?.1; + let digits = tail + .chars() + .take_while(|character| character.is_ascii_digit()) + .collect::(); + if digits.len() != 3 { + return None; + } + let status_code = digits.parse::().ok()?; + (100..=599).contains(&status_code).then_some(status_code) + }) +} + fn parse_claude_code_result( stdout: &[u8], request: &LlmRunRequest, @@ -758,7 +798,7 @@ fn parse_claude_code_result( let detail = claude_result_error_detail(&value) .map(|detail| format!(":{detail}")) .unwrap_or_default(); - return Err(platform_llm::LlmError::Transport(format!( + return Err(claude_code_failure_to_llm_error(format!( "Claude Code 返回失败终态{detail}" ))); } @@ -1156,6 +1196,29 @@ mod tests { assert!(error.to_string().contains("Authentication failed")); } + #[test] + fn claude_failure_reuses_codex_error_categories() { + assert!(matches!( + claude_code_failure_to_llm_error( + "Claude Code 返回失败终态:API Error: Request rejected (429)".into() + ), + platform_llm::LlmError::Upstream { + status_code: 429, + .. + } + )); + assert!(matches!( + claude_code_failure_to_llm_error( + "Claude Agent SDK sidecar 回合超时:连续 180000 ms 没有任何事件".into() + ), + platform_llm::LlmError::Timeout { attempts: 1 } + )); + assert!(matches!( + claude_code_failure_to_llm_error("Claude Code 缺少最终回复".into()), + platform_llm::LlmError::EmptyResponse + )); + } + #[test] fn parses_direct_stream_result_and_final_text() { let mut observed = Vec::new(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 54a6a023c..a1141d90e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -5300,7 +5300,9 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( observer, ) .await - .map_err(|detail| TurnError::from_model_call(&platform_llm::LlmError::Transport(detail))); + .map_err(|detail| { + TurnError::from_model_call(&crate::agent::claude_code_failure_to_llm_error(detail)) + }); } game_creator_codex_app_server_validate_llm_config(&config.llm).map_err(|error| { TurnError::EnvironmentNotReady(EnvironmentNotReady { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs index 707674fbf..745a9c1c2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs @@ -304,7 +304,7 @@ impl ModelCallKind { native, } => match native { Some(native) => native.is_retryable(), - None => *status_code >= 500, + None => matches!(*status_code, 401 | 408 | 429 | 500..=599), }, Self::PaidCreditsInsufficient => false, Self::EmptyResponse => false, @@ -322,9 +322,16 @@ impl ModelCallKind { } Self::EmptyResponse => Some("模型未返回内容"), Self::PayloadInvalid { .. } => Some("模型回执无法解析"), - Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => { + Self::RequestRejected { native } => { native.as_ref().and_then(NativeKind::public_summary) } + Self::UpstreamFailed { + status_code, + native, + } => native + .as_ref() + .and_then(NativeKind::public_summary) + .or_else(|| upstream_status_summary(*status_code)), } } @@ -341,13 +348,40 @@ impl ModelCallKind { Self::EmptyResponse | Self::PayloadInvalid { .. } => { Some("模型未给出可用的回执,请重试;如持续失败请检查项目诊断") } - Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => { - native.as_ref().and_then(NativeKind::recovery_hint) - } + Self::RequestRejected { native } => native.as_ref().and_then(NativeKind::recovery_hint), + Self::UpstreamFailed { + status_code, + native, + } => native + .as_ref() + .and_then(NativeKind::recovery_hint) + .or_else(|| upstream_status_recovery_hint(*status_code)), } } } +fn upstream_status_summary(status_code: u16) -> Option<&'static str> { + Some(match status_code { + 401 => "上游服务拒绝认证(HTTP 401)", + 403 => "上游服务拒绝访问(HTTP 403)", + 408 => "上游请求超时(HTTP 408)", + 429 => "上游服务返回 HTTP 429", + 500..=599 => "上游服务暂时不可用", + _ => return None, + }) +} + +fn upstream_status_recovery_hint(status_code: u16) -> Option<&'static str> { + Some(match status_code { + 401 => "请重新登录陶泥儿后重试;如持续失败请检查项目诊断", + 403 => "请检查当前账号的上游访问权限后重试;如持续失败请检查项目诊断", + 408 => "上游请求超时,请稍后重试;如持续失败请检查项目诊断", + 429 => "上游服务暂时繁忙(HTTP 429),请稍后重试;如持续失败请检查项目诊断", + 500..=599 => "上游服务暂时不可用,请稍后重试;如持续失败请检查项目诊断", + _ => return None, + }) +} + // ══════════════════════════════════════════════════════════════════════════════════════════ // 入队失败:`#[tauri::command]` 的 `Err`,这一轮没有开始也没有进队列 // ══════════════════════════════════════════════════════════════════════════════════════════ @@ -1327,6 +1361,31 @@ mod tests { assert!(!projected.is_model_repairable()); } + #[test] + fn upstream_http_status_keeps_codex_style_summary_and_retry_guidance() { + let rate_limited = TurnError::from_model_call(&LlmError::Upstream { + status_code: 429, + message: "Claude Code 返回失败终态:API Error: Request rejected (429)".into(), + }); + assert_eq!(rate_limited.public_summary(), Some("上游服务返回 HTTP 429")); + assert_eq!( + rate_limited.recovery_hint(), + Some("上游服务暂时繁忙(HTTP 429),请稍后重试;如持续失败请检查项目诊断") + ); + assert!(rate_limited.is_retryable()); + assert!(!rate_limited.is_model_repairable()); + + let unauthorized = TurnError::from_model_call(&LlmError::Upstream { + status_code: 401, + message: "Claude Code 返回失败终态:HTTP 401".into(), + }); + assert_eq!( + unauthorized.public_summary(), + Some("上游服务拒绝认证(HTTP 401)") + ); + assert!(unauthorized.is_retryable()); + } + /// 反馈判据:原生分类里"再跑一次也不会变"的那些不再反馈给模型。 #[test] fn terminal_native_kinds_are_not_fed_back_to_the_model() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 214a360e5..c45f849b8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -567,6 +567,23 @@ pub(crate) fn upload_local_asset_at( file_name: &str, media_type: &str, bytes: &[u8], +) -> Result { + upload_local_asset_at_with_category(root, file_name, media_type, bytes, None) +} + +/// 带**显式入口栏目**的上传:只给 GUI 工具栏上传用(前端 `targetCategory`)。 +/// +/// 上传的 manifest `kind` 只由内容证据推导(图片 / 视频 / 代码 → `unclassified`), +/// 与栏目不是同一套词汇:在栏目画布里上传素材时,用户选定的栏目才是归属真相, +/// 否则素材会落进「待归类」、在上传它的那一栏里看不见。 +/// 取值校验与失败关闭沿用 [`register_local_asset_entry_with_category`]: +/// 非法值报错、绝不回退到 kind 派生;其它调用方继续走 [`upload_local_asset_at`]。 +pub(crate) fn upload_local_asset_at_with_category( + root: &Path, + file_name: &str, + media_type: &str, + bytes: &[u8], + target_category: Option<&str>, ) -> Result { if bytes.is_empty() { return Err("上传文件不能为空".to_string()); @@ -584,7 +601,7 @@ pub(crate) fn upload_local_asset_at( } crate::write_game_creator_private_file(&absolute_path, bytes, "上传文件")?; - register_local_asset_entry( + register_local_asset_entry_with_category( root, &relative_path, uploaded_asset_kind(file_name, media_type), @@ -602,6 +619,7 @@ pub(crate) fn upload_local_asset_at( generation_kind: None, reference_resource_ids: Vec::new(), }, + target_category, ) } @@ -2375,6 +2393,70 @@ mod tests { ); } + /// Issue 359:工具栏上传带上入口栏目,素材才不会落进「待归类」。 + /// + /// 上传的 manifest `kind` 只由内容证据推导,图片 / 视频 / 代码都派生成 `unclassified`; + /// 在栏目画布(如「角色与对象」)里上传时栏目才是归属真相。不传时保持既有行为, + /// 非法值失败关闭且不留下半成品登记。 + #[test] + fn upload_registers_into_the_explicit_entry_category() { + fn upload_category(root: &Path, asset_id: &str) -> GameCreationAppAssetCategory { + read_existing_manifest_for_project(root) + .expect("read manifest") + .assets + .into_iter() + .find(|asset| asset.id == asset_id) + .expect("uploaded asset is registered") + .category + } + + let temporary = tempfile::tempdir().expect("tempdir"); + let root = temporary.path(); + crate::project::init_local_game_project_at(root, "upload-category-test", "上传入口栏目") + .expect("init project"); + + let uploaded = upload_local_asset_at_with_category( + root, + "hero.png", + "image/png", + b"png-bytes", + Some("character"), + ) + .expect("upload with an entry category"); + assert_eq!( + upload_category(root, &uploaded.id), + GameCreationAppAssetCategory::Character + ); + + let plain = upload_local_asset_at(root, "plain.png", "image/png", b"png-bytes") + .expect("upload without an entry category"); + assert_eq!( + upload_category(root, &plain.id), + GameCreationAppAssetCategory::Unclassified + ); + + // 非法入口栏目(栏目侧伪值 `version` 不在枚举里)失败关闭:不新增登记。 + let assets_before = read_existing_manifest_for_project(root) + .expect("read manifest") + .assets + .len(); + assert!(upload_local_asset_at_with_category( + root, + "bad.png", + "image/png", + b"png-bytes", + Some("version"), + ) + .is_err()); + assert_eq!( + read_existing_manifest_for_project(root) + .expect("read manifest") + .assets + .len(), + assets_before + ); + } + /// 画板导出推断出的 kind 必须已经是 canonical 值。 #[test] fn canvas_export_asset_kind_is_always_canonical() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs index 098f43841..712c41fbd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs @@ -1,4 +1,5 @@ use std::fs; +use std::future::Future; use std::path::PathBuf; use std::time::Duration; #[cfg(windows)] @@ -120,8 +121,19 @@ enum OwnedBrowserLaunchError { } impl OwnedBrowserLaunchError { - fn is_timeout(self) -> bool { - matches!(self, Self::WsTimeout | Self::ConnectTimeout) + fn stage(self) -> BrowserLaunchStage { + match self { + Self::SpawnFailed => BrowserLaunchStage::Spawn, + Self::WsTimeout | Self::WsFailed => BrowserLaunchStage::WsHandshake, + Self::ConnectTimeout | Self::ConnectFailed => BrowserLaunchStage::CdpConnect, + } + } + + fn is_recoverable(self) -> bool { + matches!( + self, + Self::WsTimeout | Self::WsFailed | Self::ConnectTimeout | Self::ConnectFailed + ) } fn code(self) -> &'static str { @@ -135,6 +147,118 @@ impl OwnedBrowserLaunchError { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BrowserLaunchStage { + Setup, + Spawn, + WsHandshake, + CdpConnect, +} + +impl BrowserLaunchStage { + fn as_str(self) -> &'static str { + match self { + Self::Setup => "setup", + Self::Spawn => "spawn", + Self::WsHandshake => "ws-handshake", + Self::CdpConnect => "cdp-connect", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct BrowserLaunchFailure { + code: &'static str, + stage: BrowserLaunchStage, + recoverable: bool, + subprocess_exited: bool, + cleanup_confirmed: bool, +} + +impl BrowserLaunchFailure { + fn setup(code: &'static str) -> Self { + Self { + code, + stage: BrowserLaunchStage::Setup, + recoverable: false, + subprocess_exited: true, + cleanup_confirmed: true, + } + } + + fn from_launch_error( + error: OwnedBrowserLaunchError, + subprocess_exited: bool, + cleanup_confirmed: bool, + ) -> Self { + Self { + code: error.code(), + stage: error.stage(), + recoverable: error.is_recoverable(), + subprocess_exited, + cleanup_confirmed, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct BrowserRecoveryFailure { + first: BrowserLaunchFailure, + final_attempt: Option, +} + +impl BrowserRecoveryFailure { + fn diagnostic(self) -> String { + match self.final_attempt { + Some(final_attempt) => format!( + "browser-recovery-failed: initial-stage={} final-stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=true initial-cause={} final-cause={}", + self.first.stage.as_str(), + final_attempt.stage.as_str(), + final_attempt.subprocess_exited, + final_attempt.cleanup_confirmed, + self.first.code, + final_attempt.code, + ), + None => format!( + "{}: stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=false cause={}", + if self.first.recoverable { + "browser-recovery-blocked" + } else { + "browser-launch-failed" + }, + self.first.stage.as_str(), + self.first.subprocess_exited, + self.first.cleanup_confirmed, + self.first.code, + ), + } + } +} + +async fn launch_with_one_recovery(mut launch: F) -> Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let first = match launch().await { + Ok(value) => return Ok(value), + Err(error) => error, + }; + if !first.recoverable || !first.subprocess_exited || !first.cleanup_confirmed { + return Err(BrowserRecoveryFailure { + first, + final_attempt: None, + }); + } + match launch().await { + Ok(value) => Ok(value), + Err(final_attempt) => Err(BrowserRecoveryFailure { + first, + final_attempt: Some(final_attempt), + }), + } +} + type BrowserStderrReader = futures::io::BufReader; /// 复刻 chromiumoxide 私有 ws_url_from_output 的语义(读 stderr 直到 @@ -224,22 +348,22 @@ impl BrowserProcessGuard { async fn confirm_reaped(&mut self) -> Result<(), String> { #[cfg(windows)] { - if let Some(job) = &self.job { - let deadline = Instant::now() + BROWSER_CLOSE_TIMEOUT; - loop { - if job - .is_empty() - .map_err(|_| "browser-tree-reap-unconfirmed".to_string())? - { - return Ok(()); - } - if Instant::now() >= deadline { - return Err("browser-tree-reap-unconfirmed".into()); - } - tokio::time::sleep(Duration::from_millis(20)).await; + let Some(job) = &self.job else { + return Err("browser-tree-reap-unconfirmed".into()); + }; + let deadline = Instant::now() + BROWSER_CLOSE_TIMEOUT; + loop { + if job + .is_empty() + .map_err(|_| "browser-tree-reap-unconfirmed".to_string())? + { + return Ok(()); } + if Instant::now() >= deadline { + return Err("browser-tree-reap-unconfirmed".into()); + } + tokio::time::sleep(Duration::from_millis(20)).await; } - Ok(()) } #[cfg(not(windows))] { @@ -281,24 +405,34 @@ impl OwnedBrowser { let waited = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.process.child.wait()).await; self.handler_task.abort(); self.drain_task.abort(); - if matches!(close, Ok(Ok(_))) && matches!(waited, Ok(Ok(_))) { + if matches!(close, Ok(Ok(_))) + && matches!(waited, Ok(Ok(_))) + && self.process.confirm_reaped().await.is_ok() + { return Ok(()); } - if !self.process.reap().await { + let subprocess_exited = self.process.reap().await; + if !subprocess_exited { // 进程退出未确认:保留目录与 owner.json,留待下次启动或 // 预检时由跨会话清扫收割,而不是删掉证据让清扫失明。 if let Some(temp) = self.temp.take() { std::mem::forget(temp); } - return Err("browser-cleanup-unconfirmed".into()); + return Err( + "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=false cleanup-confirmed=false recovery-retried=false" + .into(), + ); } - let confirmed = self.process.confirm_reaped().await; - if confirmed.is_err() { + if self.process.confirm_reaped().await.is_err() { if let Some(temp) = self.temp.take() { std::mem::forget(temp); } + return Err( + "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=true cleanup-confirmed=false recovery-retried=false" + .into(), + ); } - confirmed + Ok(()) } } @@ -310,24 +444,112 @@ impl Drop for OwnedBrowser { } } -/// 自拉浏览器并完成 CDP 连接。spawn 与 Job 绑定之间存在极小的逸出 -/// 窗口:绑定前产生的子进程未入 Job——Unix 上随 root 死亡级联退出; -/// Windows 没有这种级联,但窗口只有毫秒级(Chrome 此时尚未拉起子 -/// 进程),真发生泄漏时临时目录因被占用而保留,留待系统或用户清理。 -/// 绑定之后的一切子进程由 Job 全覆盖。 +async fn cleanup_failed_launch( + mut process: BrowserProcessGuard, + temp: TempDir, + error: OwnedBrowserLaunchError, + tree_control_confirmed: bool, +) -> BrowserLaunchFailure { + let subprocess_exited = process.reap().await; + let tree_reaped = if tree_control_confirmed && subprocess_exited { + #[cfg(windows)] + { + process.job.is_none() || process.confirm_reaped().await.is_ok() + } + #[cfg(not(windows))] + { + process.confirm_reaped().await.is_ok() + } + } else { + false + }; + drop(process); + let cleanup_confirmed = if tree_reaped { + temp.close().is_ok() + } else { + // 保留 Profile 和 owner.json;后续清扫不得因证据丢失猜测归属。 + let _ = temp.keep(); + false + }; + BrowserLaunchFailure::from_launch_error(error, subprocess_exited, cleanup_confirmed) +} + +/// 自拉浏览器并完成 CDP 连接。Windows 先把 root 放入 KILL_ON_JOB_CLOSE +/// 的 Job,再把绑定瞬间已经出现的整棵子树纳入同一 Job;之后由 Job 自动 +/// 覆盖新建子进程。无法证明覆盖完整时先收束整棵已知进程树,不放行浏览器。 async fn launch_owned_browser( config: BrowserConfig, executable: &std::path::Path, temp: TempDir, -) -> Result { - let child = config - .launch() - .map_err(|_| OwnedBrowserLaunchError::SpawnFailed)?; - // 无法证明整树可收割时拒绝放行浏览器;child 随 drop 由 kill_on_drop 收尾。 +) -> Result { + let child = match config.launch() { + Ok(child) => child, + Err(_) => { + return Err(BrowserLaunchFailure::from_launch_error( + OwnedBrowserLaunchError::SpawnFailed, + true, + true, + )); + } + }; + #[cfg(windows)] + let root_identity = child.inner.id().and_then(|pid| { + crate::process_identity::external_agent_runner_process_start_identity(pid) + .ok() + .flatten() + }); #[cfg(windows)] let job = match WindowsProcessJob::assign_tokio(&child.inner) { - Ok(job) => Some(job), - Err(_) => return Err(OwnedBrowserLaunchError::SpawnFailed), + Ok(job) => { + let (Some(root_pid), Some(root_identity)) = + (child.inner.id(), root_identity.as_deref()) + else { + let process = BrowserProcessGuard { + child, + job: Some(job), + }; + return Err(cleanup_failed_launch( + process, + temp, + OwnedBrowserLaunchError::SpawnFailed, + false, + ) + .await); + }; + if super::sweep::ensure_browser_tree_in_job(root_pid, root_identity, &job).is_ok() { + Some(job) + } else { + let tree_reaped = + super::sweep::kill_browser_process_tree(root_pid, root_identity).is_ok(); + let process = BrowserProcessGuard { + child, + job: Some(job), + }; + return Err(cleanup_failed_launch( + process, + temp, + OwnedBrowserLaunchError::SpawnFailed, + tree_reaped, + ) + .await); + } + } + Err(_) => { + let tree_reaped = match (child.inner.id(), root_identity.as_deref()) { + (Some(root_pid), Some(root_identity)) => { + super::sweep::kill_browser_process_tree(root_pid, root_identity).is_ok() + } + _ => false, + }; + let process = BrowserProcessGuard { child, job: None }; + return Err(cleanup_failed_launch( + process, + temp, + OwnedBrowserLaunchError::SpawnFailed, + tree_reaped, + ) + .await); + } }; let mut process = BrowserProcessGuard { child, @@ -336,17 +558,13 @@ async fn launch_owned_browser( }; // 跨会话清扫的身份锚点:写入失败时该目录之后按旧残留只删不杀。 if let Some(pid) = process.child.inner.id() { - super::sweep::write_browser_process_owner(temp.path(), pid, executable); + let _ = super::sweep::write_browser_process_owner(temp.path(), pid, executable); } let (url, reader) = match devtools_ws_url_from_stderr(&mut process.child, BROWSER_TIMEOUT).await { Ok(pair) => pair, Err(error) => { - // 收割未确认时保留目录与 owner.json,交给跨会话清扫。 - if !process.reap().await { - std::mem::forget(temp); - } - return Err(error); + return Err(cleanup_failed_launch(process, temp, error, true).await); } }; let drain_task = spawn_stderr_drain(reader); @@ -359,17 +577,23 @@ async fn launch_owned_browser( Ok(Ok(pair)) => pair, Ok(Err(_)) => { drain_task.abort(); - if !process.reap().await { - std::mem::forget(temp); - } - return Err(OwnedBrowserLaunchError::ConnectFailed); + return Err(cleanup_failed_launch( + process, + temp, + OwnedBrowserLaunchError::ConnectFailed, + true, + ) + .await); } Err(_) => { drain_task.abort(); - if !process.reap().await { - std::mem::forget(temp); - } - return Err(OwnedBrowserLaunchError::ConnectTimeout); + return Err(cleanup_failed_launch( + process, + temp, + OwnedBrowserLaunchError::ConnectTimeout, + true, + ) + .await); } }; let handler_task = tokio::spawn(async move { @@ -388,22 +612,31 @@ async fn launch_owned_browser( }) } +async fn launch_browser_attempt( + executable: &std::path::Path, + proxy_bypass_list: &str, +) -> Result { + let temporary = create_browser_process_temp_dir() + .map_err(|_| BrowserLaunchFailure::setup("browser-temp-unavailable"))?; + let config = browser_config(executable, &temporary, proxy_bypass_list) + .map_err(|_| BrowserLaunchFailure::setup("browser-config-invalid"))?; + launch_owned_browser(config, executable, temporary).await +} + +async fn launch_browser_with_recovery( + executable: &std::path::Path, + proxy_bypass_list: &str, +) -> Result { + launch_with_one_recovery(|| launch_browser_attempt(executable, proxy_bypass_list)) + .await + .map_err(|failure| failure.diagnostic()) +} + /// 仅验证浏览器启动和真实 CDP,不加载项目、不生成试玩凭证。 /// 每次独立 profile;既不串行化其它工具,也不继承 Codex 的临时 HOME。 pub(crate) async fn check_browser_health() -> Result { let discovered = discover_chrome_or_edge().map_err(|_| "browser-not-found")?; - let temporary = create_browser_process_temp_dir().map_err(|_| "browser-temp-unavailable")?; - let config = browser_config(&discovered.executable_path, &temporary, "<-loopback>") - .map_err(|_| "browser-config-invalid")?; - let owned = launch_owned_browser(config, &discovered.executable_path, temporary) - .await - .map_err(|error| { - if error.is_timeout() { - "browser-start-timeout" - } else { - "browser-start-failed" - } - })?; + let owned = launch_browser_with_recovery(&discovered.executable_path, "<-loopback").await?; let version = tokio::time::timeout(Duration::from_secs(5), owned.browser().version()).await; if owned.shutdown().await.is_err() { return Err("browser-cleanup-failed".into()); @@ -460,23 +693,19 @@ pub(crate) async fn validate_local_preview_in_browser_with_cancellation( let preview_url = validate_input(&input)?; prepare_evidence_root(&input.evidence_root)?; let browser_executable = discover_chrome_or_edge()?; - let browser_temp = create_browser_process_temp_dir()?; let proxy_bypass_list = preview_proxy_bypass_list(&preview_url); - let config = browser_config( - &browser_executable.executable_path, - &browser_temp, - &proxy_bypass_list, - )?; - - let owned = launch_owned_browser(config, &browser_executable.executable_path, browser_temp) - .await - .map_err(|error| { - if error.is_timeout() { - "启动浏览器超时".to_string() - } else { - format!("启动浏览器失败:{}", error.code()) - } - })?; + let owned = + launch_browser_with_recovery(&browser_executable.executable_path, &proxy_bypass_list) + .await + .map_err(|error| { + if error.starts_with("browser-recovery-") + || error.starts_with("browser-launch-failed") + { + error + } else { + format!("启动浏览器失败:{error}") + } + })?; let work = run_browser_validation( owned.browser(), @@ -499,10 +728,8 @@ pub(crate) async fn validate_local_preview_in_browser_with_cancellation( _=cancelled => Err("宿主已停止本轮浏览器验证".to_string()), }; - if owned.shutdown().await.is_err() { - return Err( - "browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程".into(), - ); + if let Err(error) = owned.shutdown().await { + return Err(error); } let mut result = validation?; result.completed_at_unix_ms = unix_time_ms(); @@ -583,6 +810,88 @@ mod health_tests { assert_eq!(config.viewport, Some(Viewport::default())); } + #[tokio::test] + async fn browser_ws_failure_retries_after_confirmed_cleanup_with_new_profile() { + let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let profiles = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let attempts_for_launch = std::sync::Arc::clone(&attempts); + let profiles_for_launch = std::sync::Arc::clone(&profiles); + let result = launch_with_one_recovery(move || { + let attempt = attempts_for_launch.fetch_add(1, std::sync::atomic::Ordering::AcqRel); + let profiles = std::sync::Arc::clone(&profiles_for_launch); + async move { + let temporary = tempfile::tempdir().unwrap(); + profiles + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(temporary.path().to_path_buf()); + if attempt == 0 { + drop(temporary); + Err(BrowserLaunchFailure::from_launch_error( + OwnedBrowserLaunchError::WsFailed, + true, + true, + )) + } else { + drop(temporary); + Ok(()) + } + } + }) + .await; + assert!(result.is_ok()); + assert_eq!(attempts.load(std::sync::atomic::Ordering::Acquire), 2); + let profiles = profiles + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + assert_eq!(profiles.len(), 2); + assert_ne!(profiles[0], profiles[1]); + } + + #[tokio::test] + async fn browser_recovery_does_not_retry_when_cleanup_is_unconfirmed() { + let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let attempts_for_launch = std::sync::Arc::clone(&attempts); + let failure = launch_with_one_recovery(move || { + attempts_for_launch.fetch_add(1, std::sync::atomic::Ordering::AcqRel); + async { + Err::<(), BrowserLaunchFailure>(BrowserLaunchFailure::from_launch_error( + OwnedBrowserLaunchError::ConnectTimeout, + false, + false, + )) + } + }) + .await + .unwrap_err(); + let diagnostic = failure.diagnostic(); + assert_eq!(attempts.load(std::sync::atomic::Ordering::Acquire), 1); + assert!(diagnostic.contains("stage=cdp-connect")); + assert!(diagnostic.contains("subprocess-exited=false")); + assert!(diagnostic.contains("cleanup-confirmed=false")); + assert!(diagnostic.contains("recovery-retried=false")); + } + + #[tokio::test] + async fn consecutive_browser_failures_keep_both_recovery_stages_and_safe_diagnostics() { + let failure = launch_with_one_recovery(|| async { + Err::<(), BrowserLaunchFailure>(BrowserLaunchFailure::from_launch_error( + OwnedBrowserLaunchError::WsFailed, + true, + true, + )) + }) + .await + .unwrap_err(); + let diagnostic = failure.diagnostic(); + assert!(diagnostic.contains("initial-stage=ws-handshake")); + assert!(diagnostic.contains("final-stage=ws-handshake")); + assert!(diagnostic.contains("subprocess-exited=true")); + assert!(diagnostic.contains("cleanup-confirmed=true")); + assert!(diagnostic.contains("recovery-retried=true")); + assert!(!diagnostic.contains("/tmp")); + } + #[tokio::test] #[ignore = "requires an installed Chrome/Chromium/Edge; local CDP only"] async fn real_browser_health_checks_can_run_concurrently() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs index d33a4a2ef..0a66c5762 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs @@ -12,6 +12,8 @@ use serde::{Deserialize, Serialize}; use super::discovery::system_browser_candidates; use super::process::{browser_process_temp_root, BROWSER_TEMP_PREFIX}; +#[cfg(windows)] +use crate::process_session::WindowsProcessJob; const OWNER_FILE: &str = "owner.json"; const OWNER_SCHEMA: &str = "agc-browser-process.v1"; @@ -34,13 +36,17 @@ pub(super) struct BrowserProcessOwner { /// 运行期 launch 的身份锚点;任何一步拿不到身份证明都不写, /// 该目录之后按旧残留只删不杀。 -pub(super) fn write_browser_process_owner(temp_root: &Path, browser_pid: u32, executable: &Path) { +pub(super) fn write_browser_process_owner( + temp_root: &Path, + browser_pid: u32, + executable: &Path, +) -> bool { let identity = crate::process_identity::external_agent_runner_process_start_identity(browser_pid); let owner_identity = crate::process_identity::external_agent_runner_process_start_identity(std::process::id()); let (Ok(Some(identity)), Ok(Some(owner_identity))) = (identity, owner_identity) else { - return; + return false; }; let owner = BrowserProcessOwner { schema_version: OWNER_SCHEMA.into(), @@ -52,9 +58,9 @@ pub(super) fn write_browser_process_owner(temp_root: &Path, browser_pid: u32, ex created_unix_ms: super::evidence::unix_time_ms(), }; let Ok(bytes) = serde_json::to_vec_pretty(&owner) else { - return; + return false; }; - let _ = fs::write(temp_root.join(OWNER_FILE), bytes); + fs::write(temp_root.join(OWNER_FILE), bytes).is_ok() } fn read_browser_process_owner(dir: &Path) -> Option { @@ -115,34 +121,142 @@ fn trusted_browser_executable(path: &Path) -> bool { } /// 杀前复核:PID 对应的活进程镜像必须就是 owner.json 声明的那个可执行 -/// 文件。仅核对字符串不够——/tmp 全局可写时,同机其他用户可以伪造 -/// owner.json 把 browser_pid 指到本用户的任意进程借清扫杀之。 -/// Linux 进一步要求命令行声明本目录的 profile,把 PID 绑到这份配置 -/// 目录,挡住同用户伪造 owner.json 指向正在使用的浏览器。 +/// 文件,命令行还必须绑定同一份临时 Profile;只核对镜像会把其它 AGC +/// 实例或用户 Edge 误认成本轮浏览器。 #[cfg(windows)] -fn live_process_executable_matches(pid: u32, expected: &Path, _profile_dir: &Path) -> bool { - // 已知残余风险:Windows 读他进程命令行成本高(PEB/WMI),此处只核对 - // 镜像;同用户伪造 owner.json 指向正在使用的浏览器时可误杀它。 +fn windows_process_command_line(pid: u32) -> Option { + use std::ffi::c_void; + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ, + }; + + #[repr(C)] + struct UnicodeString { + length: u16, + maximum_length: u16, + buffer: *const u16, + } + + #[link(name = "ntdll")] + unsafe extern "system" { + fn NtQueryInformationProcess( + process: *mut c_void, + information_class: u32, + information: *mut c_void, + information_length: u32, + return_length: *mut u32, + ) -> i32; + } + + let process = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, 0, pid) }; + if process.is_null() { + return None; + } + let mut required = 0; + let _ = + unsafe { NtQueryInformationProcess(process, 60, std::ptr::null_mut(), 0, &mut required) }; + if required == 0 || required > 64 * 1024 { + unsafe { CloseHandle(process) }; + return None; + } + let mut buffer = vec![0u8; required as usize]; + let status = unsafe { + NtQueryInformationProcess( + process, + 60, + buffer.as_mut_ptr().cast(), + required, + &mut required, + ) + }; + unsafe { CloseHandle(process) }; + if status != 0 { + return None; + } + let command_line = unsafe { &*(buffer.as_ptr().cast::()) }; + if command_line.buffer.is_null() || command_line.length == 0 || command_line.length % 2 != 0 { + return None; + } + let text = unsafe { + std::slice::from_raw_parts(command_line.buffer, command_line.length as usize / 2) + }; + String::from_utf16(text).ok() +} + +#[cfg(windows)] +fn windows_command_line_arguments(command_line: &str) -> Option> { + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::UI::Shell::CommandLineToArgvW; + + let wide = command_line + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + let mut count = 0; + let argv = unsafe { CommandLineToArgvW(wide.as_ptr(), &mut count) }; + if argv.is_null() || count < 0 { + if !argv.is_null() { + unsafe { LocalFree(argv.cast()) }; + } + return None; + } + let result = unsafe { std::slice::from_raw_parts(argv, count as usize) } + .iter() + .map(|argument| { + if argument.is_null() { + return None; + } + let mut length = 0; + unsafe { + while *argument.add(length) != 0 { + length += 1; + } + String::from_utf16(std::slice::from_raw_parts(*argument, length)).ok() + } + }) + .collect::>>(); + unsafe { LocalFree(argv.cast()) }; + result +} + +#[cfg(windows)] +fn command_line_contains_profile(command_line: &str, profile_dir: &Path) -> bool { + const MARKER: &str = "--user-data-dir="; + let Some(arguments) = windows_command_line_arguments(command_line) else { + return false; + }; + arguments.iter().enumerate().any(|(index, argument)| { + let value = argument.strip_prefix(MARKER).or_else(|| { + (argument == "--user-data-dir") + .then(|| arguments.get(index + 1).map(String::as_str)) + .flatten() + }); + value.is_some_and(|value| same_executable_path(Path::new(value), profile_dir)) + }) +} + +#[cfg(windows)] +fn live_process_executable_matches(pid: u32, expected: &Path, profile_dir: &Path) -> bool { use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::System::Threading::{ OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION, }; - // SAFETY: 句柄非空时由 CloseHandle 释放;打开失败按不匹配处理(fail-closed)。 let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; if handle.is_null() { return false; } let mut buffer = [0u16; 1024]; let mut length = buffer.len() as u32; - // SAFETY: buffer 可写,length 先传入容量、返回实际长度。 let okay = unsafe { QueryFullProcessImageNameW(handle, 0, buffer.as_mut_ptr(), &mut length) }; - // SAFETY: handle 是本函数持有的合法句柄。 unsafe { CloseHandle(handle) }; if okay == 0 || length == 0 { return false; } let actual = std::path::PathBuf::from(String::from_utf16_lossy(&buffer[..length as usize])); same_executable_path(&actual, expected) + && windows_process_command_line(pid) + .is_some_and(|command_line| command_line_contains_profile(&command_line, profile_dir)) } #[cfg(target_os = "linux")] @@ -181,7 +295,10 @@ fn directory_owned_by_current_user(dir: &Path) -> bool { .unwrap_or(false) } -fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<(), String> { +pub(super) fn kill_browser_process_tree( + root_pid: u32, + expected_identity: &str, +) -> Result<(), String> { #[cfg(windows)] { // 追踪所有经确认属于这棵树的 PID;只有它们全部从快照中消失才判 @@ -190,15 +307,17 @@ fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<( for _ in 0..TREE_KILL_MAX_PASSES { let snapshot = windows_process_snapshot()?; let root_present = snapshot.iter().any(|(pid, _)| *pid == root_pid); - if root_present && process_identity_matches(root_pid, expected_identity) { + if root_present { + if !process_identity_matches(root_pid, expected_identity) { + return Err("browser-sweep-root-identity-mismatch".into()); + } // root 仍是目标浏览器:发现并追踪当前整棵子树。 for pid in windows_process_tree_pids_from(&snapshot, root_pid)? { track_process(&mut tracked, pid); } - } else if !root_present { + } else { // root 已退出且 PID 未被复用:发现临终前才拉起、仍挂在旧父 - // PID 上的孤儿子进程。PID 已被复用时不做发现,避免误认 - // 复用者的子进程。 + // PID 上的孤儿子进程。PID 已被复用时不会进入此分支。 for (pid, ppid) in &snapshot { if *ppid == root_pid { track_process(&mut tracked, *pid); @@ -323,6 +442,62 @@ fn windows_process_tree_pids_from( Ok(tree) } +#[cfg(windows)] +pub(super) fn ensure_browser_tree_in_job( + root_pid: u32, + root_identity: &str, + job: &WindowsProcessJob, +) -> Result<(), String> { + for _ in 0..TREE_KILL_MAX_PASSES { + let snapshot = windows_process_snapshot()?; + if !snapshot.iter().any(|(pid, _)| *pid == root_pid) + || !process_identity_matches(root_pid, root_identity) + { + return Err("browser-job-root-identity-unconfirmed".into()); + } + let tree = windows_process_tree_pids_from(&snapshot, root_pid)?; + if tree.is_empty() { + return Err("browser-job-root-exited-before-coverage".into()); + } + let mut members = Vec::with_capacity(tree.len()); + for pid in tree { + let identity = if pid == root_pid { + root_identity.to_string() + } else { + crate::process_identity::external_agent_runner_process_start_identity(pid) + .ok() + .flatten() + .ok_or_else(|| "browser-job-process-identity-unconfirmed".to_string())? + }; + members.push((pid, identity)); + } + for (pid, identity) in members { + if !process_identity_matches(pid, &identity) { + return Err("browser-job-process-identity-changed".into()); + } + if !job.contains_pid(pid)? { + job.assign_pid(pid)?; + } + if !process_identity_matches(pid, &identity) { + return Err("browser-job-process-identity-changed".into()); + } + } + + let verified = windows_process_snapshot()?; + let verified_tree = windows_process_tree_pids_from(&verified, root_pid)?; + if process_identity_matches(root_pid, root_identity) + && !verified_tree.is_empty() + && verified_tree + .iter() + .all(|pid| job.contains_pid(*pid).unwrap_or(false)) + { + return Ok(()); + } + std::thread::sleep(TREE_KILL_PASS_INTERVAL); + } + Err("browser-job-tree-coverage-unconfirmed".into()) +} + #[cfg(windows)] fn windows_terminate_process(pid: u32) { use windows_sys::Win32::Foundation::CloseHandle; @@ -468,6 +643,18 @@ mod tests { } } + #[test] + fn owner_write_failure_leaves_no_partial_ownership_record() { + let root = tempfile::tempdir().unwrap(); + let missing_root = root.path().join("missing"); + assert!(!write_browser_process_owner( + &missing_root, + std::process::id(), + &std::env::current_exe().unwrap(), + )); + assert!(!missing_root.join(OWNER_FILE).exists()); + } + #[test] fn legacy_directory_without_owner_file_is_removed_only_when_old_enough() { let root = tempfile::tempdir().unwrap(); @@ -548,6 +735,90 @@ mod tests { assert!(trusted_browser_executable(&installed)); } + #[cfg(windows)] + #[test] + fn windows_browser_cleanup_requires_the_exact_profile_argument() { + let profile = Path::new(r#"C:\Users\tester\App Data\ga-browser-1\profile"#); + assert!(command_line_contains_profile( + r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-1\profile""#, + profile + )); + assert!(command_line_contains_profile( + r#""msedge.exe" "--user-data-dir=C:\Users\tester\App Data\ga-browser-1\profile""#, + profile + )); + assert!(!command_line_contains_profile( + r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-2\profile""#, + profile + )); + assert!(!command_line_contains_profile( + r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-1\profile-copy""#, + profile + )); + } + + #[cfg(windows)] + #[test] + fn browser_job_adopts_children_created_before_assignment() { + use std::process::{Command, Stdio}; + + let mut child = Command::new("cmd.exe") + .args([ + "/c", + "ping", + "127.0.0.1", + "-n", + "60", + "&", + "ping", + "127.0.0.1", + "-n", + "60", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn fixture"); + let pid = child.id(); + let identity = crate::process_identity::external_agent_runner_process_start_identity(pid) + .expect("fixture identity") + .expect("fixture identity present"); + std::thread::sleep(Duration::from_millis(500)); + let job = WindowsProcessJob::assign_std(&child).expect("assign fixture job"); + ensure_browser_tree_in_job(pid, &identity, &job).expect("adopt fixture tree"); + let snapshot = windows_process_snapshot().expect("snapshot after adoption"); + let tree = windows_process_tree_pids_from(&snapshot, pid).expect("tree after adoption"); + assert!(tree + .iter() + .all(|member| job.contains_pid(*member).expect("job membership"))); + job.terminate().expect("terminate fixture job"); + let _ = child.wait(); + assert!(job.is_empty().expect("fixture job empty")); + } + + #[cfg(windows)] + #[test] + fn kill_browser_process_tree_rejects_root_identity_mismatch() { + use std::process::{Command, Stdio}; + + let mut child = Command::new("cmd.exe") + .args(["/c", "ping", "127.0.0.1", "-n", "60"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn fixture"); + let pid = child.id(); + let result = kill_browser_process_tree(pid, "not-the-fixture-identity"); + let _ = child.kill(); + let _ = child.wait(); + assert_eq!( + result.expect_err("identity mismatch must fail closed"), + "browser-sweep-root-identity-mismatch" + ); + } + #[cfg(windows)] #[test] fn kill_browser_process_tree_reaps_fixture_tree() { @@ -600,10 +871,8 @@ mod tests { fn live_process_executable_matches_current_process_image() { let exe = std::env::current_exe().unwrap(); let profile = Path::new("C:\\fixture\\ga-browser-x\\profile"); - // Windows 只核对镜像;Linux 还要求命令行绑定 profile,本测试进程 - // 不具备该标记,正例只在 Windows 断言。 - #[cfg(windows)] - assert!(live_process_executable_matches( + // 当前测试进程不携带目标 Profile 标识,即使镜像一致也不能清理。 + assert!(!live_process_executable_matches( std::process::id(), &exe, profile diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs index d504530ae..ff2565b84 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs @@ -493,12 +493,19 @@ pub(crate) fn upload_local_asset( file_name: String, media_type: String, bytes: Vec, + target_category: Option, ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "asset.upload")?; let _lock = acquire_project_write_lock(root, "asset.upload")?; advance_agent_runtime_project_revision_locked(root)?; - upload_local_asset_at(root, file_name.trim(), media_type.trim(), &bytes) + upload_local_asset_at_with_category( + root, + file_name.trim(), + media_type.trim(), + &bytes, + target_category.as_deref(), + ) } #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs index 8fa08dcd6..018e9ff92 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs @@ -311,6 +311,13 @@ async fn host_npm( fn browser_validation_failure_code(error: &str) -> &'static str { if error.contains("未发现可用的") { "web-preflight-browser-missing" + } else if error.starts_with("browser-recovery-") { + "web-preflight-browser-recovery-failed" + } else if error.contains("browser-temp-unavailable") || error.contains("browser-config-invalid") + { + "web-preflight-browser-environment-failed" + } else if error.starts_with("browser-launch-failed:") { + "web-preflight-browser-launch-failed" } else if error.contains("启动浏览器超时") { "web-preflight-browser-launch-timeout" } else if error.contains("启动浏览器失败") { @@ -342,6 +349,13 @@ fn browser_validation_failure_code(error: &str) -> &'static str { } } +fn browser_validation_diagnostic(error: &str) -> Option<&str> { + (error.starts_with("browser-recovery-") + || error.starts_with("browser-launch-failed:") + || error.starts_with("browser-cleanup-unconfirmed:")) + .then_some(error) +} + pub(crate) async fn host_web_creation_preflight() -> Value { let started = Instant::now(); // 预检前顺手清扫陈旧的无头浏览器,避免残留进程放大本轮超时。 @@ -362,18 +376,60 @@ pub(crate) async fn host_web_creation_preflight() -> Value { let validation = crate::browser::validate_local_preview_in_browser(BrowserValidationInput { url: preview.url, viewports: vec![BrowserValidationViewport::Desktop, BrowserValidationViewport::Mobile], expected_text: vec![], settle_ms: 100, fail_on_console_error: true, playtest_scenario: None, evidence_root: root.join("evidence"), }).await; - let result = validation.map_err(|error| browser_validation_failure_code(&error).to_string())?; - if !result.passed || result.viewport_results.len() != 2 { return Err("web-preflight-page-check-failed".into()); } + let result = validation + .map_err(|error| { + let code = browser_validation_failure_code(&error); + browser_validation_diagnostic(&error) + .map(|diagnostic| format!("{code};diagnostic={diagnostic}")) + .unwrap_or_else(|| code.to_string()) + })?; + if !result.passed || result.viewport_results.len() != 2 { + return Err("web-preflight-page-check-failed".into()); + } let mut pngs = Vec::new(); for viewport in result.viewport_results { - let bytes = fs::read(&viewport.screenshot_path).map_err(|_| "web-preflight-screenshot-missing")?; - if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 128 { return Err("web-preflight-screenshot-invalid".into()); } - image::load_from_memory(&bytes).map_err(|_| "web-preflight-screenshot-invalid")?; - pngs.push(json!({"viewport":viewport.viewport,"passed":viewport.passed,"pngBytes":bytes.len()})); + let bytes = fs::read(&viewport.screenshot_path) + .map_err(|_| "web-preflight-screenshot-missing")?; + if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 128 { + return Err("web-preflight-screenshot-invalid".into()); + } + image::load_from_memory(&bytes) + .map_err(|_| "web-preflight-screenshot-invalid")?; + pngs.push(json!({ + "viewport": viewport.viewport, + "passed": viewport.passed, + "pngBytes": bytes.len(), + })); } - Ok::<_, String>(json!({"schemaVersion":"agc-web-creation-preflight.v1","status":"ready","runtime":{"source":runtime.source,"nodeVersion":node,"npmVersion":npm},"build":{"status":"ready","kind":"npm-node-fixture"},"browser":{"status":"ready","viewports":pngs}})) - }.await; - let mut result = run.unwrap_or_else(|code| json!({"schemaVersion":"agc-web-creation-preflight.v1","status":"blocked","code":code})); + Ok::<_, String>(json!({ + "schemaVersion": "agc-web-creation-preflight.v1", + "status": "ready", + "runtime": { + "source": runtime.source, + "nodeVersion": node, + "npmVersion": npm, + }, + "build": {"status": "ready", "kind": "npm-node-fixture"}, + "browser": {"status": "ready", "viewports": pngs}, + })) + } + .await; + let mut result = run.unwrap_or_else(|error| { + let (code, diagnostic) = error + .split_once(";diagnostic=") + .map_or((error.as_str(), None), |(code, diagnostic)| { + (code, Some(diagnostic)) + }); + let mut blocked = json!({ + "schemaVersion": "agc-web-creation-preflight.v1", + "status": "blocked", + "code": code, + }); + if let Some(diagnostic) = diagnostic { + blocked["diagnostic"] = json!(diagnostic); + } + blocked + }); result["elapsedMs"] = json!(started.elapsed().as_millis()); result } @@ -382,9 +438,14 @@ pub(crate) async fn host_web_creation_preflight() -> Value { pub(crate) async fn preflight_web_game_creation() -> Result { let result = host_web_creation_preflight().await; if result["status"] != "ready" { + let diagnostic = result["diagnostic"] + .as_str() + .map(|value| format!(";诊断:{value}")) + .unwrap_or_default(); return Err(format!( - "Web 游戏环境预检未通过:{};尚未启动生成", - result["code"].as_str().unwrap_or("web-preflight-failed") + "Web 游戏环境预检未通过:{}{};尚未启动生成", + result["code"].as_str().unwrap_or("web-preflight-failed"), + diagnostic, )); } Ok(result) @@ -548,16 +609,40 @@ mod tests { browser_validation_failure_code("启动浏览器失败:2"), "web-preflight-browser-launch-failed" ); + + assert_eq!( + browser_validation_failure_code( + "browser-launch-failed: stage=setup cause=browser-temp-unavailable" + ), + "web-preflight-browser-environment-failed" + ); + + let recovery_diagnostic = "browser-recovery-failed: initial-stage=ws-handshake final-stage=cdp-connect subprocess-exited=true cleanup-confirmed=true recovery-retried=true"; + assert_eq!( + browser_validation_failure_code(recovery_diagnostic), + "web-preflight-browser-recovery-failed" + ); + assert_eq!( + browser_validation_diagnostic(recovery_diagnostic), + Some(recovery_diagnostic) + ); + assert_eq!( + browser_validation_diagnostic("启动浏览器失败:原始错误"), + None + ); assert_eq!( browser_validation_failure_code("宿主已停止本轮浏览器验证"), "web-preflight-cancelled" ); + let cleanup_diagnostic = "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=true cleanup-confirmed=false recovery-retried=false"; assert_eq!( - browser_validation_failure_code( - "browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程" - ), + browser_validation_failure_code(cleanup_diagnostic), "web-preflight-browser-cleanup-failed" ); + assert_eq!( + browser_validation_diagnostic(cleanup_diagnostic), + Some(cleanup_diagnostic) + ); assert_eq!( browser_validation_failure_code("创建浏览器临时目录失败:拒绝访问"), "web-preflight-browser-environment-failed" diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs index 6d086e49c..f8d02848c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs @@ -236,7 +236,42 @@ impl WindowsProcessJob { .ok_or_else(|| "子进程缺少 Windows process handle".to_string())?; Self::assign_handle(handle as windows_sys::Win32::Foundation::HANDLE) } + pub(crate) fn contains_pid(&self, pid: u32) -> Result { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::JobObjects::IsProcessInJob; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if process.is_null() { + return Err("无法打开进程核对 Windows Job 归属".into()); + } + let mut in_job = 0; + let okay = unsafe { IsProcessInJob(process, self.0, &mut in_job) }; + unsafe { CloseHandle(process) }; + if okay == 0 { + return Err("无法核对进程 Windows Job 归属".into()); + } + Ok(in_job != 0) + } + pub(crate) fn assign_pid(&self, pid: u32) -> Result<(), String> { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE, + }; + let process = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) }; + if process.is_null() { + return Err("无法打开进程加入 Windows Job".into()); + } + let okay = unsafe { AssignProcessToJobObject(self.0, process) }; + unsafe { CloseHandle(process) }; + if okay == 0 { + return Err("无法将进程加入 Windows Job".into()); + } + Ok(()) + } pub(crate) fn assign_tokio_named( child: &tokio::process::Child, name: &str, diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasBottomToolbarModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasBottomToolbarModel.ts index 429d83a34..9b848ccbc 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasBottomToolbarModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasBottomToolbarModel.ts @@ -99,7 +99,13 @@ export type ResourceCanvasAudioToolAction = audioKind: ResourceCanvasGenerationKind; }; -/** 上传入口:复用既有 `upload_local_asset`。 */ +/** + * 上传入口:复用既有 `upload_local_asset`。 + * + * 调用方按当前栏目带 `targetCategory`(见 `uploadResourceCanvasToolbarFiles`)——上传的 + * manifest `kind` 只由内容证据推导,图片 / 视频 / 代码都会派生成 `unclassified`, + * 不带入口栏目素材就落「待归类」、在上传它的那一栏里看不见。 + */ export type ResourceCanvasUploadToolAction = ResourceCanvasBottomToolActionBase & { route: 'upload'; 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 df69d90a0..50e0c00dc 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 @@ -3348,8 +3348,9 @@ export default function ProjectDevelopmentView({ * 两条判据缺一不可: * 1. 这张卡已经进了布局(`resourcePositionById`),否则 reconcile 还没补位、写坐标是空操作; * 2. 这张卡已经有正式归类(投影里的 `category`)——`commitPosition` 的 `section` 必须与该资源 - * 在 sidecar 里的 section 一致,否则会被静默跳过。生成结果的实际归类与入口栏目本来就可能不同 - * (普通图片落「待归类」、规范落「文档」),所以这里按**正式归类**写,而不是入口栏目。 + * 在 sidecar 里的 section 一致,否则会被静默跳过。生成结果的实际归类与入口栏目**不一定相同** + * (Agent / Direct 路径不传 `targetCategory`,普通图片仍落「待归类」),所以这里按**正式归类**写, + * 而不是入口栏目。 * * 写完撤掉占位:结果已经接管了它的位置。占位被用户先删掉时同样清掉这条意图(没有位置可接管)。 */ @@ -9062,9 +9063,15 @@ export default function ProjectDevelopmentView({ [selectResourceCanvasPage], ); - /** 工具栏「上传」:与资源面板上传同一条「上传 + 配对读清单」链路。 */ + /** + * 工具栏「上传」:与资源面板上传同一条「上传 + 配对读清单」链路。 + * + * 入口栏目随上传一起交给原生(`targetCategory`):上传的 kind 只由内容证据推导 + * (图片 / 视频 / 代码 → `unclassified`),不带上它,素材会落进「待归类」、 + * 在上传它的那一栏里看不见。取值就是工具栏自己的栏目,与生成入口同一口径。 + */ const uploadResourceCanvasToolbarFiles = useCallback( - async (files: readonly File[]) => { + async (files: readonly File[], targetCategory: ResourceCategory | null) => { const invoke = window.__TAURI__?.core?.invoke; if (!invoke) { setResourceWorkbenchNotice('上传素材需要在客户端内打开'); @@ -9088,6 +9095,7 @@ export default function ProjectDevelopmentView({ bytes: Array.from(new Uint8Array(await file.arrayBuffer())), })), ), + targetCategory, invoke, }); setResourceWorkbenchNotice(`已上传 ${uploadable.length} 个素材`); @@ -10548,7 +10556,10 @@ export default function ProjectDevelopmentView({ } onSelectAction={handleResourceBottomToolAction} onUploadFiles={(files) => { - void uploadResourceCanvasToolbarFiles(files); + void uploadResourceCanvasToolbarFiles( + files, + resourceCanvasBottomToolbarCategory, + ); }} uploading={resourceBottomToolbarUploading} /> diff --git a/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts b/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts index 79b322249..35b7163bd 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts @@ -1,4 +1,7 @@ -import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import type { + GameCreationAppManifest, + ProjectResourceCanvasCategory, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; export type ProjectManifestSnapshotSource = | 'initial' @@ -285,6 +288,14 @@ export async function uploadProjectAssetFilesAndReadSnapshot(input: { mediaType: string; bytes: number[]; }[]; + /** + * 入口栏目:栏目画布里上传时由调用方显式给出当前栏目,原生按它登记归类。 + * + * 上传的 manifest `kind` 只由内容证据推导(图片 / 视频 / 代码 → `unclassified`), + * 不传这一项时素材会落进「待归类」、在上传它的那一栏里看不见。 + * 判定与失败关闭都在原生:非法值报错,前端不做伪分类。 + */ + targetCategory?: ProjectResourceCanvasCategory | null; invoke(command: string, args: Record): Promise; }): Promise { for (const file of input.files) { @@ -293,6 +304,7 @@ export async function uploadProjectAssetFilesAndReadSnapshot(input: { fileName: file.fileName, mediaType: file.mediaType, bytes: file.bytes, + ...(input.targetCategory ? { targetCategory: input.targetCategory } : {}), }); } const fresh = await rereadAuthoritativeProjectManifestSnapshot({ diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index fd8e5bebf..b66ab4c34 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -8839,6 +8839,52 @@ export function registerProjectAgentStatusTests() { ); }, 20_000); + it('uploads toolbar files into the entry column so they stay visible where they were uploaded', async () => { + // Issue 359:图片只由内容证据派生成 `unclassified`,在「角色与对象」栏目里上传后 + // 会落进「待归类」、在上传它的那一栏里看不见。工具栏上传必须把当前栏目交给原生。 + const manifest = createGameCreationAppManifest( + 'workbench-bottom-toolbar-upload-category', + '底部工具栏上传归类项目', + ); + const { calls } = installResourceBookBottomToolbarInvoke(manifest); + + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: '/tmp/workbench-bottom-toolbar-upload-category', + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + chat: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + await openResourceBookCategory('角色与对象'); + const uploadInput = screen.getByLabelText( + '上传素材文件', + ) as HTMLInputElement; + const file = new File(['png'], 'hero.png', { type: 'image/png' }); + if (typeof file.arrayBuffer !== 'function') { + Object.defineProperty(file, 'arrayBuffer', { + value: async () => new Uint8Array([112, 110, 103]).buffer, + }); + } + fireEvent.change(uploadInput, { target: { files: [file] } }); + + await waitFor(() => + expect(uploadCall(calls, 'hero.png')).toMatchObject({ + projectPath: '/tmp/workbench-bottom-toolbar-upload-category', + fileName: 'hero.png', + mediaType: 'image/png', + targetCategory: 'character', + bytes: [112, 110, 103], + }), + ); + }, 20_000); + it('keeps the bottom toolbar clear of the zoom dock and above the book scene', () => { const styles = readFileSync( repoPath('apps/ai-game-creator-shell/src/styles.css'), diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 14b25c309..9e38ea713 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -164,7 +164,7 @@ - 位置与层级:画布左下角(`left: 14px; bottom: 14px; z-index: 40`)。右下角是既有的缩放 / 撤销 Dock(`right: 14px; bottom: 14px`),左下角是画布上唯一两者都不占的稳定空位。工具栏是管理区 `.game-resource-book-manager` 的**直接子节点**、与画本场景并列,不进带 `scale()` 的场景层;二级菜单与「入口不可用原因」都贴着工具栏上沿弹出,不做内嵌内容。 - 外壳用共享 chrome(`packages/image-canvas-react` 的 `CanvasToolbar / CanvasToolbarGroup / CanvasChromeButton`),样式落在 AGC 的 `resourceCanvasChrome.css`;共享包只承接通用表现,不含业务规则。 -- 接线:图片类入口走本地 IPC `start_local_project_asset_generation`(`kind` ∈ `image / character / spec / icon-spec / ui-prototype / art-spritesheet`;**提交即返回任务记录**,生成由 Rust 后台任务跑完写回项目,进度用 `list_local_project_asset_generations` 读回项目内账本 `.agent/runtime/asset-generation-tasks/tasks.json`);音频入口也走 `start_local_project_asset_generation`(同一份项目内任务账本;`kind` = `sound-effect` / `background-music`,并额外携带该次生成的请求身份 `idempotencyKey`,任务 id 即该次生成的 operation id;生成仍复用既有音频无源生成链路,`generationMode: 'create'`、`editKind` = `sound-effect` / `background-music`,不新增平台路由与请求体口径);「上传」复用 `upload_local_asset`。生成 / 上传成功后一律用「配对读 `(revision, manifest)`」交给 `onManifestChange`,走既有 manifest 刷新与资源投影链路,不重算依赖图、不另写布局。 +- 接线:图片类入口走本地 IPC `start_local_project_asset_generation`(`kind` ∈ `image / character / spec / icon-spec / ui-prototype / art-spritesheet`;**提交即返回任务记录**,生成由 Rust 后台任务跑完写回项目,进度用 `list_local_project_asset_generations` 读回项目内账本 `.agent/runtime/asset-generation-tasks/tasks.json`);音频入口也走 `start_local_project_asset_generation`(同一份项目内任务账本;`kind` = `sound-effect` / `background-music`,并额外携带该次生成的请求身份 `idempotencyKey`,任务 id 即该次生成的 operation id;生成仍复用既有音频无源生成链路,`generationMode: 'create'`、`editKind` = `sound-effect` / `background-music`,不新增平台路由与请求体口径);「上传」复用 `upload_local_asset`,并带上当前栏目 `targetCategory`(上传的 manifest `kind` 只由内容证据推导,图片 / 视频 / 代码都派生成 `unclassified`;不带入口栏目素材就落进「待归类」、在上传它的那一栏里看不见,Issue 359)。生成 / 上传成功后一律用「配对读 `(revision, manifest)`」交给 `onManifestChange`,走既有 manifest 刷新与资源投影链路,不重算依赖图、不另写布局。 - 本地排队与进度可见:AGC 本地 durable 输出槽已按**精确动作指纹**分槽(不同 prompt / 素材名各自独立成槽,具备并行能力),但本批前端仍按「同一时刻只派发一条」排队——真并行派发需要并发收口设计(配对读 + manifest CAS + 聚焦意图互不覆盖),留待下一批;所以第一条未终态时第二条提交停在**前端本地队列**里(不调用提交 IPC,显示本地排队的「排队中。」),前一条终态后自动补发;任务状态与阶段文案(后端 `phaseDetail`)由任务账本提供,前端不拼阶段、不做百分比。进度面是**画布上常驻的可折叠任务侧栏**(位置与开合形态照抄网页端美术画布的任务侧栏的右上角锚点,颜色与外形仍走 AGC 平台 token;2026-09-21 由「左侧贴边 + 工具条入口」改为「右上角锚点 + 常驻开关」):展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 列表滚动 + 高度有界),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其分割线已删除)、折叠即整块让出画布、只留那一枚右上角开关(工具条上不再有重复入口),开合只走画布右上角那一枚「生成任务 · N」开关(两个页签下都在;**展开后开关让位、只留面板**,收起走面板头部 × 或点画布外部);锚点是画布那一格网格里的条目(不是写死 `top` 的绝对定位),工具条换行变高也不会压上去;每项显示状态徽标 / 阶段文案 / 已耗时 / 素材名,可「定位到素材」。侧栏非模态(不铺全屏遮罩、不做焦点陷阱、不参与模态遮挡判据),位置在画布右上角锚点里(照抄美术画布那一处)、**覆盖式**(不 reflow 挤窄画布视口),提交受理后自动展开。锚点按工作面分档:资源栏目画布与 UI 编辑器用画布顶边那一档,运行表现层下移让开右上角的版本入口;锚点是**画布那一格网格里的条目**(不是写死 `top` 的绝对定位),所以工具条换行变高也不会压上去。定位动作**每次点击都终局化**:能定位就定位并选中;素材在别的栏目先切栏目;不在投影里给「素材已不在项目里 / 已登记但尚未同步」的结论;3 秒内有界兜底,不允许提示条永久停在「正在定位生成的素材…」。 - 面板形态:独立浮层(`ThemedModal`),**不在当前面板下面追加内容**;面板内不写功能说明或规则解释文案。**点「生成」即同步关闭面板**(不等 IPC、不等排队、不等生成),面板里**不出现**「排队中。」「正在生成。」「提交中…」这类阶段文案——阶段文案的唯一去处是任务侧栏与工具栏提示条。**只有「点击瞬间就失败」**(校验不过、权限拒绝、start IPC 立即报错)才自动重开面板并带回草稿与原因;**受理之后才失败**只在侧栏把该任务收口为失败 + 原因,不重开面板。关闭 ≠ 取消请求(请求挂在任务与账本上,不挂在面板生命周期上)。 - 参数口径:比例 / 尺寸选项来自网页端美术画布的纯模型(`src/components/image-editor/ImageCanvasGenerationModel.ts`),并按本地 IPC 白名单收窄(本地通道明确拒绝 `4:3`);默认档 `1:1 · 1K`,生成 UI 设计图沿用网页端 UI 设计面板的默认 `16:9 · 1K`。本地 IPC 没有 `model` 入参,因此面板**不渲染模型选择器**(渲染一个改不了请求的控件就是假控件)。 diff --git a/docs/project-memory/plans/【实施计划】Web预检浏览器失败恢复-2026-10-02.md b/docs/project-memory/plans/【实施计划】Web预检浏览器失败恢复-2026-10-02.md new file mode 100644 index 000000000..aaa18517a --- /dev/null +++ b/docs/project-memory/plans/【实施计划】Web预检浏览器失败恢复-2026-10-02.md @@ -0,0 +1,31 @@ +# Web 预检浏览器失败恢复实现计划 + +- Version: `1` +- Status: `active` +- Date: `2026-10-02` +- Milestone: [`Web 预检浏览器失败恢复`](./【里程碑】Web预检浏览器失败恢复-2026-10-02.md) + +## 实现顺序 + +1. 将浏览器启动失败建模为带阶段、原因、子进程退出确认、清理确认的内部结果;Windows root spawn 后立即绑定 Job,并把绑定瞬间已经出现的子树逐 PID 纳入同一 Job,失败收束必须复用本轮 `BrowserProcessGuard`,并在成功收束时确认 Windows Job 为空。 +2. 在 Web 预检使用的一次浏览器启动入口加入一次有界恢复:仅 WS/CDP 瞬态失败且首次清理确认后创建新的临时目录/Profile 重试;其余失败直接失败关闭。 +3. 保留/加强 owner 与 sweep 的归属门禁,使用 Windows argv 解析核对完整 Profile 参数;无 owner、身份未知、PID 退出或复用均不得按猜测杀进程。 +4. 将启动失败和清理失败的安全诊断保留到预检 blocked 报告和 Tauri 错误中;稳定错误码独立于诊断文本,前端继续区分宿主阻塞与 IPC 故障。 +5. 补充 Rust 纯策略测试、清理边界测试和现有真实 Edge/Chromium ignored smoke;补充首页错误展示的定向测试。 + +## 验证命令 + +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell browser::` +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell environment_check::web_creation::tests` +- `npx vitest run apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx` +- `npm run typecheck --workspace apps/ai-game-creator-shell` +- `npm run check:encoding` +- `git diff --check` + +可选真实环境证据:安装 Windows Edge 时运行现有 `real_browser_health_checks_can_run_concurrently` 及新增恢复 smoke;无浏览器时保持 ignored,不把缺失环境写成通过。 + +## 风险与回滚 + +- Windows 进程命令行读取失败按不匹配处理,不执行杀进程;这可能留下临时目录,但保证不误杀。 +- 首次失败进程树收束未确认时不自动重试,避免第二个 Edge 与残留树并存;错误返回安全诊断。 +- 回滚点为浏览器启动恢复入口和 owner/sweep 归属校验,不触及 Web 预检的 Node/npm 或项目写入流程。 diff --git a/docs/project-memory/plans/【里程碑】Web预检浏览器失败恢复-2026-10-02.md b/docs/project-memory/plans/【里程碑】Web预检浏览器失败恢复-2026-10-02.md new file mode 100644 index 000000000..e56e78d5c --- /dev/null +++ b/docs/project-memory/plans/【里程碑】Web预检浏览器失败恢复-2026-10-02.md @@ -0,0 +1,41 @@ +# Web 预检浏览器失败恢复 + +- Version: `1` +- Status: `active` +- Date: `2026-10-02` +- Parent Spec: [`AI游戏创作智能体 App 实施计划`](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#自动预检与可信脚手架) + +## 目标 + +修复 Issue #584:Web 游戏环境预检在受控 Edge/Chrome 的 WS 握手或 CDP 连接失败时,能够只针对本轮 AGC 浏览器实例完成清理、使用新的隔离 Profile 重试一次,并在仍失败时保留安全、可行动的诊断信息。 + +## 边界 + +- 只改 AGC Rust 浏览器启动/清理、Web 预检错误投影及其定向测试、对应现行专题文档。 +- 清理对象必须同时满足 AGC 临时 Profile、进程启动身份、可信浏览器可执行文件和 Windows 进程树归属;无法确认时 fail-closed。 +- 不执行全量 `taskkill /IM msedge.exe`,不影响用户 Edge、其它 AGC 实例、其它项目或 worktree。 +- 不改变 Web 预检的失败关闭、Node/npm 检查、桌面/移动双视口检查和首次生成顺序。 +- 不新增网络、自动下载、跳过浏览器验证或伪造 ready 的旁路。 + +## 行为合同 + +1. WS 握手失败、WS 超时、CDP 连接失败或 CDP 连接超时属于可恢复的浏览器启动瞬态失败。 +2. 第一次失败后,宿主必须先确认本轮进程树已退出并清理本轮 Profile / owner 记录 / 临时目录;清理未确认时不得启动第二次浏览器。 +3. 清理确认后,第二次启动必须创建新的隔离临时目录和 Profile;第二次成功后继续现有 desktop/mobile 预检。 +4. 连续失败或清理被阻断时,结果保持 blocked,并带有阶段(WS 握手或 CDP 连接)、子进程是否退出、清理是否确认、是否执行恢复重试等安全诊断。 +5. owner.json 缺失/写入失败、目录过新、进程已退出、PID 被复用、身份未知或归属不明时只能按保守路径处理:不杀不明进程;可安全删除的临时目录才删除。 + +## 验收标准 + +- 定向测试构造 `browser-ws-failed` 后证明只在清理确认时重试,且重试使用新的 Profile;第二次成功返回成功。 +- 定向测试覆盖 WS/CDP 阶段、连续失败、清理未确认、owner.json 缺失/无效、刚创建目录、进程退出、PID 复用和非 AGC 进程跳过。 +- Windows 真实 Edge 安装版 smoke 保留在现有真实浏览器测试入口中;无 Edge 的环境明确跳过而不是伪造通过。 +- 现有首页预检、正常 Edge 使用、首次生成失败关闭和前端 IPC/宿主错误区分回归通过。 + +## 依赖 + +- `apps/ai-game-creator-shell/src-tauri/src/browser/process.rs` +- `apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs` +- `apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs` +- `apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts` +- 对应 Rust 与 Vitest 测试。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 29d76aed1..4aa91f9be 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,13 @@ # 决策记录 +## 2026-10-03 AGC 栏目画布上传素材按入口栏目登记(Issue 359) + +- 背景:AGC 客户端在资源栏目子画布(「UI 交互 / 角色与对象 / 场景与环境 / 音频」)左下角工具栏点「上传」后,提示条给出「已上传 1 个素材」,但当前栏目计数不变(仍「0 项」)、素材出现在「待归类」,用户看到的是"上传成功了但它从这一页消失了"。原因是上传登记的 manifest `kind` 只由**内容证据**推导(`assets.rs::uploaded_asset_kind`:图片 / 视频 / 代码 → `unclassified`,音频 → `audio`,文档 / 字体 → `document`),kind 派生分类与栏目词汇(`ui-interaction` / `character` / `scene` / `audio`)不是同一套,而 `upload_local_asset` 原先不接受入口栏目。 +- 决策:`upload_local_asset` 增加可选 `targetCategory`,Rust 走既有的 `register_local_asset_entry_with_category`(与生成入口 `start_local_project_asset_generation` 的 `targetCategory` **同一口径**:GUI 完成登记以入口栏目为准);前端 `uploadProjectAssetFilesAndReadSnapshot` 透传该字段,栏目画布工具栏上传取工具栏自己的栏目(`resourceCanvasBottomToolbarCategory`)。取值只接受共享分类枚举,非法值由原生失败关闭,前端不做伪分类。 +- 边界:不传 `targetCategory` 时保持既有 kind 派生行为——资源面板(`ResourceCanvasPanelView`,跨栏目列表而不是栏目工具)、UI 编辑器图片导入(`import_ui_editor_local_files`)、聊天附件上传都不变。`upload_local_asset_at` 签名保持不变(委托到新的 `upload_local_asset_at_with_category`),既有约 25 处调用点与 Rust 单测零改动。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{assets.rs,commands/desktop.rs}`、`apps/ai-game-creator-shell/src/view/project-development/{projectResourceLiveUpdateModel.ts,index.tsx}`、`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`、PRD §3.10、`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md`(§4 载荷表)、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`(S11a)、`pitfalls.md`。 +- 验证:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute --bin genarrative-ai-game-creator-shell assets::tests`(新增 `upload_registers_into_the_explicit_entry_category`:显式栏目 → `character`、不传 → `unclassified`、非法 `version` 失败关闭且不新增登记);`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(196 passed / 9 skipped,含新增「uploads toolbar files into the entry column so they stay visible where they were uploaded」断言工具栏上传载荷带 `targetCategory: 'character'`);`npm run agc:typecheck`、`npm run check:encoding`、`git diff --check`。 + ## 2026-10-03 生成绑定不再经 prettier:ts-rs 原始输出即提交形态 - 背景:`scripts/check-generated-bindings.mjs` 对 AGC 的 `chat/generated` / `services/generated` 在重生成后会就地跑一遍 `npx prettier --write` 再比较。这会直接改写生成文件,还把「Rust 声明真的变了」与「prettier 版本 / 配置造成的格式漂移」混在同一条告警里——本次报出的 `ThreadRequestKind.ts` / `TurnCompletedStatus.ts`「内容变化」无法复现为语义变化(已提交内容与当前 Rust 枚举一致),prettier 归一化把格式差异也报成了「与 Rust 声明不一致」;生成物被仓库格式化工具二次改写后,重跑 `cargo test export_bindings` 也不再幂等。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 521c0a27b..75a6d0d4f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,14 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-03 AGC 栏目画布上传素材落「待归类」:kind 派生分类不等于入口栏目 + +- **现象**(Issue 359):在 AGC 资源栏目子画布(如「UI 交互」「角色与对象」)左下角工具栏点「上传」选图片 / 视频 / 代码类文件,提示条给出「已上传 1 个素材」,但当前栏目计数纹丝不动(仍「0 项」),素材出现在「待归类」。用户看到的是"上传成功了,可它就消失在这个页面里"。 +- **原因**:上传登记的 manifest `kind` 只由**内容证据**推导(`assets.rs::uploaded_asset_kind`:图片 / 视频 / 代码 → `unclassified`,音频 → `audio`,文档 / 字体 → `document`),kind 派生分类与栏目词汇(`ui-interaction` / `character` / `scene` / `audio`)不是同一套;`upload_local_asset` 原先不接受入口栏目,GUI 工具栏上传只能落 kind 派生分类。 +- **处理(现行口径)**:`upload_local_asset` 增加可选 `targetCategory`,Rust 走既有的 `register_local_asset_entry_with_category`(非法值失败关闭,不回退 kind 派生);栏目画布工具栏上传时取工具栏自己的栏目(`resourceCanvasBottomToolbarCategory`)。生成入口 `start_local_project_asset_generation` 的 `targetCategory` 是同一口径——GUI 完成登记以入口栏目为准。 +- **判据/取证**:`cargo test --locked ... --bin genarrative-ai-game-creator-shell upload_` 的 `assets::tests::upload_registers_into_the_explicit_entry_category`(显式栏目 → `character`;不传 → `unclassified`;非法 `version` 失败关闭且不新增登记);appSurface「uploads toolbar files into the entry column so they stay visible where they were uploaded」断言工具栏上传载荷带 `targetCategory: 'character'`。 +- **边界**:资源面板(跨栏目列表)、UI 编辑器图片导入、聊天附件上传都**不带**入口栏目,保持 kind 派生。任何新增的「某个栏目里的上传入口」都必须显式带上当前栏目,否则又会复现本坑。 + ## 2026-10-01 用户输错一次密码被当成"客户端出问题了"引导上报 - **现象**:登录页密码输错(或密码长度不合规)后弹出「发现问题」,报告面板「错误事件(2)」列出 `密码长度需要在 6 到 128 位之间 — auth · 1 次` 与 `手机号或密码错误 — auth · 1 次`,默认全选,与 react-render / 5xx / agent-runtime 终态失败视觉等价。 @@ -6280,7 +6288,8 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **根因 3(工具被拒)**:sidecar 用 `permissionMode: 'dontAsk'` 且没有 `allowedTools`,宿主 MCP 工具(`mcp__agc__*`)一律被直接拒绝,模型只能回"没有权限"。 - **根因 4(界面看不到回复)**:聊天区是按 `item.completed` 事件流投影的(codex 路径在 `rawResponseItem/completed` 时下发 `ThreadItem::Message`),只把回复落进 `project.jsonl` 不会让本轮出现在界面上——用户看到"用户气泡 + 本轮结束于 … · 耗时",回复只在重进项目时从历史读出来。 - **根因 5(验收反馈复用 assistant ID)**:同一 client turn 进入 `ReviewRequired` 后会再次调用 cc。首次回复已经占用 `direct-codex::assistant`,第二次不同正文沿用该 ID 会被历史层正确拒绝为冲突,随后却被错误投影成 `runtime-unclassified`。真实诊断中可见「写入本项目对话历史失败:…assistant」且历史已经有该条回复。 -- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 每次解析成功后都把实际落盘的回复 item id 同步下发 `ThreadEvent::item_completed(ThreadItem::Message{role:"assistant"})`。首个回复沿用 `direct-codex::assistant`,同一回合的反馈回复遇到内容冲突时追加 `:assistant:`,相同内容仍按原 ID 幂等;落盘失败按回合失败收口。sidecar 按 `mcp__` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。 +- **根因 6(cc 字符串错误覆盖了上游分类)**:DirectProject 的 Claude Code 路由原本把 sidecar 返回的所有字符串都包装成 `LlmError::Transport`,因此 HTTP 429、401、408、5xx、sidecar 超时、空回执和无效 JSON 都显示成「执行通道未能建立或已断开」。 +- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 每次解析成功后都把实际落盘的回复 item id 同步下发 `ThreadEvent::item_completed(ThreadItem::Message{role:"assistant"})`。首个回复沿用 `direct-codex::assistant`,同一回合的反馈回复遇到内容冲突时追加 `:assistant:`,相同内容仍按原 ID 幂等;落盘失败按回合失败收口。Claude Code 的失败文本先投影到与 Codex 相同的 `LlmError` 分类:HTTP 状态、sidecar 超时、空回执和无效 JSON 分别复用上游、超时、空响应和反序列化语义;上游状态摘要与重试建议按状态码给出。sidecar 按 `mcp__` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。 - **诊断口径**:`agent.direct_turn.host_dropped` / `agent.direct_turn.panic` 里的令牌字段必须写 `tt=`,写 `turnToken=` 会命中脱敏标记,整行变成 ``,离线只剩"说不出原因"的 HostDropped。 - **验证**:dev 栈里用 CDP 注入真实回合(`node %TEMP%\agc-cdp.mjs `):①读文件轮 `claude-parse-done chars=108`,`.agent/conversations/project.jsonl` 出现 `direct-codex:cdp-…:assistant` 条目,回复内容与 `game/index.html` 前两行(`` / ``)逐字一致(证明宿主工具真的执行了);②聊天视图打开时注入 `只回三个字:收到了`,DOM 断言(`document.body.innerText`)同时出现用户气泡 `11:40:05`、助手回复 `收到了` 与 `本轮结束于 11:40:16 · 耗时 10.7秒`(证明 `item.completed` 实时投影生效,不必重进项目);同一日志不再出现新的 `host_dropped`。另有 `agent::claude_code_cli::tests::direct_claude_feedback_reply_does_not_fail_on_a_reused_client_turn_id` 回归覆盖同一回合两次不同回复。`cargo test … -- claude_code_cli::tests direct_turn_failure::tests` 19 passed。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`、`apps/ai-game-creator-shell/agent-sidecar/src/index.mjs`。 diff --git a/docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md b/docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md index 1de78d7c0..ab8643343 100644 --- a/docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md +++ b/docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md @@ -6,7 +6,7 @@ ## 1. 一句话 -功能画布 = 资源栏目页;栏目页左下角渲染一条由栏目 `category` 决定的工具栏,图片类与音频入口都走本地 `start_local_project_asset_generation`(提交即返回、后台生成,见 2026-09-20 音频并入后台任务账本),上传复用 `upload_local_asset`;生成 / 上传成功后一律走既有 manifest 刷新与资源定位。 +功能画布 = 资源栏目页;栏目页左下角渲染一条由栏目 `category` 决定的工具栏,图片类与音频入口都走本地 `start_local_project_asset_generation`(提交即返回、后台生成,见 2026-09-20 音频并入后台任务账本),上传复用 `upload_local_asset`(带当前栏目 `targetCategory`,素材登记进上传它的那一栏);生成 / 上传成功后一律走既有 manifest 刷新与资源定位。 ## 2. 入口矩阵(事实源) @@ -42,7 +42,7 @@ | 生成 UI 设计图 | 同上 | `kind: 'ui-design'`,默认 `16:9 · 1K`;前置同上 | | 生成背景音乐 | `start_local_project_asset_generation` | `kind: 'background-music'`、`idempotencyKey`;任务 id 即该次生成的 operation id | | 生成音效 | `start_local_project_asset_generation` | `kind: 'sound-effect'`、其余同上 | -| 上传 | `upload_local_asset` | `{ projectPath, fileName, mediaType, bytes }` | +| 上传 | `upload_local_asset` | `{ projectPath, fileName, mediaType, bytes, targetCategory }`;`targetCategory` = 当前栏目(Issue 359) | `start_local_project_asset_generation` 的完整参数是 `{ projectPath, projectId, taskId, kind, prompt, aspectRatio, imageSize, assetName, outputPath, idempotencyKey }`(Rust `src-tauri/src/asset_generation_tasks.rs`);图片类入口沿用 `{ projectPath, projectId, taskId, kind, prompt, aspectRatio, imageSize, assetName, outputPath }` 逐字不变,音频入口只带 `{ projectPath, projectId, taskId, kind, prompt, assetName, idempotencyKey }`(`idempotencyKey` = 该次生成的幂等键,任务 id 即 operation id;原生命令内部仍复用既有音频无源生成链路,不新增平台路由与请求体口径),**提交即返回**一条任务记录(`taskId / status / phaseDetail / assetId / error` 等);生成在 `tauri::async_runtime::spawn` 出来的后台任务里跑,账本落在项目内 `.agent/runtime/asset-generation-tasks/tasks.json`,进度由 `list_local_project_asset_generations` 读回。截图面板不再「等生成结束」,所以**点「生成」即同步关闭面板**(不等 IPC),面板内不出现阶段文案;只有「点击瞬间就失败」才带草稿重开(关闭 ≠ 取消)。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 4faeabece..2c56e16fb 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -134,7 +134,7 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema - 对客户端刚创建且内容仍匹配可信模板的 Web 脚手架,在正式生成前由宿主执行受控依赖准备和真实 Vite 构建。依赖安装禁用生命周期脚本;不自动安装或覆盖导入/用户修改过的工程。 - 准备凭证的 `ready` 只证明初次环境准备成功,不代表当前游戏已验收,后续正常修改不得因此重新安装。`preparing` 中断恢复必须证明原拥有者已结束且其执行子树已回收;身份或归属未知时保留阻断,不重复执行。 - 输出明确区分“Node/npm 构建能力通过”与“本项目 Vite 构建通过”;任何一步失败保留可行动错误,不降级为预检成功。 -- 首页必须区分宿主预检阻塞与 Tauri IPC 调用失败:Rust 返回的安全错误码可透传到状态栏;无错误码的瞬态 IPC 失败只允许一次有界重试后显示独立的客户端连接故障,不得统一伪装成 Node/npm 或浏览器故障。预检仍保持失败关闭,不得因为展示错误变得可绕过。 +- 首页必须区分宿主预检阻塞与 Tauri IPC 调用失败:Rust 返回的安全错误码可透传到状态栏;无错误码的瞬态 IPC 失败只允许一次有界重试后显示独立的客户端连接故障,不得统一伪装成 Node/npm 或浏览器故障。预检仍保持失败关闭,不得因为展示错误变得可绕过。浏览器 WS 握手、WS 超时和 CDP 连接失败只允许在确认本轮 AGC 浏览器进程树已退出、Profile/owner/临时目录已按归属清理后使用新的隔离 Profile 自动恢复一次;清理未确认时不得重试,连续失败必须返回包含阶段、子进程退出确认、清理确认和是否重试的安全诊断。 - 安装载荷提供相同的安全预检 CLI,验证无系统 Node 的独立运行、缺包/篡改失败关闭。NSIS 解包载荷 smoke 与真实安装器注册流程分开报告,不覆盖当前用户的既有安装。 ### 跑酷固定基线 @@ -188,7 +188,7 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema ### 环境与工作流 - 客户端交付配套 Node/npm;发布包从本机已安装且与目标平台/架构一致的工具链制作受校验资源,保留许可并校验内容摘要。安装态不依赖系统 PATH 的 Node;开发态可使用已验证的宿主运行时。不得从项目或相对 PATH 加载伪造运行时。随包运行时是**单架构**官方发行版,因此 macOS 当前只构建 `aarch64-apple-darwin` 单架构包;要出 universal 必须先让 staging 支持按架构各带一份同版本运行时,在此之前 universal 目标失败关闭,不得只带宿主架构那一份糊过去。 -- 新建 Web 游戏在生图和大量实现前执行客户端环境预检,检查 Node/npm 的实际版本、浏览器启动和 CDP 可用性。报告只包含安全状态、版本、耗时和错误码;错误码必须按真实原因分流,浏览器验证只允许在确有证据时使用 `web-preflight-browser-missing` / `-launch-timeout` / `-launch-failed` / `-browser-cleanup-failed`,取消、证据写入、输入与页面校验各有独立码,未识别原因落回专用 `web-preflight-unclassified`,不得用一个具体子系统码兜底。缺失或异常必须尽早返回阻塞,不能指示模型改宿主环境、全盘搜索或自行下载一套运行时。编辑器工程不强制 Web 工具链。 +- 新建 Web 游戏在生图和大量实现前执行客户端环境预检,检查 Node/npm 的实际版本、浏览器启动和 CDP 可用性。报告只包含安全状态、版本、耗时和错误码;错误码必须按真实原因分流,浏览器验证只允许在确有证据时使用 `web-preflight-browser-missing` / `-launch-timeout` / `-launch-failed` / `-browser-environment-failed` / `-browser-cleanup-failed` / `-browser-recovery-failed`,取消、证据写入、输入与页面校验各有独立码,未识别原因落回专用 `web-preflight-unclassified`,不得用一个具体子系统码兜底。浏览器恢复诊断只保留阶段、进程退出、清理确认和重试状态等安全字段,不暴露命令行、路径或上游原文。缺失或异常必须尽早返回阻塞,不能指示模型改宿主环境、全盘搜索或自行下载一套运行时。编辑器工程不强制 Web 工具链。 - 预检不安装依赖、不修改项目 revision、不请求平台生成;构建仍执行项目自己的 npm 脚本。Codex 隔离 HOME 与平台凭据边界保持不变,客户端把已验证的运行时加入执行 PATH,不能把宿主凭据目录交给模型。 - 第一轮先明确本次必需玩法、素材和验收项。同批独立读取尽量合并,必需图片一次规划;已有且可用的资产复用。已有目标全部通过后给出交付结果,非阻塞的新点子列为后续工作,不在收尾时主动开启新的生产链。 @@ -329,7 +329,7 @@ Rust 侧在 `server-rs/crates/shared-contracts` 维护唯一权威 `GameCreation - 参考选择范围为同一项目已登记图片,可跨栏目、多选,无规范前置时最多 5 张,有规范前置时最多 4 张用户参考(总计最多 5 张);复用资源引用选择组件,不允许文档、音视频、占位或跨项目素材。本地生成命令补最小引用 ID 参数并转换为当前账号绑定下的远端资源 ID,沿用图片生成 API 已有 `referenceImageSrcs`。需要规范图的普通图片请求合并并去重规范引用,总数不超过现有 API 限制;只接受单规范引用的图集操作不显示用户参考选择器,原生提交拒绝额外参考而非静默丢弃。不得降级成纯提示词。 - 占位由宿主按项目与独立草稿 ID 管理,提交后关联任务 ID;失败重试使用同一占位。切项目清理未提交草稿与界面位置,已提交任务继续沿用账本恢复,重开后不承诺恢复未持久化的占位位置。迟到结果先核对项目和任务归属;只有本会话仍存在的占位才应用最新位置。删除占位只隐藏展示,不取消后台任务或丢弃正式结果。 - 参考必须是原生可解码的栅格图片,SVG 不进入参考候选;原生在上传任何引用前预校验整组素材的归属、受控路径、文件及解码,失败不静默丢图。需要重新上传当前账号绑定的参考遵循 `asset.upload` 权限。manifest 读侧的引用形状检查不证明远端账号归属,实际生成始终通过当前账号 binding 解析,不凭历史来源 ID 发起请求。 -- 图片类 GUI 生成通过可选 `targetCategory` 在原生登记时写入入口栏目,使用既有 manifest `category` 字段及合法分类词表;不传时保留按 kind 派生的行为,Agent 不传。该值不改变远端生成内容及计费幂等槽,仅决定本地生成结果分类;重试保持原占位栏目。同路径重新生成时,主产物按本次入口栏目更新分类(包含覆盖此前手动分类),图集附属切片保持既有独立分类规则。 +- 图片类 GUI 生成通过可选 `targetCategory` 在原生登记时写入入口栏目,使用既有 manifest `category` 字段及合法分类词表;不传时保留按 kind 派生的行为,Agent 不传。该值不改变远端生成内容及计费幂等槽,仅决定本地生成结果分类;重试保持原占位栏目。同路径重新生成时,主产物按本次入口栏目更新分类(包含覆盖此前手动分类),图集附属切片保持既有独立分类规则。GUI 上传(栏目画布底部工具栏的 `upload_local_asset`)与图片类生成**同一口径**:带可选 `targetCategory` 在当前栏目登记,不传(资源面板、UI 编辑器导入、聊天附件)时保持按内容证据派生 kind 的行为。 - 生成面板打开后,占位和面板需处于当前画布标题栏与底部工具栏之间;面板复用公共外观,空间不足时面板内部滚动,提交按钮可达。音频/BGM 占位绑定原有 operation/idempotency 身份,进行中不允许换身份重复提交;失败可用原身份重试,成功结果与图片一样接管占位。关闭未提交浮层保留可继续编辑的草稿,删除占位才丢弃该草稿。 - 整理范围为当前栏目页全部资源;“所有资源”页为当前项目所有可展示资源,总览不新增整理行为。重排结果成为自动坐标,可撤销恢复原坐标与手动标记;历史仅保留当前会话,切项目清空。多选仅作用于当前画布可见选中资源,不携带筛选隐藏或跨栏目残留选择;取消手势恢复拖动前坐标,切项目清空选择。 - 当前素材名以现有正式命名链路为准:生成时 assetName 参与落盘名称,重命名更新文件名;卡片消费正式资源 label,不从临时输入或历史任务名覆盖后续重命名,不新增平行显示名持久化。若原有命名链路丢失 assetName,则修复原链路,而非只在卡片本地伪造。文档卡不显示任何正文摘要,但详情原文与 JSON 识别读取不变。 diff --git a/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md b/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md index 08df2cc5f..07b6c5b88 100644 --- a/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md +++ b/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md @@ -57,7 +57,7 @@ | **S15** 删除 | 工具条 / 标签面板「删除」→ 确认弹窗 | 三分支:① 无引用→直接删;② 被引用未勾选→只删素材、版本保留**悬空绑定**、界面不合成幽灵资源卡;③ 勾选→素材与该批版本在**同一次 manifest 写入**内一起删 | L413(版本只追加的唯一例外)、L532–533;#309 C5 | 弹窗 `ariaLabel="确认删除资源"`;被引用时出现 `被 N 个游戏版本使用` + 版本列表 + checkbox `把相关游戏版本一并删除`;无引用时该 body 整块不渲染 | **只摘登记、不删磁盘文件**;读引用命令精确名是 `read_local_project_asset_references` | | **S15a** 替换素材(面板内选 + 画布点选目标) | 选中被**当前版本**绑定的素材 → 工具条「替换素材」→ 画布右上角出现非模态候选面板(面板内可筛可选)→ 直接在画布上点目标素材(或点面板里的候选)→ 面板「确认」 | 面板确认与画布点选是**同一条**写入链路(`replace_local_project_version_resource`,载荷逐字一致):改该版本绑定、不建新版本;画布点选只把目标落成面板的当前选择,确认后才写入 | PRD §5.3 / §7.8 第 8 条;#309「直接替换」口径 | 面板判据:`role="dialog"` 名为「选择替换素材」、挂 `.image-canvas-editor__project-asset-picker--floating`、**没有** `.platform-overlay` 遮罩;点画布候选后面板里该候选 `aria-selected="true"`、面板里的搜索词与分类筛选保持原样、零写入;非法目标(源素材本身 / 不在权威候选里 / 分类不同 / 未登记资源)在面板里出现 `role="alert"`(「替换素材与源素材相同」/「替换素材未登记或已被删除」/「替换素材不兼容:分类不同」)且零写入;面板关闭 = 卸载;Esc 只收面板(画布全局 Esc 的清选中不随之触发) | 点选只能点**当前画布上可见**的卡:目标在别的栏目时先切栏目(替换会话跨栏目存活,不因「收起资源」或切栏目结束);面板开着时空白处仍可框选 / 平移,卡片单击语义的恢复发生在面板关闭之后 | -| **S11a** 栏目画布底部工具栏 | 进「UI 交互 / 角色与对象 / 场景与环境 / 音频」任一栏目 → 点左下角工具栏里的入口生成 → 「收起资源」回总览看工具栏消失 | 工具栏只在矩阵四个栏目(功能画布)渲染;图片类入口走 `start_local_project_asset_generation`(提交即返回、生成在后台跑),音频入口走同一条命令的音频载荷,上传复用 `upload_local_asset`;生成 / 上传成功后走既有 manifest 刷新与资源定位 | PRD §3.10、§7.9 | DOM 判据 `[data-resource-bottom-toolbar=""]`(资源总览、「所有资源」展开态、文档、待归类、项目版本都**没有**这个节点);工具栏是 `.game-resource-book-manager` 的直接子节点(`closest('.game-resource-book-scene')` 为 `null`);每个入口一次 `start_local_project_asset_generation`:图片类载荷逐字为 `{ projectPath, projectId, taskId, kind, prompt, aspectRatio, imageSize, assetName, outputPath }`(`kind` 映射见 PRD §7.9 第 3 条;`taskId` 是前端每次提交新铸的本地任务 id),音频类载荷为 `{ projectPath, projectId, taskId, kind, prompt, assetName, idempotencyKey }`(`taskId` 即该次生成的 operation id,不发图片类那套比例 / 尺寸 / 参考 / 落点参数);该命令提交即返回,之后有 `list_local_project_asset_generations` 轮询与 `get_local_game_project_revision` + `get_local_game_manifest` 的配对读;点「生成」即把这次输入交给后台账本并**同步关闭**面板(不等 IPC、不等排队、不等生成,画布立即恢复可交互;面板 DOM 里没有阶段文案与「后台运行并关闭」这类在途按钮),关闭不等于取消,关闭后任务仍出现在「生成任务」面板(入口按钮 `aria-label="生成任务"`,非模态浮层、无 `aria-modal`)并显示后端 `phaseDetail`;第一条未终态时提交第二条 → 第二条显示「排队中。」且生成提交 IPC 次数仍为 1,第一条终态后自动补发(次数变 2);缺 `assets/art-spec.png` 时「生成图标素材 / 生成 UI 设计图」仍可点击(`aria-disabled="true"` 但**不是**原生 disabled)并给出含该路径的 `role="alert"` 原因、零生成请求;音频入口面板标题即「生成背景音乐」/「生成音效」且没有类型选择器 | ① 本地通道没有 `model` / `specType` / `replaceExisting` 入参:面板不渲染模型选择器,角色规范与自定义规范共用 `spec` 通道(靠 `assetName` 与提示词区分),「图标规范」在项目已有权威规范图时不再指向 `assets/art-spec.png`(否则会被 Rust 的防覆盖校验硬拒);② 本轮不做生成视频 / 宣发素材 / 生成游戏场景 / 选择工具 / 抓手工具;③ 既有「生成素材」浮层入口只保留生成视频,音频入口只在音频栏目工具栏出现 | +| **S11a** 栏目画布底部工具栏 | 进「UI 交互 / 角色与对象 / 场景与环境 / 音频」任一栏目 → 点左下角工具栏里的入口生成 → 「收起资源」回总览看工具栏消失 | 工具栏只在矩阵四个栏目(功能画布)渲染;图片类入口走 `start_local_project_asset_generation`(提交即返回、生成在后台跑),音频入口走同一条命令的音频载荷,上传复用 `upload_local_asset` 并带上当前栏目 `targetCategory`(素材落在上传它的那一栏,不落「待归类」);生成 / 上传成功后走既有 manifest 刷新与资源定位 | PRD §3.10、§7.9 | DOM 判据 `[data-resource-bottom-toolbar=""]`(资源总览、「所有资源」展开态、文档、待归类、项目版本都**没有**这个节点);工具栏是 `.game-resource-book-manager` 的直接子节点(`closest('.game-resource-book-scene')` 为 `null`);每个入口一次 `start_local_project_asset_generation`:图片类载荷逐字为 `{ projectPath, projectId, taskId, kind, prompt, aspectRatio, imageSize, assetName, outputPath }`(`kind` 映射见 PRD §7.9 第 3 条;`taskId` 是前端每次提交新铸的本地任务 id),音频类载荷为 `{ projectPath, projectId, taskId, kind, prompt, assetName, idempotencyKey }`(`taskId` 即该次生成的 operation id,不发图片类那套比例 / 尺寸 / 参考 / 落点参数);该命令提交即返回,之后有 `list_local_project_asset_generations` 轮询与 `get_local_game_project_revision` + `get_local_game_manifest` 的配对读;点「生成」即把这次输入交给后台账本并**同步关闭**面板(不等 IPC、不等排队、不等生成,画布立即恢复可交互;面板 DOM 里没有阶段文案与「后台运行并关闭」这类在途按钮),关闭不等于取消,关闭后任务仍出现在「生成任务」面板(入口按钮 `aria-label="生成任务"`,非模态浮层、无 `aria-modal`)并显示后端 `phaseDetail`;第一条未终态时提交第二条 → 第二条显示「排队中。」且生成提交 IPC 次数仍为 1,第一条终态后自动补发(次数变 2);缺 `assets/art-spec.png` 时「生成图标素材 / 生成 UI 设计图」仍可点击(`aria-disabled="true"` 但**不是**原生 disabled)并给出含该路径的 `role="alert"` 原因、零生成请求;音频入口面板标题即「生成背景音乐」/「生成音效」且没有类型选择器 | ① 本地通道没有 `model` / `specType` / `replaceExisting` 入参:面板不渲染模型选择器,角色规范与自定义规范共用 `spec` 通道(靠 `assetName` 与提示词区分),「图标规范」在项目已有权威规范图时不再指向 `assets/art-spec.png`(否则会被 Rust 的防覆盖校验硬拒);② 本轮不做生成视频 / 宣发素材 / 生成游戏场景 / 选择工具 / 抓手工具;③ 既有「生成素材」浮层入口只保留生成视频,音频入口只在音频栏目工具栏出现 | ### D 阶段 · 版本与运行