diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index 91e5d01a0..b6193445f 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -2,6 +2,8 @@ "schemaVersion": "game-creator-config.v2", "agentMode": "codex_app_server", "llm": { + "customEnabled": false, + "visibleModels": [], "apiKey": "", "baseUrl": "https://dev.genarrative.world/gpt/v1", "model": "gpt-6-astra", diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 046b6154e..ce2317900 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -1,7 +1,7 @@ { "name": "@genarrative/ai-game-creator-shell", "private": true, - "version": "0.1.45", + "version": "0.1.47", "type": "module", "scripts": { "dev": "node scripts/start-tauri-dev.mjs", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 06994d8c6..7dd55a0f6 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "genarrative-ai-game-creator-shell" -version = "0.1.45" +version = "0.1.47" dependencies = [ "agent-runtime-core", "axum", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 6c3d01ec0..b5cb1f2d2 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "genarrative-ai-game-creator-shell" -version = "0.1.45" +version = "0.1.47" edition = "2021" publish = false 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 25d1591e4..170130beb 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 @@ -131,7 +131,6 @@ impl CodexAppServerCredential { ) -> Option<(&'a str, &'a str)> { match self { Self::PlatformSession { .. } => None, - #[cfg(test)] Self::AppDataKey { .. } => (!llm.api_key.trim().is_empty()) .then_some((llm.base_url.trim_end_matches('/'), llm.api_key.trim())), #[cfg(test)] @@ -139,7 +138,7 @@ impl CodexAppServerCredential { .as_deref() .map(|api_key| (GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL, api_key)), #[cfg(not(test))] - Self::AppDataKey { .. } | Self::AuthBridge { .. } => None, + Self::AuthBridge { .. } => None, } } } @@ -2018,7 +2017,13 @@ impl CodexAppServerConnection { let codex_cli_version = game_creator_codex_cli_version_identity() .map_err(platform_llm::LlmError::InvalidConfig)?; let mut effective_llm = llm.clone(); - let credential = if game_creator_official_llm_route_locked() { + let credential = if llm.custom_enabled { + crate::config::validate_custom_llm_connection(llm) + .map_err(platform_llm::LlmError::InvalidConfig)?; + CodexAppServerCredential::AppDataKey { + fingerprint: format!("custom-key:{:x}", Sha256::digest(llm.api_key.as_bytes())), + } + } else if game_creator_official_llm_route_locked() { let session = current_platform_session().ok_or_else(|| { platform_llm::LlmError::InvalidConfig( "authentication-required: 请先登录陶泥儿账号".to_string(), @@ -2186,7 +2191,8 @@ impl CodexAppServerConnection { true, ), _ => ( - (workspace_mode == CodexAppServerWorkspaceMode::DirectProject) + (llm.custom_enabled + || workspace_mode == CodexAppServerWorkspaceMode::DirectProject) .then(|| credential.direct_provider_route(llm)) .flatten() .map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())), @@ -4743,6 +4749,8 @@ mod tests { fn test_llm() -> GameCreatorLlmConfig { GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "fixture-secret".to_string(), base_url: "https://example.invalid/v1".to_string(), model: "fixture-model".to_string(), @@ -5528,6 +5536,59 @@ mod tests { assert_ne!(command_token, provider_key); } + #[tokio::test] + async fn custom_llm_broker_uses_configured_route_without_exposing_upstream_key() { + let mut llm = test_llm(); + llm.custom_enabled = true; + llm.api_key = "custom-upstream-fixture-secret".into(); + llm.base_url = "http://127.0.0.1:9/v1".into(); + llm.model = "vendor/model.v1:latest".into(); + llm.visible_models = vec![llm.model.clone()]; + let credential = CodexAppServerCredential::AppDataKey { + fingerprint: "custom-fixture".into(), + }; + let (base, key) = credential + .direct_provider_route(&llm) + .expect("custom route"); + assert_eq!(base, llm.base_url); + assert_eq!(key, llm.api_key); + let proxy = start_codex_provider_proxy(base, key, false).await.unwrap(); + for mode in [ + CodexAppServerWorkspaceMode::DirectProject, + CodexAppServerWorkspaceMode::ToolHost, + ] { + let mut command = tokio::process::Command::new("fixture"); + configure_game_creator_codex_app_server_command_for_mode( + &mut command, + &llm, + mode, + Some(&proxy), + None, + true, + ) + .unwrap(); + let arguments = command + .as_std() + .get_args() + .map(|arg| arg.to_string_lossy()) + .collect::>() + .join("\n"); + let params = codex_app_server_thread_start_params( + &llm.model, + std::path::Path::new("fixture-workspace"), + mode, + String::new(), + true, + ); + assert_eq!(params["model"], "vendor/model.v1:latest"); + assert!(!arguments.contains(&llm.api_key)); + assert!(!arguments.contains("/api/llm")); + for (_, value) in command.as_std().get_envs() { + assert!(!value.is_some_and(|value| value.to_string_lossy().contains(&llm.api_key))); + } + } + } + #[cfg(unix)] #[tokio::test] async fn direct_project_spawn_restores_broker_token_after_environment_isolation() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index ab733b185..43781b495 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -5329,6 +5329,8 @@ mod tests { fn direct_test_llm() -> GameCreatorLlmConfig { GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "fixture-secret".to_string(), base_url: "https://example.invalid/v1".to_string(), model: "fixture-model".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs index d0e3a92a0..624479e44 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs @@ -605,6 +605,8 @@ fn finalization_cleanup_closes_entire_tool_plan_repair_chain_before_removal() { response_stream_fixture("finalization-tool-plan-repair-chain-run"); let root = project.path(); let llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "finalization-tool-plan-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "finalization-tool-plan-model".to_string(), @@ -968,6 +970,8 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon let root = project.path(); let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff 身份漂移")]); let old_llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "old-provider-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "old-provider-model".to_string(), @@ -1073,6 +1077,8 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo LlmMessage::user("修复格式"), ]); let old_llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "old-tool-plan-provider-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "old-tool-plan-model".to_string(), @@ -1192,6 +1198,8 @@ async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_remov response_stream_fixture("generic-retry-drift-tool-plan-chain-run"); let root = project.path(); let old_llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "old-generic-retry-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "old-generic-retry-model".to_string(), @@ -1277,6 +1285,8 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() { response_stream_fixture("tool-plan-capacity-preflight-run"); let root = project.path(); let llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "tool-plan-capacity-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "tool-plan-capacity-model".to_string(), @@ -1400,6 +1410,8 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem LlmMessage::user("修复格式"), ]); let llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "durable-control-tool-plan-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "durable-control-tool-plan-model".to_string(), @@ -1525,6 +1537,8 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff( snapshot.request_slot = "loop-0-repair-0".to_string(); let request = LlmRunRequest::new(vec![LlmMessage::user("等待 steer 或 cancel")]); let llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "tool-plan-cleanup-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "tool-plan-cleanup-model".to_string(), @@ -1597,6 +1611,8 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() { snapshot.request_slot = "loop-0-repair-0".to_string(); let request = LlmRunRequest::new(vec![LlmMessage::user("终态遗留 handoff")]); let llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "terminal-handoff-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "terminal-handoff-model".to_string(), @@ -1693,6 +1709,8 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat let root = project.path(); let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff/retry 冲突")]); let llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "provider-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "provider-model".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index f183e0f6b..02b48e807 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -2,8 +2,9 @@ use super::*; pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock = OnceLock::new(); -pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK: OnceLock< - std::sync::Mutex>, +/// 同一 AppData 允许多个界面窗口同时挂载事件接收端,因此这里是按 token 去重的注册表。 +pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS: OnceLock< + std::sync::Mutex>, > = OnceLock::new(); #[cfg(test)] pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK: std::sync::Mutex<()> = @@ -293,8 +294,8 @@ pub(crate) use entrypoints::{ configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress, emit_game_creator_agent_runtime_update, emit_game_creator_manifest_invalidated, game_creator_agent_runtime_update_event, generate_local_game_draft_at, - install_game_creator_manifest_invalidation_event_sink, read_game_creator_agent_runtime_at, - read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at, + read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at, + read_game_creator_agent_runtimes_at, register_game_creator_manifest_invalidation_event_sink, set_game_creator_agent_runtime_update_app_handle, start_game_creator_manifest_invalidation_event_sink, validate_game_creator_manifest_invalidation_event_sink, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 4038f1bcb..4e5177b10 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -1,11 +1,12 @@ use super::*; const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024; +const GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX: usize = 16; -fn lock_game_creator_manifest_invalidation_event_sink( -) -> std::sync::MutexGuard<'static, Option> { - GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK - .get_or_init(|| Mutex::new(None)) +fn lock_game_creator_manifest_invalidation_event_sinks( +) -> std::sync::MutexGuard<'static, Vec> { + GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS + .get_or_init(|| Mutex::new(Vec::new())) .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } @@ -219,7 +220,7 @@ pub(crate) fn configure_game_creator_manifest_invalidation_event_sink( token: &str, ) -> Result<(), String> { let sink = validate_game_creator_manifest_invalidation_event_sink(port, token)?; - install_game_creator_manifest_invalidation_event_sink(sink); + register_game_creator_manifest_invalidation_event_sink(sink); Ok(()) } @@ -240,10 +241,29 @@ pub(crate) fn validate_game_creator_manifest_invalidation_event_sink( }) } -pub(crate) fn install_game_creator_manifest_invalidation_event_sink( +/// 登记一个界面窗口的事件接收端。 +/// +/// 同一窗口重复 attach 用同一个 token,按 token 覆盖旧登记;不同窗口各自持有 +/// 自己的 token,注册表按登记顺序保留,最多 `GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX` 个。 +pub(crate) fn register_game_creator_manifest_invalidation_event_sink( sink: GameCreatorManifestInvalidationEventSink, ) { - *lock_game_creator_manifest_invalidation_event_sink() = Some(sink); + let mut sinks = lock_game_creator_manifest_invalidation_event_sinks(); + if let Some(existing) = sinks + .iter_mut() + .find(|existing| existing.token == sink.token) + { + *existing = sink; + return; + } + if sinks.len() >= GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX { + sinks.remove(0); + } + sinks.push(sink); +} + +fn remove_game_creator_manifest_invalidation_event_sink(token: &str) { + lock_game_creator_manifest_invalidation_event_sinks().retain(|sink| sink.token != token); } #[cfg(test)] @@ -258,14 +278,20 @@ impl GameCreatorManifestInvalidationEventSinkTestGuard { } pub(crate) fn configured_sink(&self) -> Option { - lock_game_creator_manifest_invalidation_event_sink().clone() + lock_game_creator_manifest_invalidation_event_sinks() + .first() + .cloned() + } + + pub(crate) fn configured_sinks(&self) -> Vec { + lock_game_creator_manifest_invalidation_event_sinks().clone() } } #[cfg(test)] impl Drop for GameCreatorManifestInvalidationEventSinkTestGuard { fn drop(&mut self) { - *lock_game_creator_manifest_invalidation_event_sink() = None; + lock_game_creator_manifest_invalidation_event_sinks().clear(); } } @@ -281,16 +307,43 @@ pub(crate) fn acquire_game_creator_manifest_invalidation_event_sink_test_guard( } fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Result<(), String> { - let sink = lock_game_creator_manifest_invalidation_event_sink().clone(); - let Some(sink) = sink else { + let sinks = lock_game_creator_manifest_invalidation_event_sinks().clone(); + if sinks.is_empty() { return Ok(()); + } + let event = GameCreatorManifestInvalidatedEvent { + project_path: root.to_string_lossy().into_owned(), + agent_id: agent_id.to_string(), }; + let mut failed_tokens = Vec::new(); + let mut last_error = None; + for sink in &sinks { + match relay_game_creator_manifest_invalidation_to_sink(sink, &event) { + Ok(()) => {} + Err(error) => { + // 窗口已退出或接收端已释放时只淘汰该接收端,不能影响其它窗口。 + failed_tokens.push(sink.token.clone()); + last_error = Some(error); + } + } + } + if !failed_tokens.is_empty() { + lock_game_creator_manifest_invalidation_event_sinks() + .retain(|sink| !failed_tokens.contains(&sink.token)); + } + match last_error { + Some(error) => Err(error), + None => Ok(()), + } +} + +fn relay_game_creator_manifest_invalidation_to_sink( + sink: &GameCreatorManifestInvalidationEventSink, + event: &GameCreatorManifestInvalidatedEvent, +) -> Result<(), String> { let envelope = GameCreatorManifestInvalidationRelayEnvelope { - token: sink.token, - event: GameCreatorManifestInvalidatedEvent { - project_path: root.to_string_lossy().into_owned(), - agent_id: agent_id.to_string(), - }, + token: sink.token.clone(), + event: event.clone(), }; let payload = serde_json::to_vec(&envelope) .map_err(|error| format!("序列化 manifest 失效事件失败:{error}"))?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index eee72252c..12650f798 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1991,6 +1991,8 @@ pub(crate) fn write_game_creator_app_config( .lock() .map_err(|_| "配置写入锁不可用")?; let (current, overlays) = load_game_creator_app_config_for_write()?; + // 自定义开关只能从本地配置文件开启,不能由渲染层越过配置门禁。 + config.llm.custom_enabled = current.llm.custom_enabled; config.selected_model_id = current.selected_model_id; config.selected_model_is_default = current.selected_model_is_default; persist_game_creator_app_config(config, overlays, false) @@ -2027,7 +2029,12 @@ pub(crate) fn select_game_creator_model( let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK .lock() .map_err(|_| "配置写入锁不可用")?; - if model_id.is_empty() + let (mut config, overlays) = load_game_creator_app_config_for_write()?; + if config.llm.custom_enabled { + if !config.llm.visible_models.contains(&model_id) { + return Err("所选模型未勾选或已移除,请刷新模型列表".into()); + } + } else if model_id.is_empty() || model_id.len() > 64 || !model_id .bytes() @@ -2035,12 +2042,21 @@ pub(crate) fn select_game_creator_model( { return Err("模型标识无效".into()); } - let (mut config, overlays) = load_game_creator_app_config_for_write()?; config.selected_model_id = model_id; config.selected_model_is_default = is_default; persist_game_creator_app_config(config, overlays, true) } +#[tauri::command] +pub(crate) async fn discover_game_creator_llm_models( + llm: GameCreatorLlmConfig, +) -> Result, String> { + if !load_game_creator_app_config()?.llm.custom_enabled { + return Err("请先在本地配置中开启 llm.customEnabled".to_string()); + } + fetch_custom_llm_models(&llm).await +} + fn persist_game_creator_app_config( config: GameCreatorAppConfig, overlays: Vec<(PathBuf, serde_json::Value)>, @@ -2056,8 +2072,8 @@ fn persist_game_creator_app_config( let previous = overlay.clone(); if let Some(fields) = overlay.as_object_mut() { for (key, value) in fields.iter_mut() { - if matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault") - == model_only + if !model_only + || matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault") { if let Some(saved_value) = saved.get(key) { // 仅同步已有覆盖项;其它字段继续保留原有覆盖语义。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index b66b976af..95c7726f6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -108,6 +108,8 @@ fn user_selected_path_is_authorized(path: &Path, is_directory: bool) -> bool { } pub(crate) const OFFICIAL_LLM_ROUTER_BASE_URL: &str = "https://router.genarrative.world/v1"; +/// 官方路由未选择平台目录模型时写入配置文件的占位标识。 +pub(crate) const OFFICIAL_LLM_ROUTER_DEFAULT_MODEL: &str = "platform-default"; pub(crate) const GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS: [(&str, &str); 21] = [ (GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "high"), @@ -162,6 +164,9 @@ fn build_game_creator_platform_llm_config( llm: &GameCreatorLlmConfig, config_path: &str, ) -> Result { + if llm.custom_enabled { + return build_game_creator_provider_llm_config(llm, config_path); + } if game_creator_official_llm_route_locked() { return build_game_creator_official_platform_llm_config(llm); } @@ -454,7 +459,7 @@ fn check_game_creator_codex_config( ); let mut status = check_game_creator_llm_config_values(&app_config.llm, "llm"); status.agent_mode = app_config.agent_mode.clone(); - if game_creator_official_llm_route_locked() { + if !app_config.llm.custom_enabled && game_creator_official_llm_route_locked() { let account_ready = current_platform_session().is_some(); status.account_credential_state = if account_ready { "ready".to_string() @@ -490,7 +495,7 @@ fn check_game_creator_codex_config( ); agent.configured = cli_error.is_none() && route_error.is_none(); agent.error = cli_error.clone().or(route_error); - if game_creator_official_llm_route_locked() { + if !llm.custom_enabled && game_creator_official_llm_route_locked() { agent.account_credential_state = status.account_credential_state.clone(); agent.official_route_locked = true; agent.configured = status.configured; @@ -528,6 +533,14 @@ pub(crate) fn game_creator_codex_app_server_llm_route_error( if agent_mode != GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { return None; } + if llm.custom_enabled { + if let Err(error) = validate_custom_llm_connection(llm) { + return Some(error); + } + if !llm.visible_models.contains(&llm.model) { + return Some("请至少勾选一个模型,并从已勾选列表选择模型".to_string()); + } + } if llm.api_kind != "openai_responses" { return Some(format!( "配置项 {config_path}.apiKind={} 不能由 codex_app_server 直接映射;请使用 openai_responses 或切换 provider 模式", @@ -601,7 +614,7 @@ pub(crate) fn check_game_creator_llm_config_values( "unavailable" } .to_string(), - official_route_locked: game_creator_official_llm_route_locked(), + official_route_locked: !config.custom_enabled && game_creator_official_llm_route_locked(), reasoning_effort: config.reasoning_effort.clone(), stream: config.stream, web_search_enabled: config.web_search_enabled, @@ -3332,9 +3345,7 @@ pub(crate) fn configure_game_creator_runtime_config_dir( write_game_creator_config_atomically(&config_path, DEFAULT_GAME_CREATOR_APP_CONFIG_JSON) .map_err(std::io::Error::other)?; } - // Both the normal config and the optional local override are persisted - // inputs. Every real AGC build scrubs legacy provider credentials from - // either file before the next read can observe them again. + // 按主配置与本地覆盖的最终开关决定是否保留自定义连接。 for path in [ config_path, config_dir.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME), @@ -3421,6 +3432,7 @@ pub(crate) fn load_game_creator_app_config() -> Result bool { + if config.llm.as_ref().and_then(|llm| llm.custom_enabled) == Some(true) { + return false; + } let mut changed = config.agent_mode.as_deref() != Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER) || config.agent_llm.is_some(); config.agent_mode = Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string()); config.agent_llm = None; if let Some(llm) = config.llm.as_mut() { - changed |= llm.api_key.is_some() - || llm.base_url.is_some() - || llm.model.is_some() - || llm.api_kind.is_some(); - llm.api_key = None; - llm.base_url = None; - llm.model = None; - llm.api_kind = None; + // 官方路由仍然清空凭据,但把连接字段留在文件里:手写自定义连接时 + // 用户能看到 baseUrl / apiKey / model / apiKind 四要素与开关、模型列表并列。 + let official_model = config + .selected_model_id + .clone() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| OFFICIAL_LLM_ROUTER_DEFAULT_MODEL.to_string()); + changed |= llm.api_key.as_deref() != Some("") + || llm.base_url.as_deref() != Some(OFFICIAL_LLM_ROUTER_BASE_URL) + || llm.model.as_deref() != Some(official_model.as_str()) + || llm.api_kind.as_deref() != Some(DEFAULT_GAME_CREATOR_LLM_API_KIND) + || llm.custom_enabled.is_none() + || llm.visible_models.is_none(); + llm.api_key = Some(String::new()); + llm.base_url = Some(OFFICIAL_LLM_ROUTER_BASE_URL.to_string()); + llm.model = Some(official_model); + llm.api_kind = Some(DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string()); + llm.custom_enabled = Some(false); + llm.visible_models = Some(llm.visible_models.take().unwrap_or_default()); } if config.editor_api.is_some() { changed = true; @@ -3480,6 +3507,7 @@ pub(crate) fn migrate_legacy_game_creator_agent_mode(path: &Path) -> Result<(), let mut config = serde_json::from_str::(&content) .map_err(|error| format!("解析客户端配置失败:{}: {error}", path.display()))?; let mut changed = false; + changed |= ensure_game_creator_custom_llm_file_fields(&mut config); let inferred_agent_mode = config .agent_mode .as_deref() @@ -3531,7 +3559,7 @@ pub(crate) fn migrate_legacy_game_creator_agent_mode(path: &Path) -> Result<(), )); } } - if game_creator_official_llm_route_locked() { + if game_creator_official_llm_route_locked() && !custom_llm_enabled_at_config_path(path)? { changed |= scrub_locked_game_creator_config_file(&mut config); } if changed { @@ -3542,11 +3570,30 @@ pub(crate) fn migrate_legacy_game_creator_agent_mode(path: &Path) -> Result<(), Ok(()) } -/// Returns whether a real AGC build must use the authenticated API Server -/// proxy instead of any persisted provider credentials. +/// 让文件始终带 `customEnabled` 与 `visibleModels`:自定义连接靠手写这些字段开启, +/// 键缺席时用户无法从文件本身看出开关和模型列表写在哪里。 +pub(crate) fn ensure_game_creator_custom_llm_file_fields( + config: &mut GameCreatorAppConfigFile, +) -> bool { + let Some(llm) = config.llm.as_mut() else { + return false; + }; + let mut changed = false; + if llm.custom_enabled.is_none() { + llm.custom_enabled = Some(false); + changed = true; + } + if llm.visible_models.is_none() { + llm.visible_models = Some(Vec::new()); + changed = true; + } + changed +} + +/// 默认官方路由策略;显式 llm.customEnabled 由调用方优先处理。 /// /// Debug and release binaries intentionally share this decision. The two -/// exceptions are the Rust unit-test build and the explicitly env-gated debug +/// test exceptions are the Rust unit-test build and the explicitly env-gated debug /// deterministic-provider E2E; their loopback fixtures are never compiled into /// or enabled inside a shipped release binary. pub(crate) fn game_creator_official_llm_route_locked() -> bool { @@ -3577,16 +3624,19 @@ pub(crate) fn lock_game_creator_app_config_to_official_route(config: &mut GameCr return; } config.agent_mode = GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string(); + config.agent_llm.clear(); + config.editor_api.api_key.clear(); + if config.llm.custom_enabled { + return; + } config.llm.api_key.clear(); config.llm.base_url = OFFICIAL_LLM_ROUTER_BASE_URL.to_string(); config.llm.model = if config.selected_model_id.is_empty() { - "platform-default".to_string() + OFFICIAL_LLM_ROUTER_DEFAULT_MODEL.to_string() } else { config.selected_model_id.clone() }; config.llm.api_kind = DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(); - config.agent_llm.clear(); - config.editor_api.api_key.clear(); } pub(crate) fn game_creator_app_config_view( @@ -3944,6 +3994,12 @@ pub(crate) fn merge_game_creator_llm_config( config: &mut GameCreatorLlmConfig, patch: GameCreatorLlmConfigFile, ) { + if let Some(value) = patch.custom_enabled { + config.custom_enabled = value; + } + if let Some(value) = patch.visible_models { + config.visible_models = value; + } if let Some(value) = patch.api_key { config.api_key = value; } @@ -3989,6 +4045,12 @@ pub(crate) fn merge_game_creator_llm_patch( config: &mut GameCreatorLlmConfigFile, patch: GameCreatorLlmConfigFile, ) { + if let Some(value) = patch.custom_enabled { + config.custom_enabled = Some(value); + } + if let Some(value) = patch.visible_models { + config.visible_models = Some(value); + } if let Some(value) = patch.api_key { config.api_key = Some(value); } @@ -4073,6 +4135,145 @@ pub(crate) fn trim_config_string(value: &str) -> Option { } } +fn custom_llm_enabled_at_config_path(path: &Path) -> Result { + let parent = path.parent().ok_or("客户端配置缺少父目录")?; + let mut enabled = false; + for name in [ + GAME_CREATOR_CONFIG_FILE_NAME, + GAME_CREATOR_LOCAL_CONFIG_FILE_NAME, + ] { + if let Some(content) = read_game_creator_config_file(&parent.join(name))? { + let file: GameCreatorAppConfigFile = + serde_json::from_str(&content).map_err(|_| "解析客户端配置失败".to_string())?; + if let Some(value) = file.llm.and_then(|llm| llm.custom_enabled) { + enabled = value; + } + } + } + Ok(enabled) +} + +pub(crate) fn validate_custom_llm_connection( + llm: &GameCreatorLlmConfig, +) -> Result { + if llm.api_key.trim().is_empty() { + return Err("请填写自定义 LLM API Key".to_string()); + } + let url = + url::Url::parse(llm.base_url.trim()).map_err(|_| "自定义 LLM API 地址无效".to_string())?; + if !matches!(url.scheme(), "http" | "https") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err("请填写不含凭据、查询参数和片段的 HTTP(S) API 根地址".to_string()); + } + Ok(url) +} + +pub(crate) fn normalize_custom_llm_model_ids(ids: &[String]) -> Result, String> { + if ids.len() > 4096 { + return Err("模型列表超过 4096 项上限".to_string()); + } + let mut result = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for id in ids { + let id = id.trim(); + if id.is_empty() + || id.len() > 256 + || id.chars().any(|c| c.is_control() || c.is_whitespace()) + { + return Err("模型标识为空、包含空白或超过 256 字节".to_string()); + } + if seen.insert(id.to_string()) { + result.push(id.to_string()); + } + } + Ok(result) +} + +fn apply_custom_llm_model_selection(config: &mut GameCreatorAppConfig) { + if !config.llm.custom_enabled { + return; + } + if config.selected_model_is_default + || !config + .llm + .visible_models + .contains(&config.selected_model_id) + { + config.selected_model_id = config + .llm + .visible_models + .first() + .cloned() + .unwrap_or_default(); + config.selected_model_is_default = true; + } + config.llm.model = config.selected_model_id.clone(); +} + +pub(crate) async fn fetch_custom_llm_models( + llm: &GameCreatorLlmConfig, +) -> Result, String> { + let mut url = validate_custom_llm_connection(llm)?; + url.set_path(&format!("{}/models", url.path().trim_end_matches('/'))); + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|_| "初始化模型列表请求失败".to_string())?; + let mut response = client + .get(url) + .bearer_auth(llm.api_key.trim()) + .send() + .await + .map_err(|error| { + if error.is_timeout() { + "模型列表请求超时,请重试".to_string() + } else { + "无法连接模型端点,请检查 API 地址和网络".to_string() + } + })?; + if !response.status().is_success() { + return Err(format!( + "模型列表读取失败(HTTP {})", + response.status().as_u16() + )); + } + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| "读取模型列表响应失败或超时".to_string())? + { + if body.len() + chunk.len() > 1024 * 1024 { + return Err("模型列表响应超过 1 MiB 上限".to_string()); + } + body.extend_from_slice(&chunk); + } + #[derive(Deserialize)] + struct Model { + id: String, + } + #[derive(Deserialize)] + struct Models { + data: Vec, + } + let models: Models = serde_json::from_slice(&body) + .map_err(|_| "模型端点需返回 OpenAI 兼容的 data[].id 列表".to_string())?; + let ids = models + .data + .into_iter() + .map(|model| model.id) + .collect::>(); + let mut ids = normalize_custom_llm_model_ids(&ids)?; + ids.sort(); + Ok(ids) +} + pub(crate) fn normalize_game_creator_app_config( mut config: GameCreatorAppConfig, ) -> Result { @@ -4086,6 +4287,17 @@ pub(crate) fn normalize_game_creator_app_config( lock_game_creator_app_config_to_official_route(&mut config); } config.agent_mode = normalize_game_creator_agent_mode(&config.agent_mode)?; + if config.llm.custom_enabled { + validate_custom_llm_connection(&config.llm)?; + config.llm.visible_models = normalize_custom_llm_model_ids(&config.llm.visible_models)?; + if config.llm.visible_models.is_empty() { + return Err("请至少勾选一个要显示的模型".to_string()); + } + if config.llm.api_kind != "openai_responses" { + return Err("自定义 LLM 需要支持 OpenAI Responses 协议".to_string()); + } + apply_custom_llm_model_selection(&mut config); + } config.llm.api_key = config.llm.api_key.trim().to_string(); config.llm.base_url = trim_config_string(&config.llm.base_url).ok_or_else(|| llm_base_url_config_error("llm"))?; @@ -4182,7 +4394,9 @@ pub(crate) fn normalize_game_creator_llm_patch_config( } pub(crate) fn is_empty_game_creator_llm_patch(patch: &GameCreatorLlmConfigFile) -> bool { - patch.api_key.is_none() + patch.custom_enabled.is_none() + && patch.visible_models.is_none() + && patch.api_key.is_none() && patch.base_url.is_none() && patch.model.is_none() && patch.api_kind.is_none() @@ -4342,6 +4556,188 @@ mod private_file_write_tests { } } +#[cfg(test)] +mod custom_llm_tests { + use super::*; + use std::io::{Read, Write}; + + fn custom_llm() -> GameCreatorLlmConfig { + GameCreatorLlmConfig { + custom_enabled: true, + api_key: "custom-fixture-key".into(), + base_url: "https://provider.example/v1".into(), + model: "vendor/model.v1:latest".into(), + visible_models: vec!["vendor/model.v1:latest".into(), "second.model".into()], + ..GameCreatorLlmConfig::default() + } + } + + #[test] + fn custom_llm_defaults_closed_and_explicit_config_survives_scrub() { + assert!(!GameCreatorLlmConfig::default().custom_enabled); + let mut file: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({ + "llm": {"customEnabled": true, "apiKey": "fixture", "baseUrl": "https://custom.example/v1", "visibleModels": ["vendor/a.v1"]} + })).unwrap(); + assert!(!scrub_locked_game_creator_config_file(&mut file)); + let mut config = GameCreatorAppConfig::default(); + merge_game_creator_llm_config(&mut config.llm, file.llm.unwrap()); + assert!(config.llm.custom_enabled); + assert_eq!(config.llm.api_key, "fixture"); + assert_eq!(config.llm.visible_models, ["vendor/a.v1"]); + } + + #[test] + fn custom_llm_selection_is_allowlisted_and_removed_model_falls_back() { + let mut config = GameCreatorAppConfig { + llm: custom_llm(), + selected_model_id: "second.model".into(), + ..GameCreatorAppConfig::default() + }; + apply_custom_llm_model_selection(&mut config); + assert_eq!(config.llm.model, "second.model"); + config.llm.visible_models.pop(); + let normalized = normalize_game_creator_app_config(config).unwrap(); + assert_eq!(normalized.llm.model, "vendor/model.v1:latest"); + assert_eq!(normalized.selected_model_id, normalized.llm.model); + assert!(normalized.selected_model_is_default); + assert!(game_creator_codex_app_server_llm_route_error( + "codex_app_server", + &normalized.llm, + "llm" + ) + .is_none()); + } + + #[test] + fn custom_llm_missing_connection_or_models_is_rejected_without_official_fallback() { + for field in ["key", "models", "url"] { + let mut config = GameCreatorAppConfig { + llm: custom_llm(), + ..GameCreatorAppConfig::default() + }; + match field { + "key" => config.llm.api_key.clear(), + "models" => config.llm.visible_models.clear(), + _ => config.llm.base_url = "file:///private".into(), + } + assert!( + normalize_game_creator_app_config(config).is_err(), + "{field}" + ); + } + let mut config = custom_llm(); + config.api_key.clear(); + assert!(build_game_creator_platform_llm_config(&config, "llm").is_err()); + } + + #[test] + fn custom_llm_migration_uses_merged_overlay_switch() { + let root = tempfile::tempdir().unwrap(); + let primary = root.path().join(GAME_CREATOR_CONFIG_FILE_NAME); + let overlay = root.path().join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME); + std::fs::write( + &primary, + r#"{"llm":{"customEnabled":false,"apiKey":"fixture"}}"#, + ) + .unwrap(); + std::fs::write(&overlay, r#"{"llm":{"customEnabled":true}}"#).unwrap(); + assert!(custom_llm_enabled_at_config_path(&primary).unwrap()); + std::fs::write(&overlay, r#"{"llm":{"customEnabled":false}}"#).unwrap(); + assert!(!custom_llm_enabled_at_config_path(&primary).unwrap()); + } + + fn model_server(status: &str, body: &str) -> (String, std::thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/v1", listener.local_addr().unwrap()); + let response = format!("HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); + let handle = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + let mut buf = [0; 1024]; + while !request.windows(4).any(|part| part == b"\r\n\r\n") { + let count = socket.read(&mut buf).unwrap(); + if count == 0 { + break; + } + request.extend_from_slice(&buf[..count]); + } + let _ = socket.write_all(response.as_bytes()); + String::from_utf8(request).unwrap() + }); + (url, handle) + } + + #[tokio::test] + async fn custom_llm_discovers_models_directly_with_custom_bearer_and_deduplicates() { + let (url, server) = model_server( + "200 OK", + r#"{"data":[{"id":"vendor/model.v1:latest"},{"id":"second.model"},{"id":"second.model"}]}"#, + ); + let mut llm = custom_llm(); + llm.base_url = url; + assert_eq!( + fetch_custom_llm_models(&llm).await.unwrap(), + ["second.model", "vendor/model.v1:latest"] + ); + let request = server.join().unwrap(); + assert!(request.starts_with("GET /v1/models HTTP/1.1")); + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer custom-fixture-key")); + assert!(!request.contains("/api/llm")); + } + + #[tokio::test] + async fn custom_llm_discovery_reports_safe_errors_and_bounds_response() { + for (status, body, expected) in [ + ( + "401 Unauthorized", + "private-upstream-secret".to_string(), + "HTTP 401", + ), + ( + "302 Found", + "private-upstream-secret".to_string(), + "HTTP 302", + ), + ("200 OK", "not-json-private-secret".to_string(), "data[].id"), + ("200 OK", "x".repeat(1024 * 1024 + 1), "1 MiB"), + ] { + let (url, server) = model_server(status, &body); + let mut llm = custom_llm(); + llm.base_url = url; + let error = fetch_custom_llm_models(&llm).await.unwrap_err(); + assert!(error.contains(expected), "{error}"); + assert!(!error.contains("secret")); + server.join().unwrap(); + } + } + + #[tokio::test] + async fn custom_llm_discovery_times_out_when_response_body_stalls() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let mut llm = custom_llm(); + llm.base_url = format!("http://{}/v1", listener.local_addr().unwrap()); + let server = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + let mut request = [0; 4096]; + socket.read(&mut request).unwrap(); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n") + .unwrap(); + std::thread::sleep(std::time::Duration::from_secs(16)); + }); + let start = std::time::Instant::now(); + let error = fetch_custom_llm_models(&llm).await.unwrap_err(); + assert!(error.contains("超时"), "{error}"); + assert!(start.elapsed() < std::time::Duration::from_secs(16)); + server.join().unwrap(); + } +} + #[cfg(test)] mod private_path_elevation_policy_tests { use super::*; 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 bec55a981..ea8d647e8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1084,6 +1084,10 @@ struct GameCreatorAppConfigFile { #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorLlmConfigFile { + #[serde(skip_serializing_if = "Option::is_none")] + custom_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + visible_models: Option>, #[serde(skip_serializing_if = "Option::is_none")] api_key: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -1139,6 +1143,10 @@ struct GameCreatorAppConfig { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorLlmConfig { + #[serde(default)] + custom_enabled: bool, + #[serde(default)] + visible_models: Vec, api_key: String, base_url: String, model: String, @@ -1655,6 +1663,8 @@ impl Default for GameCreatorAppConfig { impl Default for GameCreatorLlmConfig { fn default() -> Self { Self { + custom_enabled: false, + visible_models: Vec::new(), api_key: String::new(), base_url: DEFAULT_GAME_CREATOR_LLM_BASE_URL.to_string(), model: DEFAULT_GAME_CREATOR_LLM_MODEL.to_string(), @@ -2209,6 +2219,8 @@ fn game_creator_gui_run_event_requests_runner_shutdown(event: &tauri::RunEvent) enum GameCreatorGuiRunnerShutdownOutcome { NotRequested, Requested, + /// 仍有其它界面窗口持有参与锁,Runner 必须保留给它们。 + Retained, Failed(GameCreatorGuiRunnerShutdownFailure), } @@ -2246,7 +2258,8 @@ fn classify_game_creator_gui_runner_shutdown_error( GameCreatorGuiRunnerShutdownFailure::ProcessIdentity } else if error.contains("当前平台不支持") || error.contains("macOS 不提供") { GameCreatorGuiRunnerShutdownFailure::PlatformUnsupported - } else if error.contains("实例锁") || error.contains("owner 锁") { + } else if error.contains("实例锁") || error.contains("参与锁") || error.contains("owner 锁") + { GameCreatorGuiRunnerShutdownFailure::LockTimeout } else if error.contains("endpoint") { GameCreatorGuiRunnerShutdownFailure::EndpointUnavailable @@ -2262,13 +2275,14 @@ fn resolve_game_creator_gui_runner_shutdown( shutdown: F, ) -> GameCreatorGuiRunnerShutdownOutcome where - F: FnOnce() -> Result<(), String>, + F: FnOnce() -> Result, { if !game_creator_gui_run_event_requests_runner_shutdown(event) { return GameCreatorGuiRunnerShutdownOutcome::NotRequested; } match shutdown() { - Ok(()) => GameCreatorGuiRunnerShutdownOutcome::Requested, + Ok(true) => GameCreatorGuiRunnerShutdownOutcome::Requested, + Ok(false) => GameCreatorGuiRunnerShutdownOutcome::Retained, Err(error) => GameCreatorGuiRunnerShutdownOutcome::Failed( classify_game_creator_gui_runner_shutdown_error(&error), ), @@ -2288,11 +2302,17 @@ fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) { app_log!("agent.direct_codex.gui_exit.shutdown_failed: {error}"); } } - match resolve_game_creator_gui_runner_shutdown(event, shutdown_external_agent_runner) { + match resolve_game_creator_gui_runner_shutdown( + event, + shutdown_external_agent_runner_for_gui_exit, + ) { GameCreatorGuiRunnerShutdownOutcome::NotRequested => {} GameCreatorGuiRunnerShutdownOutcome::Requested => { app_log!("agent.runner.gui_exit.shutdown_requested") } + GameCreatorGuiRunnerShutdownOutcome::Retained => { + app_log!("agent.runner.gui_exit.retained_for_other_windows") + } GameCreatorGuiRunnerShutdownOutcome::Failed(failure) => { app_log!("agent.runner.gui_exit.shutdown_failed.{}", failure.code()) } @@ -2601,27 +2621,25 @@ fn main() { ) })?; setup_log.append("startup.runner.configure.complete"); - let gui_owner_lock = acquire_external_agent_runner_gui_owner_lock(&config_dir) + hold_external_agent_runner_gui_participant_lock(&config_dir) .inspect_err(|error| { let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); setup_log.fail(&format!( - "startup.runner.owner-lock.failed details={details}" + "startup.runner.participant-lock.failed details={details}" )); }) .map_err(|error| { std::io::Error::new( std::io::ErrorKind::AlreadyExists, - format!("获取 GUI owner 锁失败:{error}"), + format!("建立 AGC 界面参与锁失败:{error}"), ) })?; - let gui_owner_epoch = gui_owner_lock.owner_epoch().to_string(); - app.manage(gui_owner_lock); setup_log.append("startup.runner.start.begin"); set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); set_direct_thread_manager_app_handle(app.handle().clone()); let manifest_event_sink = start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?; - attach_external_agent_runner_gui_owner(&manifest_event_sink, &gui_owner_epoch) + attach_external_agent_runner_gui_owner(&manifest_event_sink) .inspect_err(|error| { let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); setup_log.fail(&format!( @@ -2721,6 +2739,7 @@ fn main() { read_game_creator_app_config, write_game_creator_app_config, select_game_creator_model, + discover_game_creator_llm_models, upload_local_asset, register_local_asset, create_ui_design_resource, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index be22c4d96..71fd5bf4c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -12,21 +12,20 @@ pub(crate) use client::{ clear_external_agent_runner_platform_session, compact_external_agent_runner_context, configure_external_agent_runner, configure_external_agent_runner_read_only, continue_external_agent_runner_action, ensure_external_agent_runner_started, - ensure_external_agent_runner_started_for_gui, install_external_agent_runner_platform_session, + ensure_external_agent_runner_started_for_gui, hold_external_agent_runner_gui_participant_lock, + install_external_agent_runner_platform_session, interrupt_external_agent_runner_provider_for_steer_decision, notify_external_agent_runner, pause_external_agent_runner, read_external_agent_runner_status, require_external_agent_runner_configured_for_cli_runtime_write, require_external_agent_runner_for_cli_runtime_write, resume_external_agent_runner, shutdown_external_agent_runner, shutdown_external_agent_runner_for_client_exit, - shutdown_external_agent_runner_if_idle, steer_external_agent_runner, - wake_external_agent_runner_pending, wake_external_agent_runner_pending_for_run, + shutdown_external_agent_runner_for_gui_exit, shutdown_external_agent_runner_if_idle, + steer_external_agent_runner, wake_external_agent_runner_pending, + wake_external_agent_runner_pending_for_run, }; #[cfg(windows)] pub(crate) use endpoint::validate_windows_regular_file_handle; -pub(crate) use endpoint::{ - acquire_external_agent_runner_gui_owner_lock, external_agent_runner_enabled, - external_agent_runner_is_server_process, -}; +pub(crate) use endpoint::{external_agent_runner_enabled, external_agent_runner_is_server_process}; #[allow(unused_imports)] pub(crate) use protocol::{ExternalAgentRunnerStatus, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION}; pub(crate) use server::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 6c6f9fdbf..12561a7f8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -25,35 +25,76 @@ pub(super) struct ExternalAgentRunnerGuiOwnerAttachmentState { struct ExternalAgentRunnerGuiOwnerRegistration { generation: u64, + claim_mode: ExternalAgentRunnerGuiOwnerClaimMode, config_dir: PathBuf, params: ExternalAgentRunnerRequestParams, attached_boot_id: Option, } +/// claim 解析模式。 +/// +/// `Adopt` 用于窗口启动:沿用现有 durable claim,只有 claim 缺失或不可读时才发布。 +/// `Publish` 用于本窗口改动了平台登录态:发布新 epoch,成为新的登录态权威。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum ExternalAgentRunnerGuiOwnerClaimMode { + Adopt, + Publish, +} + static EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE: OnceLock< Mutex, > = OnceLock::new(); +static EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK: OnceLock< + Mutex>, +> = OnceLock::new(); + fn external_agent_runner_gui_owner_attachment_state( ) -> &'static Mutex { EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE .get_or_init(|| Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default())) } +fn external_agent_runner_gui_participant_lock( +) -> &'static Mutex> { + EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK.get_or_init(|| Mutex::new(None)) +} + +/// 取得并持有本窗口的界面参与锁,直到窗口退出。 +/// +/// 参与锁是共享句柄锁:同一 AppData 的多个窗口可以同时持有,Runner 用独占探测 +/// 判断是否仍有窗口存活,因此这个锁同时也是“Runner 不能先退出”的存活凭据。 +pub(crate) fn hold_external_agent_runner_gui_participant_lock( + config_dir: &Path, +) -> Result<(), String> { + let lock = acquire_external_agent_runner_gui_participant_lock(config_dir)?; + *lock_unpoisoned(external_agent_runner_gui_participant_lock()) = Some(lock); + Ok(()) +} + +fn release_external_agent_runner_gui_participant_lock() { + drop(lock_unpoisoned(external_agent_runner_gui_participant_lock()).take()); +} + +/// 登记本窗口的 owner claim 与 attach 参数。 +/// +/// 这里不做 claim 文件 IO:claim 由调用方按 `claim_mode` 解析后写进 `params`, +/// 因此该函数可以在没有真实 AppData 的单元测试里使用。 pub(super) fn register_external_agent_runner_gui_owner_attachment( state: &Mutex, config_dir: &Path, + claim_mode: ExternalAgentRunnerGuiOwnerClaimMode, mut params: ExternalAgentRunnerRequestParams, ) -> Result<(), String> { let mut state = lock_unpoisoned(state); state.generation = state.generation.wrapping_add(1); let generation = state.generation; - params.gui_owner_session_revision = Some(generation); - if let Some(owner_epoch) = params.gui_owner_epoch.as_deref() { - write_external_agent_runner_gui_owner_claim_atomic(config_dir, owner_epoch, generation)?; + if params.gui_owner_session_revision.is_none() { + params.gui_owner_session_revision = Some(generation); } state.registration = Some(ExternalAgentRunnerGuiOwnerRegistration { generation, + claim_mode, config_dir: config_dir.to_path_buf(), params, attached_boot_id: None, @@ -61,6 +102,29 @@ pub(super) fn register_external_agent_runner_gui_owner_attachment( Ok(()) } +pub(super) fn reserve_external_agent_runner_gui_owner_claim_revision( + state: &Mutex, +) -> u64 { + let mut state = lock_unpoisoned(state); + state.generation = state.generation.wrapping_add(1); + state.generation +} + +pub(super) fn resolve_external_agent_runner_gui_owner_claim( + config_dir: &Path, + claim_mode: ExternalAgentRunnerGuiOwnerClaimMode, + session_revision: u64, +) -> Result { + match claim_mode { + ExternalAgentRunnerGuiOwnerClaimMode::Adopt => { + adopt_or_publish_external_agent_runner_gui_owner_claim(config_dir, session_revision) + } + ExternalAgentRunnerGuiOwnerClaimMode::Publish => { + publish_external_agent_runner_gui_owner_claim(config_dir, session_revision) + } + } +} + pub(super) fn attach_registered_external_agent_runner_gui_owner_if_needed_with( state: &Mutex, config_dir: &Path, @@ -68,27 +132,72 @@ pub(super) fn attach_registered_external_agent_runner_gui_owner_if_needed_with Result<(), String> where - F: FnOnce(&ExternalAgentRunnerEndpoint, ExternalAgentRunnerRequestParams) -> Result<(), String>, + F: Fn(&ExternalAgentRunnerEndpoint, ExternalAgentRunnerRequestParams) -> Result<(), String>, { - let Some((generation, params)) = ({ - let state = lock_unpoisoned(state); - state.registration.as_ref().and_then(|registration| { - (registration.config_dir == config_dir - && registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str())) - .then(|| (registration.generation, registration.params.clone())) - }) - }) else { - return Ok(()); - }; + const ATTACH_CLAIM_RETRY_LIMIT: usize = 3; + let mut last_claim_error = None; + for attempt in 0..ATTACH_CLAIM_RETRY_LIMIT { + let Some((generation, params, claim_mode)) = ({ + let state = lock_unpoisoned(state); + state.registration.as_ref().and_then(|registration| { + (registration.config_dir == config_dir + && registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str())) + .then(|| { + ( + registration.generation, + registration.params.clone(), + registration.claim_mode, + ) + }) + }) + }) else { + return Ok(()); + }; - attach(endpoint, params)?; - - let mut state = lock_unpoisoned(state); - if let Some(registration) = state.registration.as_mut() { - if registration.generation == generation && registration.config_dir == config_dir { - registration.attached_boot_id = Some(endpoint.boot_id.clone()); + match attach(endpoint, params) { + Ok(()) => { + let mut state = lock_unpoisoned(state); + if let Some(registration) = state.registration.as_mut() { + if registration.generation == generation + && registration.config_dir == config_dir + { + registration.attached_boot_id = Some(endpoint.boot_id.clone()); + } + } + return Ok(()); + } + Err(error) if attempt + 1 < ATTACH_CLAIM_RETRY_LIMIT && error.contains("claim") => { + // 另一个窗口在本次 attach 前后发布了新 claim:按最新 claim 重新解析后重试。 + last_claim_error = Some(error); + refresh_registered_external_agent_runner_gui_owner_claim( + state, config_dir, claim_mode, + )?; + } + Err(error) => return Err(error), } } + Err(last_claim_error.unwrap_or_else(|| "Agent Runner attach 重试后仍然失败".to_string())) +} + +fn refresh_registered_external_agent_runner_gui_owner_claim( + state: &Mutex, + config_dir: &Path, + claim_mode: ExternalAgentRunnerGuiOwnerClaimMode, +) -> Result<(), String> { + let session_revision = reserve_external_agent_runner_gui_owner_claim_revision(state); + let claim = + resolve_external_agent_runner_gui_owner_claim(config_dir, claim_mode, session_revision)?; + let mut state = lock_unpoisoned(state); + let Some(registration) = state.registration.as_mut() else { + return Ok(()); + }; + if registration.config_dir != config_dir { + return Ok(()); + } + registration.claim_mode = claim_mode; + registration.params.gui_owner_epoch = Some(claim.owner_epoch); + registration.params.gui_owner_session_revision = Some(claim.session_revision); + registration.attached_boot_id = None; Ok(()) } @@ -963,7 +1072,6 @@ pub(crate) fn shutdown_external_agent_runner() -> Result<(), String> { pub(crate) fn attach_external_agent_runner_gui_owner( event_sink: &GameCreatorManifestInvalidationEventSink, - gui_owner_epoch: &str, ) -> Result<(), String> { EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT .store(true, std::sync::atomic::Ordering::Release); @@ -971,13 +1079,25 @@ pub(crate) fn attach_external_agent_runner_gui_owner( let config_dir = external_agent_runner_config_dir() .ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?; let platform_session = crate::current_platform_session(); + // 启动阶段先采纳现有 durable claim:第二个及后续窗口与第一个窗口共享同一 + // epoch,因此不会被判定为抢走登录态权威;claim 缺失或不可读时才发布新 claim。 + let session_revision = reserve_external_agent_runner_gui_owner_claim_revision( + external_agent_runner_gui_owner_attachment_state(), + ); + let claim = resolve_external_agent_runner_gui_owner_claim( + &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, + session_revision, + )?; register_external_agent_runner_gui_owner_attachment( external_agent_runner_gui_owner_attachment_state(), &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(event_sink.port), event_sink_token: Some(event_sink.token.clone()), - gui_owner_epoch: Some(gui_owner_epoch.to_string()), + gui_owner_epoch: Some(claim.owner_epoch), + gui_owner_session_revision: Some(claim.session_revision), platform_user_id: platform_session .as_ref() .map(|session| session.user_id.clone()), @@ -1107,7 +1227,7 @@ pub(super) fn remember_external_agent_runner_platform_session( state, session, generation, - write_external_agent_runner_gui_owner_claim_atomic, + publish_external_agent_runner_gui_owner_claim, ) } @@ -1115,7 +1235,7 @@ pub(super) fn remember_external_agent_runner_platform_session_with( state: &Mutex, session: Option<(&str, &str, &str)>, generation: u64, - write_claim: impl FnOnce(&Path, &str, u64) -> Result<(), String>, + publish_claim: impl FnOnce(&Path, u64) -> Result, ) -> Result<(), String> { let mut state = lock_unpoisoned(state); let Some(registration) = state.registration.as_ref() else { @@ -1147,21 +1267,23 @@ pub(super) fn remember_external_agent_runner_platform_session_with( } state.generation = state.generation.wrapping_add(1); let registration_generation = state.generation; - let claim = state.registration.as_ref().and_then(|registration| { - registration - .params - .gui_owner_epoch - .as_deref() - .map(|owner_epoch| (registration.config_dir.clone(), owner_epoch.to_string())) - }); - if let Some((config_dir, owner_epoch)) = claim { - write_claim(&config_dir, &owner_epoch, registration_generation)?; - } + // 本窗口改动了平台登录态:发布新 epoch 的 claim,成为新的登录态权威。 + // 并发发布以最后一次成功写入为准,落败窗口在 attach 阶段按最新 claim 重试。 + // 只有已经建立过 claim 的登记才需要发布:没有 epoch 的登记(纯 CLI / 单元测试替身) + // 不写任何 claim 文件。 + let published_claim = state + .registration + .as_ref() + .filter(|registration| registration.params.gui_owner_epoch.is_some()) + .map(|registration| registration.config_dir.clone()) + .map(|config_dir| publish_claim(&config_dir, registration_generation)) + .transpose()?; let registration = state .registration .as_mut() .expect("checked GUI owner registration must remain present while locked"); registration.generation = registration_generation; + registration.claim_mode = ExternalAgentRunnerGuiOwnerClaimMode::Publish; registration.attached_boot_id = None; registration.params.platform_user_id = session.map(|(user_id, _, _)| user_id.to_string()); registration.params.platform_access_token = @@ -1169,7 +1291,10 @@ pub(super) fn remember_external_agent_runner_platform_session_with( registration.params.platform_api_base_url = session.map(|(_, _, api_base_url)| api_base_url.to_string()); registration.params.platform_auth_generation = Some(generation); - registration.params.gui_owner_session_revision = Some(registration_generation); + if let Some(claim) = published_claim { + registration.params.gui_owner_epoch = Some(claim.owner_epoch); + registration.params.gui_owner_session_revision = Some(claim.session_revision); + } Ok(()) } @@ -1263,6 +1388,25 @@ pub(crate) fn shutdown_external_agent_runner_for_client_exit() -> Result Result { + let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); + let Some(config_dir) = external_agent_runner_config_dir() else { + return Ok(true); + }; + release_external_agent_runner_gui_participant_lock(); + if external_agent_runner_lock_is_held(&external_agent_runner_gui_participant_lock_path( + &config_dir, + ))? { + return Ok(false); + } + shutdown_external_agent_runner_at(&config_dir)?; + Ok(true) +} + pub(super) fn wait_for_external_agent_runner( config_dir: &Path, child: &mut Child, @@ -1301,34 +1445,12 @@ pub(super) fn ensure_external_agent_runner( ) -> Result { let endpoint_path = external_agent_runner_endpoint_path(config_dir); let executable_fingerprint = current_external_agent_runner_executable_fingerprint()?; - if let Ok(endpoint) = read_external_agent_runner_endpoint(&endpoint_path) { - match external_agent_runner_endpoint_reuse_decision(&endpoint, &executable_fingerprint) { - ExternalAgentRunnerReuseDecision::Reuse => { - if ping_external_agent_runner(&endpoint).is_ok() { - attach_registered_external_agent_runner_gui_owner_if_needed( - config_dir, &endpoint, - )?; - return Ok(endpoint); - } - } - ExternalAgentRunnerReuseDecision::Retire => { - let incompatible_ping = send_external_agent_runner_request_with_protocol_and_id( - &endpoint, - endpoint.protocol_version, - random_identifier(b"genarrative-agent-runner-upgrade-ping-id")?, - "runner.ping", - ExternalAgentRunnerRequestParams::default(), - ); - if incompatible_ping.is_ok() { - retire_incompatible_external_agent_runner( - &endpoint_path, - &endpoint, - EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT - .load(std::sync::atomic::Ordering::Acquire), - )?; - } - } - } + if let Some(endpoint) = reuse_or_retire_external_agent_runner_endpoint( + config_dir, + &endpoint_path, + &executable_fingerprint, + )? { + return Ok(endpoint); } let mut launched = launch_external_agent_runner(config_dir)?; match wait_for_external_agent_runner(config_dir, &mut launched.child, &executable_fingerprint) { @@ -1345,11 +1467,57 @@ pub(super) fn ensure_external_agent_runner( Err(error) => { let _ = launched.child.kill(); let _ = launched.child.wait(); + // 同一 AppData 的另一个窗口可能在这段时间里已经启动了 Runner: + // 实例锁竞争失败不能立刻报成启动失败,先按最新 endpoint 复用一次。 + if let Some(endpoint) = reuse_or_retire_external_agent_runner_endpoint( + config_dir, + &endpoint_path, + &executable_fingerprint, + )? { + return Ok(endpoint); + } Err(error) } } } +fn reuse_or_retire_external_agent_runner_endpoint( + config_dir: &Path, + endpoint_path: &Path, + executable_fingerprint: &str, +) -> Result, String> { + if let Ok(endpoint) = read_external_agent_runner_endpoint(&endpoint_path) { + match external_agent_runner_endpoint_reuse_decision(&endpoint, executable_fingerprint) { + ExternalAgentRunnerReuseDecision::Reuse => { + if ping_external_agent_runner(&endpoint).is_ok() { + attach_registered_external_agent_runner_gui_owner_if_needed( + config_dir, &endpoint, + )?; + return Ok(Some(endpoint)); + } + } + ExternalAgentRunnerReuseDecision::Retire => { + let incompatible_ping = send_external_agent_runner_request_with_protocol_and_id( + &endpoint, + endpoint.protocol_version, + random_identifier(b"genarrative-agent-runner-upgrade-ping-id")?, + "runner.ping", + ExternalAgentRunnerRequestParams::default(), + ); + if incompatible_ping.is_ok() { + retire_incompatible_external_agent_runner( + endpoint_path, + &endpoint, + EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT + .load(std::sync::atomic::Ordering::Acquire), + )?; + } + } + } + } + Ok(None) +} + pub(crate) fn configure_external_agent_runner(config_dir: impl AsRef) -> Result<(), String> { let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); let config_dir = normalize_external_agent_runner_config_dir(config_dir.as_ref())?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index b254c88f9..344d478e8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -1,6 +1,6 @@ use super::{endpoint::*, project_owner::*, protocol::*, state::*}; use crate::{ - install_game_creator_manifest_invalidation_event_sink, + register_game_creator_manifest_invalidation_event_sink, validate_game_creator_manifest_invalidation_event_sink, }; use serde::Deserialize; @@ -116,9 +116,9 @@ fn apply_external_agent_runner_gui_owner_attachment( .gui_owner_session_revision .ok_or_else(|| "Agent Runner GUI owner 缺少 session revision".to_string())?; let config_dir = state - .gui_owner_lock_path + .gui_participant_lock_path .parent() - .ok_or_else(|| "Agent Runner GUI owner 锁缺少 AppData 父目录".to_string())?; + .ok_or_else(|| "AGC 界面参与锁缺少 AppData 父目录".to_string())?; let mut active_claim = lock_unpoisoned(&state.gui_owner_platform_session_claim); let durable_claim = read_external_agent_runner_gui_owner_claim(config_dir)?; if durable_claim.owner_epoch != requested_epoch @@ -127,7 +127,14 @@ fn apply_external_agent_runner_gui_owner_attachment( return Err("Agent Runner GUI owner claim 已过期".to_string()); } let requested_claim = (requested_epoch.to_string(), requested_revision); - let replace_claim = active_claim.as_ref() != Some(&requested_claim); + // 同一 AppData 的多个窗口共享同一个 epoch:只有 epoch 变化(本窗口发布了新的 + // 登录态权威)才允许强制替换会话。同一 epoch 内的重复 attach 只做单调校验, + // 因此后开窗口的“无登录态 attach”不会清空已有会话。 + let epoch_changed = match active_claim.as_ref() { + Some(active) => active.0 != requested_epoch, + None => true, + }; + let replace_claim = epoch_changed; let result = match ( params.platform_user_id.as_deref(), params.platform_access_token.as_deref(), @@ -159,7 +166,7 @@ fn apply_external_agent_runner_gui_owner_attachment( crate::clear_platform_session_checked(generation) } } - (None, None, None, None) if replace_claim => { + (None, None, None, None) if epoch_changed => { crate::clear_platform_session_for_gui_owner(0); Ok(()) } @@ -185,7 +192,7 @@ fn apply_external_agent_runner_gui_owner_attachment( return Err("Agent Runner GUI owner claim 在 attach 提交期间已变化".to_string()); } if let Some(event_sink) = event_sink { - install_game_creator_manifest_invalidation_event_sink(event_sink); + register_game_creator_manifest_invalidation_event_sink(event_sink); } *active_claim = Some(requested_claim); Ok(()) @@ -195,9 +202,9 @@ pub(super) fn validate_external_agent_runner_gui_owner_claim_current( state: &ExternalAgentRunnerServerState, ) -> Result<(), String> { let config_dir = state - .gui_owner_lock_path + .gui_participant_lock_path .parent() - .ok_or_else(|| "Agent Runner GUI owner 锁缺少 AppData 父目录".to_string())?; + .ok_or_else(|| "AGC 界面参与锁缺少 AppData 父目录".to_string())?; let mut active_claim = lock_unpoisoned(&state.gui_owner_platform_session_claim); let durable_claim = read_external_agent_runner_gui_owner_claim(config_dir); let matches = durable_claim.as_ref().is_ok_and(|claim| { @@ -768,7 +775,7 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim( } } "runner.attach_gui_owner" => { - match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) { + match external_agent_runner_lock_is_held(&state.gui_participant_lock_path) { Ok(true) => { let event_sink = request .params @@ -810,7 +817,7 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim( Ok(false) => ExternalAgentRunnerResponse::failure( &request.request_id, "gui-owner-missing", - "Agent Runner 未检测到活跃 GUI owner 锁", + "Agent Runner 未检测到活跃的 AGC 界面进程", ), Err(error) => ExternalAgentRunnerResponse::failure( &request.request_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs index bfbd56241..de3ade0ae 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs @@ -6,7 +6,12 @@ use std::io::{self, Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering; use std::sync::Mutex; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(5); +const EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_RETRY_INTERVAL: Duration = + Duration::from_millis(40); pub(super) fn external_agent_runner_config_dir_lock() -> &'static Mutex> { EXTERNAL_AGENT_RUNNER_CONFIG_DIR.get_or_init(|| Mutex::new(None)) @@ -305,8 +310,8 @@ pub(super) fn external_agent_runner_lock_path(config_dir: &Path) -> PathBuf { config_dir.join(EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME) } -pub(super) fn external_agent_runner_gui_owner_lock_path(config_dir: &Path) -> PathBuf { - config_dir.join(EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME) +pub(super) fn external_agent_runner_gui_participant_lock_path(config_dir: &Path) -> PathBuf { + config_dir.join(EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_FILE_NAME) } pub(super) fn external_agent_runner_gui_owner_claim_path(config_dir: &Path) -> PathBuf { @@ -341,8 +346,9 @@ pub(super) fn read_external_agent_runner_gui_owner_claim( Ok(claim) } -pub(super) fn external_agent_runner_gui_owner_is_locked(path: &Path) -> Result { - match try_open_external_agent_runner_lock(path, "Agent Runner GUI owner 锁")? { +/// 独占探测:返回 `true` 表示仍有界面进程持有该参与锁。 +pub(super) fn external_agent_runner_lock_is_held(path: &Path) -> Result { + match try_open_external_agent_runner_lock(path, "AGC 界面参与锁")? { Some(lock) => { drop(lock); Ok(false) @@ -743,10 +749,21 @@ pub(super) fn read_current_external_agent_runner_endpoint( }) } +/// 锁文件的两种打开方式。 +/// +/// `Exclusive` 是权威探测:能否独占取得句柄决定“还有没有存活持有者”。 +/// `Shared` 是参与者持有:同一 AppData 的多个界面进程可以同时持有。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum ExternalAgentRunnerLockMode { + Exclusive, + Shared, +} + #[cfg(unix)] -pub(super) fn try_open_external_agent_runner_lock( +pub(super) fn open_external_agent_runner_lock_file( path: &Path, label: &str, + mode: ExternalAgentRunnerLockMode, ) -> Result, String> { use std::os::fd::AsRawFd; use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; @@ -809,14 +826,30 @@ pub(super) fn try_open_external_agent_runner_lock( path.display() )); } + let flock_operation = match mode { + ExternalAgentRunnerLockMode::Exclusive => libc::LOCK_EX | libc::LOCK_NB, + ExternalAgentRunnerLockMode::Shared => libc::LOCK_SH | libc::LOCK_NB, + }; // SAFETY: flock only observes the valid fd owned by `file`; `file` remains alive on success. - let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + let result = unsafe { libc::flock(file.as_raw_fd(), flock_operation) }; if result == 0 { return Ok(Some(file)); } let error = io::Error::last_os_error(); if error.kind() == io::ErrorKind::WouldBlock { - Ok(None) + return match mode { + ExternalAgentRunnerLockMode::Exclusive => Ok(None), + ExternalAgentRunnerLockMode::Shared => Err(format!( + "{label} 正被独占探测或持有,稍后重试:{}", + path.display() + )), + }; + } + if mode == ExternalAgentRunnerLockMode::Shared { + Err(format!( + "以共享方式获取 {label} 失败:{}: {error}", + path.display() + )) } else { Err(format!( "获取 {label} 系统锁失败:{}: {error}", @@ -825,39 +858,58 @@ pub(super) fn try_open_external_agent_runner_lock( } } +#[cfg(unix)] +pub(super) fn try_open_external_agent_runner_lock( + path: &Path, + label: &str, +) -> Result, String> { + open_external_agent_runner_lock_file(path, label, ExternalAgentRunnerLockMode::Exclusive) +} + #[cfg(windows)] pub(super) fn windows_external_agent_runner_lock_is_busy_error(error: &io::Error) -> bool { matches!(error.raw_os_error(), Some(32 | 33)) } #[cfg(windows)] -pub(super) fn try_open_external_agent_runner_lock( +pub(super) fn open_external_agent_runner_lock_file( path: &Path, label: &str, + mode: ExternalAgentRunnerLockMode, ) -> Result, String> { use std::os::windows::fs::OpenOptionsExt; const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_SHARE_DELETE: u32 = 0x0000_0004; let parent = path .parent() .ok_or_else(|| format!("{label} 缺少 AppData 父目录:{}", path.display()))?; let private_parent = crate::inspect_game_creator_runtime_config_dir(parent)?; let runner_lock_path = private_parent.join(EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME); - let gui_owner_lock_path = private_parent.join(EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME); - if path != runner_lock_path && path != gui_owner_lock_path { + let gui_participant_lock_path = + private_parent.join(EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_FILE_NAME); + if path != runner_lock_path && path != gui_participant_lock_path { return Err(format!( "{label} 必须位于已验证的私有 AppData 固定锁路径:{} 或 {}", runner_lock_path.display(), - gui_owner_lock_path.display() + gui_participant_lock_path.display() )); } + let share_mode = match mode { + ExternalAgentRunnerLockMode::Exclusive => 0, + ExternalAgentRunnerLockMode::Shared => { + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE + } + }; match OpenOptions::new() .create(true) .read(true) .write(true) - .share_mode(0) + .share_mode(share_mode) .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) .open(path) { @@ -875,16 +927,30 @@ pub(super) fn try_open_external_agent_runner_lock( )); } validate_windows_regular_file_handle(&file, label)?; - // share_mode(0) gives this process an exclusive handle. At this point the fixed - // lock path is known to be a stale, single-link, non-reparse regular file inside - // the current TokenUser's private AppData. Repairing its owner is therefore safe - // and is required when Windows creates it with TokenOwner=Administrators. + // The fixed lock path is known to be a single-link, non-reparse regular file + // inside the current TokenUser's private AppData. Repairing its owner is + // therefore safe and is required when Windows creates it with + // TokenOwner=Administrators. crate::initialize_windows_game_creator_file_owner_for_current_user(path)?; validate_windows_regular_file_handle(&file, label)?; crate::secure_windows_game_creator_path_for_current_user(path, false, false)?; Ok(Some(file)) } - Err(error) if windows_external_agent_runner_lock_is_busy_error(&error) => Ok(None), + Err(error) + if mode == ExternalAgentRunnerLockMode::Exclusive + && windows_external_agent_runner_lock_is_busy_error(&error) => + { + Ok(None) + } + Err(error) + if mode == ExternalAgentRunnerLockMode::Shared + && windows_external_agent_runner_lock_is_busy_error(&error) => + { + Err(format!( + "{label} 正被独占探测或持有,稍后重试:{}", + path.display() + )) + } Err(error) => Err(format!( "安全打开 {label} 失败:{}: {error}", path.display() @@ -892,12 +958,29 @@ pub(super) fn try_open_external_agent_runner_lock( } } +#[cfg(windows)] +pub(super) fn try_open_external_agent_runner_lock( + path: &Path, + label: &str, +) -> Result, String> { + open_external_agent_runner_lock_file(path, label, ExternalAgentRunnerLockMode::Exclusive) +} + +#[cfg(not(any(unix, windows)))] +pub(super) fn open_external_agent_runner_lock_file( + path: &Path, + label: &str, + _mode: ExternalAgentRunnerLockMode, +) -> Result, String> { + Err(format!("当前平台不支持 {label} 系统锁:{}", path.display())) +} + #[cfg(not(any(unix, windows)))] pub(super) fn try_open_external_agent_runner_lock( path: &Path, label: &str, ) -> Result, String> { - Err(format!("当前平台不支持 {label} 系统锁:{}", path.display())) + open_external_agent_runner_lock_file(path, label, ExternalAgentRunnerLockMode::Exclusive) } pub(super) fn acquire_external_agent_runner_instance_lock( @@ -929,40 +1012,90 @@ pub(super) fn acquire_external_agent_runner_instance_lock( Ok(ExternalAgentRunnerInstanceLock { _file: file }) } -pub(crate) fn acquire_external_agent_runner_gui_owner_lock( +/// 取得本窗口在该 AppData 下的界面参与锁。 +/// +/// 参与锁以共享句柄打开:同一 AppData 可以同时持有任意数量的界面窗口。 +/// Runner 侧用同文件的独占探测判断“是否仍有界面进程存活”,探测窗口很短, +/// 所以这里遇到瞬时冲突时按 `EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_RETRY_INTERVAL` +/// 重试,直到 `EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_ACQUIRE_TIMEOUT` 截止。 +pub(crate) fn acquire_external_agent_runner_gui_participant_lock( config_dir: &Path, -) -> Result { - let path = external_agent_runner_gui_owner_lock_path(config_dir); - let Some(mut file) = try_open_external_agent_runner_lock(&path, "Agent Runner GUI owner 锁")? - else { - return Err("AI 游戏创作界面已由同一 AppData 目录中的其他进程运行".to_string()); - }; - let owner_epoch = uuid::Uuid::new_v4().to_string(); - let acquired_at = unix_millis(); +) -> Result { + let path = external_agent_runner_gui_participant_lock_path(config_dir); + let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_ACQUIRE_TIMEOUT; + let mut last_error = "AGC 界面参与锁未知失败".to_string(); + loop { + match open_external_agent_runner_lock_file( + &path, + "AGC 界面参与锁", + ExternalAgentRunnerLockMode::Shared, + ) { + Ok(Some(mut file)) => { + write_external_agent_runner_gui_participant_diagnostic(&mut file, &path)?; + return Ok(ExternalAgentRunnerGuiParticipantLock { _file: file }); + } + Ok(None) => { + last_error = format!("AGC 界面参与锁无法以共享方式取得:{}", path.display()); + } + Err(error) => last_error = error, + } + if Instant::now() >= deadline { + return Err(format!("取得 AGC 界面参与锁失败:{last_error}")); + } + thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_RETRY_INTERVAL); + } +} + +/// 参与锁诊断内容只由首个窗口写入,后续窗口不覆写,避免并发写坏 JSON。 +fn write_external_agent_runner_gui_participant_diagnostic( + file: &mut File, + path: &Path, +) -> Result<(), String> { + let existing_len = file.metadata().map(|metadata| metadata.len()).unwrap_or(0); + if existing_len > 0 { + return Ok(()); + } let diagnostic = serde_json::to_vec(&json!({ "protocolVersion": EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, "pid": std::process::id(), - "ownerEpoch": owner_epoch, - "acquiredAt": acquired_at, + "instanceId": uuid::Uuid::new_v4().to_string(), + "acquiredAt": unix_millis(), })) - .map_err(|error| format!("生成 Agent Runner GUI owner 锁信息失败:{error}"))?; + .map_err(|error| format!("生成 AGC 界面参与锁信息失败:{error}"))?; file.set_len(0) .and_then(|_| file.seek(SeekFrom::Start(0)).map(|_| ())) .and_then(|_| file.write_all(&diagnostic)) .and_then(|_| file.sync_data()) - .map_err(|error| { - format!( - "写入 Agent Runner GUI owner 锁信息失败:{}: {error}", - path.display() - ) - })?; - write_external_agent_runner_gui_owner_claim_atomic(config_dir, &owner_epoch, 0)?; - Ok(ExternalAgentRunnerGuiOwnerLock { - _file: file, + .map_err(|error| format!("写入 AGC 界面参与锁信息失败:{}: {error}", path.display())) +} + +/// 发布新的 durable claim:新 epoch + 本次会话 revision。 +/// +/// 发布是“谁改动登录态谁成为新 epoch 权威”的实现;并发发布以最后一次 +/// 成功写入为准,落败窗口按最新 claim 重试。 +pub(crate) fn publish_external_agent_runner_gui_owner_claim( + config_dir: &Path, + session_revision: u64, +) -> Result { + let owner_epoch = uuid::Uuid::new_v4().to_string(); + write_external_agent_runner_gui_owner_claim_atomic(config_dir, &owner_epoch, session_revision)?; + Ok(ExternalAgentRunnerGuiOwnerClaim { owner_epoch, + session_revision, }) } +/// 采纳现有 durable claim;只有 claim 缺失或不可读时才发布新 claim。 +pub(crate) fn adopt_or_publish_external_agent_runner_gui_owner_claim( + config_dir: &Path, + session_revision: u64, +) -> Result { + match read_external_agent_runner_gui_owner_claim(config_dir) { + Ok(claim) => Ok(claim), + Err(_) => publish_external_agent_runner_gui_owner_claim(config_dir, session_revision), + } +} + pub(super) fn write_external_agent_runner_gui_owner_claim_atomic( config_dir: &Path, owner_epoch: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs index 4f0e3d555..0728e9050 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs @@ -13,8 +13,8 @@ pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 7; pub(super) const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json"; pub(super) const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock"; -pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME: &str = - "agent-runner.gui-owner.lock"; +pub(super) const EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_FILE_NAME: &str = + "agent-runner.gui-participant.lock"; pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_CLAIM_FILE_NAME: &str = "agent-runner.gui-owner.claim.json"; pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_FILE_NAME: &str = "execution-owner.lock"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs index cde54a691..998ca0e9f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs @@ -156,7 +156,7 @@ fn external_agent_runner_watchdog_tick(state: &ExternalAgentRunnerServerState) - if !state.gui_owner_attached.load(Ordering::Acquire) { return false; } - match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) { + match external_agent_runner_lock_is_held(&state.gui_participant_lock_path) { Ok(true) => { let _ = validate_external_agent_runner_gui_owner_claim_current(state); false @@ -224,7 +224,7 @@ pub(crate) fn run_external_agent_runner_server( )?; let gui_owner_present_at_start = resolve_external_agent_runner_initial_gui_owner( gui_owner_required, - external_agent_runner_gui_owner_is_locked(&external_agent_runner_gui_owner_lock_path( + external_agent_runner_lock_is_held(&external_agent_runner_gui_participant_lock_path( &config_dir, ))?, )?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs index 3070039b6..7dda8ab43 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs @@ -16,7 +16,7 @@ pub(super) struct ExternalAgentRunnerServerState { pub(super) draining: AtomicBool, pub(super) active_connections: AtomicUsize, pub(super) known_roots: Mutex>, - pub(super) gui_owner_lock_path: PathBuf, + pub(super) gui_participant_lock_path: PathBuf, pub(super) project_execution_owners: Mutex>, project_execution_owner_recovery_changed: Condvar, @@ -80,10 +80,10 @@ impl Drop for ExternalAgentRunnerProjectExecutionOwnerRecoveryGuard<'_> { impl ExternalAgentRunnerServerState { pub(super) fn new(endpoint_path: PathBuf, endpoint: ExternalAgentRunnerEndpoint) -> Self { - let gui_owner_lock_path = endpoint_path + let gui_participant_lock_path = endpoint_path .parent() - .map(external_agent_runner_gui_owner_lock_path) - .unwrap_or_else(|| PathBuf::from(EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME)); + .map(external_agent_runner_gui_participant_lock_path) + .unwrap_or_else(|| PathBuf::from(EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_FILE_NAME)); Self { endpoint_path, endpoint: Mutex::new(endpoint), @@ -94,7 +94,7 @@ impl ExternalAgentRunnerServerState { draining: AtomicBool::new(false), active_connections: AtomicUsize::new(0), known_roots: Mutex::new(BTreeSet::new()), - gui_owner_lock_path, + gui_participant_lock_path, project_execution_owners: Mutex::new(BTreeMap::new()), project_execution_owner_recovery_changed: Condvar::new(), write_request_cache: Mutex::new(ExternalAgentRunnerRequestCache::default()), @@ -231,16 +231,10 @@ pub(super) struct ExternalAgentRunnerInstanceLock { pub(super) _file: File, } +/// 界面进程持有的参与锁。共享句柄,同一 AppData 可同时存在多个窗口。 #[derive(Debug)] -pub(crate) struct ExternalAgentRunnerGuiOwnerLock { +pub(crate) struct ExternalAgentRunnerGuiParticipantLock { pub(super) _file: File, - pub(super) owner_epoch: String, -} - -impl ExternalAgentRunnerGuiOwnerLock { - pub(crate) fn owner_epoch(&self) -> &str { - &self.owner_epoch - } } pub(super) struct ExternalAgentRunnerProjectOwnerStorage { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 20a6dfc0d..8e9bcb08b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -44,6 +44,23 @@ fn private_runner_test_config_dir(directory: &TestDirectoryGuard) -> PathBuf { .expect("prepare private runner AppData") } +/// 模拟一个界面窗口:持有界面参与锁,并发布自己的 owner claim。 +struct TestGuiParticipant { + _lock: ExternalAgentRunnerGuiParticipantLock, + owner_epoch: String, +} + +fn acquire_test_gui_participant(config_dir: &Path, session_revision: u64) -> TestGuiParticipant { + let lock = acquire_external_agent_runner_gui_participant_lock(config_dir) + .expect("acquire GUI participant lock"); + let claim = publish_external_agent_runner_gui_owner_claim(config_dir, session_revision) + .expect("publish GUI owner claim"); + TestGuiParticipant { + _lock: lock, + owner_epoch: claim.owner_epoch, + } +} + fn acquire_project_owner_after_release( root: &Path, boot_id: &str, @@ -574,8 +591,13 @@ fn gui_owner_registration_replays_once_for_each_runner_boot() { event_sink_token: Some(event_sink_token.clone()), ..ExternalAgentRunnerRequestParams::default() }; - register_external_agent_runner_gui_owner_attachment(&state, &config_dir, params) - .expect("register GUI owner attachment"); + register_external_agent_runner_gui_owner_attachment( + &state, + &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, + params, + ) + .expect("register GUI owner attachment"); let calls = std::cell::RefCell::new(Vec::new()); let endpoint_a = test_endpoint( @@ -657,6 +679,7 @@ fn gui_owner_registration_replays_only_the_latest_platform_session() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(31_330), event_sink_token: Some("f".repeat(64)), @@ -725,6 +748,7 @@ fn gui_owner_platform_session_change_marks_the_same_boot_for_reattach() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(31_331), event_sink_token: Some("d".repeat(64)), @@ -776,6 +800,7 @@ fn stale_gui_owner_attach_completion_cannot_mark_new_session_as_attached() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { platform_user_id: Some("user-a".to_string()), platform_access_token: Some("token-a".to_string()), @@ -827,8 +852,7 @@ fn stale_gui_owner_attach_completion_cannot_mark_new_session_as_attached() { fn gui_owner_platform_session_payload_clears_runner_session() { let directory = unique_test_directory(); let config_dir = private_runner_test_config_dir(&directory); - let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire platform-session clear owner"); + let owner = acquire_test_gui_participant(&config_dir, 0); let state = ExternalAgentRunnerServerState::new( config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), test_endpoint("platform-clear-token", "platform-clear-boot", 31_333), @@ -841,7 +865,7 @@ fn gui_owner_platform_session_payload_clears_runner_session() { apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(0), platform_auth_generation: Some(2), ..ExternalAgentRunnerRequestParams::default() @@ -855,8 +879,7 @@ fn gui_owner_platform_session_payload_clears_runner_session() { fn gui_owner_partial_platform_session_payload_fails_without_mutation() { let directory = unique_test_directory(); let config_dir = private_runner_test_config_dir(&directory); - let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire partial-session owner"); + let owner = acquire_test_gui_participant(&config_dir, 0); let state = ExternalAgentRunnerServerState::new( config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), test_endpoint("platform-partial-token", "platform-partial-boot", 31_334), @@ -870,7 +893,7 @@ fn gui_owner_partial_platform_session_payload_fails_without_mutation() { let error = apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(0), platform_user_id: Some("runner-owner-b".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), @@ -896,9 +919,8 @@ fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old "runner-token-seed", "https://dev.genarrative.world", ); - let owner_a = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire old GUI owner epoch"); - let owner_a_epoch = owner_a.owner_epoch().to_string(); + let owner_a = acquire_test_gui_participant(&config_dir, 0); + let owner_a_epoch = owner_a.owner_epoch.clone(); apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { @@ -914,12 +936,11 @@ fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old .expect("old GUI installs high-generation owner A"); drop(owner_a); - let owner_b = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire new GUI owner epoch"); + let owner_b = acquire_test_gui_participant(&config_dir, 0); apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner_b.owner_epoch().to_string()), + gui_owner_epoch: Some(owner_b.owner_epoch.clone()), gui_owner_session_revision: Some(0), platform_user_id: Some("runner-owner-b".to_string()), platform_access_token: Some("runner-token-b".to_string()), @@ -963,8 +984,7 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_ config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), test_endpoint(token, "platform-claim-gate-boot", 31_337), ); - let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire claim gate owner"); + let owner = acquire_test_gui_participant(&config_dir, 0); let _session = crate::install_test_platform_session( "runner-owner-seed", "runner-token-seed", @@ -973,7 +993,7 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_ apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(0), platform_user_id: Some("runner-owner-a".to_string()), platform_access_token: Some("runner-token-a".to_string()), @@ -985,7 +1005,7 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_ .expect("attach owner A claim"); state.gui_owner_attached.store(true, Ordering::Release); - write_external_agent_runner_gui_owner_claim_atomic(&config_dir, owner.owner_epoch(), 1) + write_external_agent_runner_gui_owner_claim_atomic(&config_dir, owner.owner_epoch.as_str(), 1) .expect("advance durable claim before reattach"); assert!( !external_agent_runner_shutdown_if_gui_owner_lost(&state) @@ -996,7 +1016,7 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_ apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(1), platform_user_id: Some("runner-owner-b".to_string()), platform_access_token: Some("runner-token-b".to_string()), @@ -1041,14 +1061,14 @@ fn failed_platform_session_sync_fences_runner_before_returning_error() { fn failed_gui_owner_claim_write_is_fenced_before_local_session_can_change() { let directory = unique_test_directory(); let config_dir = private_runner_test_config_dir(&directory); - let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire claim-write failure owner"); + let owner = acquire_test_gui_participant(&config_dir, 0); let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), platform_user_id: Some("runner-owner-a".to_string()), platform_access_token: Some("runner-token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), @@ -1069,7 +1089,7 @@ fn failed_gui_owner_claim_write_is_fenced_before_local_session_can_change() { "https://dev.genarrative.world", )), 2, - |_, _, _| Err("injected durable claim write failure".to_string()), + |_, _| Err("injected durable claim write failure".to_string()), ) }, || { @@ -1118,6 +1138,7 @@ fn gui_owner_registration_failed_replay_remains_pending_for_same_boot() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams::default(), ) .expect("register GUI owner attachment"); @@ -1166,6 +1187,7 @@ fn gui_owner_registration_missing_event_sink_confirmation_retries_same_boot() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(31_322), event_sink_token: Some("c".repeat(64)), @@ -1215,6 +1237,7 @@ fn gui_owner_registration_false_event_sink_confirmation_retries_same_boot() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(31_323), event_sink_token: Some("d".repeat(64)), @@ -1267,6 +1290,7 @@ fn gui_owner_registration_does_not_cross_config_dirs() { register_external_agent_runner_gui_owner_attachment( &state, ®istered_config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(31_324), event_sink_token: Some(event_sink_token.clone()), @@ -1316,18 +1340,113 @@ fn gui_owner_registration_does_not_cross_config_dirs() { } #[test] -fn gui_owner_lock_allows_only_one_frontend_process_per_appdata() { +fn gui_participant_lock_allows_multiple_windows_and_tracks_liveness() { let directory = unique_test_directory(); let config_dir = private_runner_test_config_dir(&directory); - let first = - acquire_external_agent_runner_gui_owner_lock(&config_dir).expect("first GUI owns AppData"); - let error = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect_err("second GUI must not share the same Runner owner"); - assert!(error.contains("其他进程运行")); + let participant_lock_path = external_agent_runner_gui_participant_lock_path(&config_dir); + assert!(!external_agent_runner_lock_is_held(&participant_lock_path) + .expect("probe without any window")); + let first = acquire_external_agent_runner_gui_participant_lock(&config_dir) + .expect("first window participates"); + assert!(external_agent_runner_lock_is_held(&participant_lock_path) + .expect("first window keeps the runner alive")); + let second = acquire_external_agent_runner_gui_participant_lock(&config_dir) + .expect("second window shares the same AppData"); + + drop(second); + assert!( + external_agent_runner_lock_is_held(&participant_lock_path) + .expect("remaining window keeps the runner alive"), + "runner must survive while any window is still open" + ); drop(first); - acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("GUI owner lock is recoverable after the first frontend exits"); + assert!( + !external_agent_runner_lock_is_held(&participant_lock_path) + .expect("last window releases the participant lock"), + "runner may stop once every window has exited" + ); +} + +#[test] +fn gui_owner_claim_adoption_keeps_epoch_and_publication_rotates_it() { + let directory = unique_test_directory(); + let config_dir = private_runner_test_config_dir(&directory); + let published = + publish_external_agent_runner_gui_owner_claim(&config_dir, 3).expect("publish claim"); + assert_eq!(published.session_revision, 3); + + let adopted = adopt_or_publish_external_agent_runner_gui_owner_claim(&config_dir, 9) + .expect("adopt existing claim"); + assert_eq!(adopted.owner_epoch, published.owner_epoch); + assert_eq!( + adopted.session_revision, 3, + "采纳路径必须沿用现有 claim,不能推进 revision 或换 epoch" + ); + + let rotated = + publish_external_agent_runner_gui_owner_claim(&config_dir, 9).expect("publish new claim"); + assert_ne!(rotated.owner_epoch, published.owner_epoch); + assert_eq!(rotated.session_revision, 9); + assert_eq!( + read_external_agent_runner_gui_owner_claim(&config_dir) + .expect("read durable claim") + .session_revision, + 9 + ); +} + +#[test] +fn second_window_attach_with_same_claim_keeps_runner_platform_session() { + let directory = unique_test_directory(); + let config_dir = private_runner_test_config_dir(&directory); + let state = ExternalAgentRunnerServerState::new( + config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint( + "multi-window-claim-token-multi-window-claim-token", + "multi-window-claim-boot", + 31_338, + ), + ); + let _session = crate::install_test_platform_session( + "runner-owner-a", + "runner-token-a", + "https://dev.genarrative.world", + ); + let owner = acquire_test_gui_participant(&config_dir, 0); + apply_external_agent_runner_gui_owner_platform_session( + &state, + &ExternalAgentRunnerRequestParams { + gui_owner_epoch: Some(owner.owner_epoch.clone()), + gui_owner_session_revision: Some(0), + platform_user_id: Some("runner-owner-a".to_string()), + platform_access_token: Some("runner-token-a".to_string()), + platform_api_base_url: Some("https://dev.genarrative.world".to_string()), + platform_auth_generation: Some(7), + ..ExternalAgentRunnerRequestParams::default() + }, + ) + .expect("first window installs its session"); + assert_eq!( + crate::current_platform_session().map(|session| (session.user_id, session.generation)), + Some(("runner-owner-a".to_string(), 7)) + ); + + // 第二个窗口启动时本身还没有登录态:同 claim 的 attach 只能是空操作。 + apply_external_agent_runner_gui_owner_platform_session( + &state, + &ExternalAgentRunnerRequestParams { + gui_owner_epoch: Some(owner.owner_epoch.clone()), + gui_owner_session_revision: Some(0), + ..ExternalAgentRunnerRequestParams::default() + }, + ) + .expect("second window attaches with the same claim"); + assert_eq!( + crate::current_platform_session().map(|session| (session.user_id, session.generation)), + Some(("runner-owner-a".to_string(), 7)), + "同一 claim 的第二个窗口不得清空平台登录态" + ); } #[test] @@ -1341,8 +1460,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), test_endpoint(token, "gui-owner-monitor-boot", 31319), ); - let owner = - acquire_external_agent_runner_gui_owner_lock(&config_dir).expect("acquire GUI owner lock"); + let owner = acquire_test_gui_participant(&config_dir, 0); let attached = handle_external_agent_runner_request( ExternalAgentRunnerRequest { protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, @@ -1352,7 +1470,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u params: ExternalAgentRunnerRequestParams { event_sink_port: Some(31_318), event_sink_token: Some("b".repeat(64)), - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(0), ..ExternalAgentRunnerRequestParams::default() }, @@ -1372,7 +1490,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u !external_agent_runner_shutdown_if_gui_owner_lost(&state).expect("owner remains present") ); - write_external_agent_runner_gui_owner_claim_atomic(&config_dir, owner.owner_epoch(), 1) + write_external_agent_runner_gui_owner_claim_atomic(&config_dir, owner.owner_epoch.as_str(), 1) .expect("advance owner claim revision"); let replacement = handle_external_agent_runner_request( ExternalAgentRunnerRequest { @@ -1383,7 +1501,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u params: ExternalAgentRunnerRequestParams { event_sink_port: Some(31_319), event_sink_token: Some("c".repeat(64)), - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, @@ -1392,11 +1510,18 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u ); assert!(replacement.ok); assert_eq!( - sink_guard.configured_sink(), - Some(crate::GameCreatorManifestInvalidationEventSink { - port: 31_319, - token: "c".repeat(64), - }) + sink_guard.configured_sinks(), + vec![ + crate::GameCreatorManifestInvalidationEventSink { + port: 31_318, + token: "b".repeat(64), + }, + crate::GameCreatorManifestInvalidationEventSink { + port: 31_319, + token: "c".repeat(64), + }, + ], + "第二个窗口 attach 必须让两个接收端同时保留" ); let stale_replay = handle_external_agent_runner_request( @@ -1408,7 +1533,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u params: ExternalAgentRunnerRequestParams { event_sink_port: Some(31_318), event_sink_token: Some("b".repeat(64)), - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(0), ..ExternalAgentRunnerRequestParams::default() }, @@ -1421,11 +1546,17 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u Some("platform-session-invalid") ); assert_eq!( - sink_guard.configured_sink(), - Some(crate::GameCreatorManifestInvalidationEventSink { - port: 31_319, - token: "c".repeat(64), - }), + sink_guard.configured_sinks(), + vec![ + crate::GameCreatorManifestInvalidationEventSink { + port: 31_318, + token: "b".repeat(64), + }, + crate::GameCreatorManifestInvalidationEventSink { + port: 31_319, + token: "c".repeat(64), + }, + ], "旧 claim 的迟到或缓存 attach 不能覆盖当前事件接收端" ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index adb540bc2..9e049b60d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -1,5 +1,73 @@ use super::*; +#[test] +fn legacy_official_config_gains_hand_editable_connection_keys_on_startup() { + // 用户现有配置文件(官方路由、连接字段已被清掉)在启动迁移路径上必须补齐 + // 开关、模型列表与连接四要素,手写自定义连接时能看到完整字段。 + let mut config: GameCreatorAppConfigFile = serde_json::from_str( + r#"{"schemaVersion":"game-creator-config.v2","agentMode":"codex_app_server","llm":{"reasoningEffort":"max","stream":true},"selectedModelId":"quality","selectedModelIsDefault":true}"#, + ) + .unwrap(); + assert!(ensure_game_creator_custom_llm_file_fields(&mut config)); + assert!(scrub_locked_game_creator_config_file(&mut config)); + let migrated: serde_json::Value = serde_json::to_value(&config).unwrap(); + assert_eq!(migrated["llm"]["customEnabled"], false); + assert_eq!(migrated["llm"]["visibleModels"], serde_json::json!([])); + assert_eq!(migrated["llm"]["apiKey"], ""); + assert_eq!(migrated["llm"]["baseUrl"], OFFICIAL_LLM_ROUTER_BASE_URL); + assert_eq!(migrated["llm"]["model"], "quality"); + assert_eq!( + migrated["llm"]["apiKind"], + DEFAULT_GAME_CREATOR_LLM_API_KIND + ); + assert_eq!(migrated["llm"]["reasoningEffort"], "max"); +} + +#[test] +fn custom_llm_config_save_reload_and_selection_preserve_overlay_and_credentials() { + let root = unique_project_path(); + fs::create_dir_all(&root).unwrap(); + let _guard = use_test_runtime_config_dir(root.clone()); + let primary = root.join(GAME_CREATOR_CONFIG_FILE_NAME); + let overlay = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME); + write_game_creator_config_atomically( + &primary, + &serde_json::to_string(&GameCreatorAppConfig::default()).unwrap(), + ) + .unwrap(); + write_game_creator_config_atomically(&overlay, r#"{"llm":{"customEnabled":true,"apiKey":"fixture-key","baseUrl":"https://custom.example/v1","visibleModels":["a/v1","b:v2"]},"selectedModelId":"a/v1","selectedModelIsDefault":true}"#).unwrap(); + let mut config = read_game_creator_app_config().unwrap().config; + assert_eq!(config.llm.model, "a/v1"); + let selected = select_game_creator_model("b:v2".into(), false).unwrap(); + assert_eq!(selected.config.llm.model, "b:v2"); + assert!(select_game_creator_model("not-listed".into(), false).is_err()); + config.llm.visible_models = vec!["b:v2".into()]; + config.llm.api_key = "changed-fixture-key".into(); + write_game_creator_app_config(config).unwrap(); + let reloaded = read_game_creator_app_config().unwrap().config; + assert!(reloaded.llm.custom_enabled); + assert_eq!(reloaded.llm.visible_models, ["b:v2"]); + assert_eq!(reloaded.llm.model, "b:v2"); + assert_eq!(reloaded.llm.api_key, "changed-fixture-key"); + let persisted: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&primary).unwrap()).unwrap(); + for key in [ + "customEnabled", + "visibleModels", + "apiKey", + "baseUrl", + "model", + "apiKind", + "reasoningEffort", + ] { + assert!( + persisted["llm"].get(key).is_some(), + "本地配置始终保留 {key},方便手写自定义连接:{persisted}" + ); + } + fs::remove_dir_all(root).unwrap(); +} + #[test] fn config_file_overrides_defaults_without_env() { let root = unique_project_path(); @@ -313,10 +381,14 @@ fn locked_config_scrub_removes_all_legacy_provider_credentials() { .llm .as_ref() .expect("global llm remains as non-sensitive tuning"); - assert!(llm.api_key.is_none()); - assert!(llm.base_url.is_none()); - assert!(llm.model.is_none()); - assert!(llm.api_kind.is_none()); + // 连接字段保留在文件里(空 Key + 官方地址),便于手写自定义连接时对照。 + assert_eq!(llm.api_key.as_deref(), Some("")); + assert_eq!(llm.base_url.as_deref(), Some(OFFICIAL_LLM_ROUTER_BASE_URL)); + assert_eq!( + llm.api_kind.as_deref(), + Some(DEFAULT_GAME_CREATOR_LLM_API_KIND) + ); + assert!(llm.model.is_some()); let serialized = serde_json::to_string(&config).expect("serialize scrubbed config"); assert!(!serialized.contains("legacy-global-key")); assert!(!serialized.contains("legacy-agent-key")); @@ -688,6 +760,8 @@ fn app_config_commands_write_runtime_config_file() { agent_llm.insert( " planner ".to_string(), GameCreatorLlmConfigFile { + custom_enabled: None, + visible_models: None, api_key: Some(" planner-key ".to_string()), base_url: Some(" https://planner.example.test/v1 ".to_string()), model: Some(" planner-model ".to_string()), @@ -709,6 +783,8 @@ fn app_config_commands_write_runtime_config_file() { schema_version: GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION.to_string(), agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), llm: GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: " unit-test-key ".to_string(), base_url: " https://runtime.example.test/v1 ".to_string(), model: " runtime-model ".to_string(), @@ -838,7 +914,7 @@ fn app_config_save_updates_conflicting_local_overlay() { fs::create_dir_all(&root).expect("config dir"); let _guard = use_test_runtime_config_dir(root.clone()); let overlay_path = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME); - fs::write( + write_game_creator_config_atomically( &overlay_path, r#"{"llm":{"reasoningEffort":"low"},"selectedModelId":"existing","selectedModelIsDefault":true,"custom":{"keep":true}}"#, ) @@ -871,7 +947,7 @@ fn app_config_model_selection_only_updates_model_overlay() { fs::create_dir_all(&root).expect("config dir"); let _guard = use_test_runtime_config_dir(root.clone()); let overlay_path = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME); - fs::write( + write_game_creator_config_atomically( &overlay_path, r#"{"selectedModelId":"old","selectedModelIsDefault":false,"llm":{"reasoningEffort":"low"}}"#, ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index f12416e7d..a91336e16 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -202,9 +202,13 @@ fn gui_final_exit_is_the_only_run_event_that_requests_runner_shutdown() { GameCreatorGuiRunnerShutdownOutcome::NotRequested ); assert_eq!( - resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || Ok(())), + resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || Ok(true)), GameCreatorGuiRunnerShutdownOutcome::Requested ); + assert_eq!( + resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || Ok(false)), + GameCreatorGuiRunnerShutdownOutcome::Retained + ); assert_eq!( resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || { Err("private shutdown diagnostic".to_string()) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index 1eacbe9c9..dd4946ea4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -5885,6 +5885,8 @@ async fn agent_loop_uses_per_agent_llm_overrides() { agent_llm.insert( "planner".to_string(), GameCreatorLlmConfigFile { + custom_enabled: None, + visible_models: None, api_key: Some("planner-key".to_string()), base_url: Some(planner_base_url), model: Some("planner-model".to_string()), @@ -5903,6 +5905,8 @@ async fn agent_loop_uses_per_agent_llm_overrides() { agent_llm.insert( "generator".to_string(), GameCreatorLlmConfigFile { + custom_enabled: None, + visible_models: None, api_key: Some("generator-key".to_string()), base_url: Some(generator_base_url), model: Some("generator-model".to_string()), @@ -5921,6 +5925,8 @@ async fn agent_loop_uses_per_agent_llm_overrides() { agent_llm.insert( "art-asset-plan".to_string(), GameCreatorLlmConfigFile { + custom_enabled: None, + visible_models: None, api_key: Some("art-key".to_string()), base_url: Some(art_base_url), model: Some("art-model".to_string()), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs index 762b67983..db84a285b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs @@ -1494,7 +1494,7 @@ async fn background_agent_runtime_deletes_file_then_verifies_before_completion() assert!(prompt_input.contains("只删除项目内普通文件")); let verification_request = receiver - .recv_timeout(Duration::from_secs(2)) + .recv_timeout(Duration::from_secs(10)) .expect("verification plan request"); assert!(verification_request.contains("file.delete")); assert!(verification_request.contains("已删除 game/obsolete-runtime-file.txt")); diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index fd8cfc657..1d4c33053 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "陶泥儿", - "version": "0.1.45", + "version": "0.1.47", "identifier": "world.genarrative.ai-game-creator", "build": { "beforeDevCommand": "npm --prefix ../.. run agc:serve", diff --git a/apps/ai-game-creator-shell/src-tauri/tests/runner_gui_owner_lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/tests/runner_gui_owner_lifecycle.rs index c49ef18c1..d9cd7e56b 100644 --- a/apps/ai-game-creator-shell/src-tauri/tests/runner_gui_owner_lifecycle.rs +++ b/apps/ai-game-creator-shell/src-tauri/tests/runner_gui_owner_lifecycle.rs @@ -10,7 +10,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; const RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json"; const RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock"; -const GUI_OWNER_LOCK_FILE_NAME: &str = "agent-runner.gui-owner.lock"; +const GUI_PARTICIPANT_LOCK_FILE_NAME: &str = "agent-runner.gui-participant.lock"; struct TestDirectory(PathBuf); @@ -50,8 +50,9 @@ fn open_locked_file(path: &Path) -> File { .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW) .open(path) .expect("open isolated lock file"); + // 模拟一个界面窗口:参与锁以共享锁持有,多个窗口可以同时持有。 // SAFETY: file owns a live descriptor and flock does not retain pointers. - assert_eq!(unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }, 0); + assert_eq!(unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_SH) }, 0); file } @@ -101,7 +102,7 @@ fn runner_binary() -> &'static str { #[test] fn gui_owned_runner_rejects_start_when_owner_dies_before_first_check() { let directory = TestDirectory::new("owner-lost-before-check"); - let owner = open_locked_file(&directory.path().join(GUI_OWNER_LOCK_FILE_NAME)); + let owner = open_locked_file(&directory.path().join(GUI_PARTICIPANT_LOCK_FILE_NAME)); let script = "kill -STOP $$; exec \"$1\" --agent-runner --config-dir \"$2\" --gui-owner-required"; let mut child = Command::new("/bin/sh") @@ -139,7 +140,7 @@ fn gui_owned_runner_rejects_start_when_owner_dies_before_first_check() { #[test] fn gui_owned_runner_exits_and_cleans_endpoint_after_established_owner_dies() { let directory = TestDirectory::new("owner-lost-after-start"); - let owner = open_locked_file(&directory.path().join(GUI_OWNER_LOCK_FILE_NAME)); + let owner = open_locked_file(&directory.path().join(GUI_PARTICIPANT_LOCK_FILE_NAME)); let mut child = Command::new(runner_binary()) .arg("--agent-runner") .arg("--config-dir") diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index ee075cfa5..9523363ac 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -605,16 +605,17 @@ function isPersistableDirectCodexConversationMessage(message: ChatMessage) { return /^[a-z0-9][a-z0-9-]{5,159}$/iu.test(turnId); } -function claimInitialSupervisorMessageForPage(projectPath: string) { +function claimInitialSupervisorMessageForPage(projectPath: string, scope = '') { let claimedProjectPaths = initialSupervisorMessageClaimsByPage.get(window); if (!claimedProjectPaths) { claimedProjectPaths = new Set(); initialSupervisorMessageClaimsByPage.set(window, claimedProjectPaths); } - if (claimedProjectPaths.has(projectPath)) { + const claimKey = `${projectPath}\u0000${scope}`; + if (claimedProjectPaths.has(claimKey)) { return false; } - claimedProjectPaths.add(projectPath); + claimedProjectPaths.add(claimKey); return true; } @@ -683,6 +684,7 @@ type AppProps = { activeVersionId?: ProjectSupervisorComponentProps['activeVersionId']; supervisorChatOnly?: boolean; initialSupervisorMessage?: string; + initialSupervisorMessageClaimScope?: string; initialCreationType?: HomeCreationType | null; initialAttachments?: LauncherImportedAttachment[]; playRequest?: ProjectSupervisorComponentProps['playRequest']; @@ -733,6 +735,7 @@ export function App({ activeVersionId = null, supervisorChatOnly = false, initialSupervisorMessage = '', + initialSupervisorMessageClaimScope = '', initialCreationType = null, initialAttachments = [], playRequest = null, @@ -870,6 +873,7 @@ export function App({ const initialSupervisorMessageLatchRef = useRef({ projectPath: initialProjectPath, prompt: initialSupervisorMessage.trim(), + claimScope: initialSupervisorMessageClaimScope, creationType: initialCreationType, attachments: toDirectCodexTurnAttachments(initialAttachments), }); @@ -7501,7 +7505,7 @@ export function App({ } if ( chatAgentBusy || - !claimInitialSupervisorMessageForPage(latch.projectPath) + !claimInitialSupervisorMessageForPage(latch.projectPath, latch.claimScope) ) { return; } diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 1a8e48d72..88594678d 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -757,6 +757,8 @@ export type RuntimeAgentLlmProviderPresetId = | RuntimeLlmProviderPresetId; export interface GameCreatorLlmConfig { + customEnabled?: boolean; + visibleModels?: string[]; apiKey: string; baseUrl: string; model: string; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 9d2ac7aba..d7c064561 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -43,7 +43,10 @@ import type { WorkspaceLauncherShellProps } from './model'; import { NonEmptyProjectDialog, ProjectsPage } from './ProjectCreation'; import { useAccountWallet } from './useAccountWallet'; import { useDeveloperAgentPanel } from './useDeveloperAgentPanel'; -import { useHomeProjectCreation } from './useHomeProjectCreation'; +import { + DESIGN_ARTIFACTS_BUILD_PROMPT, + useHomeProjectCreation, +} from './useHomeProjectCreation'; import { useRecentProjects } from './useRecentProjects'; export function WorkspaceLauncherShell({ @@ -120,7 +123,7 @@ export function WorkspaceLauncherShell({ const agentRuntimeMode: 'design' | 'game' = planningStartMode ? 'design' : 'game'; - const suppressInitialGameTurn = switchedToGameRuntime; + const omitOriginalPlanningTurnInputs = switchedToGameRuntime; const activeProjectContextRef = useRef(currentProjectContext); const manifestMergeRef = useRef(null); activeProjectContextRef.current = currentProjectContext; @@ -674,20 +677,27 @@ export function WorkspaceLauncherShell({ initialProjectManifest={currentProjectContext.manifest} initialProjectKind={currentProjectContext.projectKind} initialSupervisorMessage={ - !suppressInitialGameTurn - ? currentProjectContext.initialPrompt - : '' + switchedToGameRuntime + ? DESIGN_ARTIFACTS_BUILD_PROMPT + : !omitOriginalPlanningTurnInputs + ? currentProjectContext.initialPrompt + : '' } initialCreationType={ - !suppressInitialGameTurn - ? currentProjectContext.creationType - : null + switchedToGameRuntime + ? 'game' + : !omitOriginalPlanningTurnInputs + ? currentProjectContext.creationType + : null } initialAttachments={ - !suppressInitialGameTurn + !omitOriginalPlanningTurnInputs ? currentProjectContext.attachments : [] } + initialSupervisorMessageClaimScope={ + switchedToGameRuntime ? 'approved-design-build' : '' + } activeVersionId={activeVersionId} orchestrationMode="single-supervisor" projectSupervisorOnly diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index 5c54a5055..90ace801c 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -35,6 +35,7 @@ export type ProjectSupervisorComponentProps = { initialProjectManifest?: GameCreationAppManifest; initialProjectKind?: 'web' | 'godot' | 'cocos'; initialSupervisorMessage?: string; + initialSupervisorMessageClaimScope?: string; initialCreationType?: HomeCreationType | null; initialAttachments?: LauncherImportedAttachment[]; orchestrationMode?: 'single-supervisor' | 'professional-dag'; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 56201d7d3..27059edca 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -66,7 +66,7 @@ type UseHomeProjectCreationOptions = { rememberRecentWorkspace: (projectPath: string) => void; }; -const APPROVED_GDD_BUILD_PROMPT = [ +export const APPROVED_GDD_BUILD_PROMPT = [ '请按照附件中的已批准 GDD 开始建造这款游戏。', '', '这份 GDD 已覆盖游戏定位与一句话概念、类型与美术方向、游戏支柱、核心循环、目标用户、平台与输入事实、MVP 系统、暂不纳入范围、创作者提示和原型验证项。', @@ -74,6 +74,9 @@ const APPROVED_GDD_BUILD_PROMPT = [ '请先阅读并理解附件中的 fast_gdd.md,以它作为本次建造的主要依据,优先实现其中 MVP 范围内的可运行游戏原型。', ].join('\n'); +export const DESIGN_ARTIFACTS_BUILD_PROMPT = + '查看当前项目 design_artifacts/ 目录及其子目录下的文档,理解其游戏设计,并按照这些文档将游戏实现出来。'; + function createTextAttachmentFile(content: string) { const file = new File([content], 'fast_gdd.md', { type: 'text/markdown', diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx index 62e782c5f..0a0426d8e 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -18,6 +18,8 @@ import { ClientAuthRequestError } from '../../services/clientApi'; import { ClientHttpTimeoutError } from '../../services/clientHttp'; import { cachedLlmModelCatalog, + LLM_CONFIG_CHANGED_EVENT, + LlmModelCatalogConfigError, refreshLlmModelCatalog, } from '../../services/llmModelCatalog'; @@ -30,6 +32,7 @@ export type ConversationModelSelectHandle = { class ModelSelectionConfigError extends Error {} function modelCatalogErrorMessage(error: unknown) { + if (error instanceof LlmModelCatalogConfigError) return error.message; if (error instanceof ClientHttpTimeoutError) return '模型列表请求超时,请重试'; if (error instanceof ClientAuthRequestError && error.status) @@ -70,6 +73,7 @@ export function ConversationModelSelect({ const selectedRef = useRef(''); const selectionEpochRef = useRef(0); const busyTokenRef = useRef(0); + const syncEpochRef = useRef(0); const configWriteChainRef = useRef>(Promise.resolve()); const onReadyRef = useRef(onReady); const mountedRef = useRef(true); @@ -192,6 +196,7 @@ export function ConversationModelSelect({ const syncCatalog = useCallback( async (showBusy: boolean, manualRefresh = false) => { + const syncEpoch = ++syncEpochRef.current; if (manualRefresh && mountedRef.current) { setManualRefreshBusy(true); setNotice('正在刷新模型列表'); @@ -214,10 +219,17 @@ export function ConversationModelSelect({ try { catalog = await refreshLlmModelCatalog(); } catch (error) { + if (syncEpoch !== syncEpochRef.current) + return Boolean(selectedRef.current); catalogError = error; const cached = cachedLlmModelCatalog(); if (!cached) { if (mountedRef.current) { + appliedRevisionRef.current = null; + selectedRef.current = ''; + setModels([]); + setSelected(''); + setDefaultModelId(''); setError(modelCatalogErrorMessage(error)); setNotice(''); } @@ -227,6 +239,8 @@ export function ConversationModelSelect({ catalog = cached; usingCachedCatalog = true; } + if (syncEpoch !== syncEpochRef.current) + return Boolean(selectedRef.current); const ready = await applyCatalog(catalog, showBusy, epochAtRequest); if (usingCachedCatalog && mountedRef.current) setError(modelCatalogErrorMessage(catalogError)); @@ -235,6 +249,8 @@ export function ConversationModelSelect({ } return ready; } catch (error) { + if (syncEpoch !== syncEpochRef.current) + return Boolean(selectedRef.current); if (mountedRef.current) { setNotice(''); setError( @@ -268,8 +284,20 @@ export function ConversationModelSelect({ function handleWindowFocus() { void syncCatalog(false); } + function handleConfigChanged() { + selectionEpochRef.current += 1; + appliedRevisionRef.current = null; + selectedRef.current = ''; + setSelected(''); + setModels([]); + void syncCatalog(true); + } window.addEventListener('focus', handleWindowFocus); - return () => window.removeEventListener('focus', handleWindowFocus); + window.addEventListener(LLM_CONFIG_CHANGED_EVENT, handleConfigChanged); + return () => { + window.removeEventListener('focus', handleWindowFocus); + window.removeEventListener(LLM_CONFIG_CHANGED_EVENT, handleConfigChanged); + }; }, [syncCatalog]); useEffect(() => { diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index d222b1fbd..604b908e0 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -918,7 +918,8 @@ export function ProjectSupervisorView({ rows={3} value={chatInput} references={chatReferences} - showTriggerButton={!directCodex} + showTriggerButton={!directCodex && !planningSurfaceActive} + showPolishAction={!planningSurfaceActive} placeholder={ directCodex ? '描述你的想法,或 @ 引用素材' diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/CustomLlmSettings.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/CustomLlmSettings.tsx new file mode 100644 index 000000000..b75938007 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/runtime-config/CustomLlmSettings.tsx @@ -0,0 +1,157 @@ +import { useEffect, useRef, useState } from 'react'; + +import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField'; +import { resolveTauriInvoke } from '../../app/tauri'; +import type { GameCreatorLlmConfig } from '../../app/types'; + +export function CustomLlmSettings({ + llm, + disabled, + onChange, +}: { + llm: GameCreatorLlmConfig; + disabled: boolean; + onChange: (llm: GameCreatorLlmConfig) => void; +}) { + const [available, setAvailable] = useState([]); + const [query, setQuery] = useState(''); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(''); + const [error, setError] = useState(''); + const requestEpoch = useRef(0); + const selected = llm.visibleModels ?? []; + + useEffect(() => { + requestEpoch.current += 1; + setAvailable([]); + setBusy(false); + setStatus(''); + setError(''); + return () => { + requestEpoch.current += 1; + }; + }, [llm.baseUrl, llm.apiKey]); + + async function discover() { + const invoke = resolveTauriInvoke(); + if (!invoke || busy) return; + const epoch = ++requestEpoch.current; + setBusy(true); + setError(''); + setStatus('正在读取模型列表'); + try { + const models = await invoke( + 'discover_game_creator_llm_models', + { llm }, + ); + if (epoch !== requestEpoch.current) return; + setAvailable(models); + setStatus( + models.length + ? `已读取 ${models.length} 个模型` + : '端点没有返回可用模型', + ); + } catch (error) { + if (epoch !== requestEpoch.current) return; + setStatus(''); + setError(typeof error === 'string' ? error : '模型列表读取失败,请重试'); + } finally { + if (epoch === requestEpoch.current) setBusy(false); + } + } + + const candidates = [...new Set([...available, ...selected])].filter((id) => + id.toLowerCase().includes(query.trim().toLowerCase()), + ); + return ( +
+ + + + {status ?

{status}

: null} + {error ?

{error}

: null} +
+
+

可选模型

+ setQuery(event.currentTarget.value)} + /> +
+ {candidates.map((id) => ( + + ))} +
+
+
+

已勾选模型({selected.length})

+ {selected.length ? ( +
    + {selected.map((id, index) => ( +
  1. + {id} + {index === 0 ? 默认 : null} +
  2. + ))} +
+ ) : ( +

尚未勾选模型

+ )} +
+
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index 289a0cfbb..a12f99ff8 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -34,6 +34,7 @@ import { gameCreatorLlmReasoningEfforts, } from '../../app/types'; import { checkForAppUpdate } from '../../services/appUpdate'; +import { notifyLlmConfigChanged } from '../../services/llmModelCatalog'; import { listAgcExtensions, reloadAgcPlugin, @@ -43,11 +44,15 @@ import { stopAgcPlugin, } from '../../services/pluginHost'; import { PluginPanelHost } from '../plugins/PluginPanelHost'; +import { reasoningEffortLabel } from '../project-workspace/composerReasoningEffort'; +import { CustomLlmSettings } from './CustomLlmSettings'; const defaultRuntimeConfigDraft: GameCreatorAppConfig = { schemaVersion: 'game-creator-config.v2', agentMode: 'codex_app_server', llm: { + customEnabled: false, + visibleModels: [], apiKey: '', baseUrl: '', model: '', @@ -148,9 +153,9 @@ function normalizeRuntimeConfigDraft( agentMode: 'codex_app_server', llm: { ...config.llm, - apiKey: '', - baseUrl: '', - model: '', + apiKey: config.llm.customEnabled ? config.llm.apiKey : '', + baseUrl: config.llm.customEnabled ? config.llm.baseUrl : '', + model: config.llm.customEnabled ? config.llm.model : '', apiKind: 'openai_responses', reasoningEffort, webSearchEnabled: @@ -182,9 +187,7 @@ function normalizeRuntimeConfigDraft( maxRetries: clampRuntimeConfigNumber(config.llm.maxRetries, 0), retryBackoffMs: clampRuntimeConfigNumber(config.llm.retryBackoffMs, 1), }, - // Official AGC builds have one account-backed route. Drop every legacy - // per-agent override (including non-sensitive tuning) at the UI boundary - // so it cannot be persisted or accidentally re-exposed as a route. + // 客户端统一使用全局连接,设置不保留独立的 Agent 路由覆盖。 agentLlm: {}, editorApi: allowAdvancedExternalEditorConfig ? { ...defaultRuntimeConfigDraft.editorApi, ...config.editorApi } @@ -621,6 +624,7 @@ export function RuntimeConfigDialog({ advancedExternalEditorConfigEnabled, ); setRuntimeConfigDraft(savedConfig); + notifyLlmConfigChanged(); setRuntimeConfigStatus(`已保存:${result.path}`); setRuntimeConfigToast({ tone: 'success', @@ -641,7 +645,13 @@ export function RuntimeConfigDialog({ } function resetRuntimeConfigDraft() { - setRuntimeConfigDraft(defaultRuntimeConfigDraft); + setRuntimeConfigDraft({ + ...defaultRuntimeConfigDraft, + llm: { + ...defaultRuntimeConfigDraft.llm, + customEnabled: runtimeConfigDraft.llm.customEnabled, + }, + }); setRuntimeConfigStatus('已恢复默认配置,保存后生效'); } @@ -785,13 +795,49 @@ export function RuntimeConfigDialog({
工作方式 陶泥儿智能创作(固定) - 需求将由官方智能服务执行 + {!runtimeConfigDraft.llm.customEnabled ? ( + 需求将由官方智能服务执行 + ) : null}
智能服务 - 官方账号服务(固定) - 登录后自动使用当前账号权限。 + + {runtimeConfigDraft.llm.customEnabled + ? '自定义 LLM' + : '官方账号服务(固定)'} + + {!runtimeConfigDraft.llm.customEnabled ? ( + 登录后自动使用当前账号权限。 + ) : null}
+ {runtimeConfigDraft.llm.customEnabled ? ( + <> +
+ 协议 + OpenAI Responses + 自定义端点需兼容该协议。 +
+
+ 推理档 + + {reasoningEffortLabel( + runtimeConfigDraft.llm.reasoningEffort, + )} + + 在对话输入盒的模型旁按回合调整。 +
+ + setRuntimeConfigDraft((current) => ({ + ...current, + llm, + })) + } + /> + + ) : null} {runtimeConfigDraft.agentMode !== 'codex_cli' ? ( <> {/* 推理档已下移到对话输入盒的模型选择器旁(按回合生效), diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index c0b5e0750..65d0650da 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -13,6 +13,8 @@ import type { import { API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION, + isApiResponse, + parseApiErrorMessage, unwrapApiResponse, } from '../../../../packages/shared/src/http'; import { @@ -138,6 +140,18 @@ export function getClientAuthErrorMessage(error: unknown, fallback: string) { return error instanceof Error ? error.message : fallback; } +/** + * 旧形态错误体:未带 `x-genarrative-response-envelope` 时后端返回 + * `{ error: { code, message }, meta }`,没有 `ok` 字段,但 message 同样是给用户看的原因。 + */ +function isLegacyApiErrorBody(value: unknown) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const record = value as Record; + return 'error' in record || 'message' in record || 'code' in record; +} + async function readAuthErrorMessage(response: Response, fallback: string) { const httpFallback = getClientAuthHttpErrorMessage(response.status, fallback); const text = await readClientHttpResponseText(response, { @@ -150,15 +164,27 @@ async function readAuthErrorMessage(response: Response, fallback: string) { try { parsed = JSON.parse(text) as unknown; } catch { + // 非 JSON(代理错误页、纯文本)不把内部英文原样抛给用户。 return httpFallback; } - try { - unwrapApiResponse(parsed); - } catch (error) { - const message = error instanceof Error ? error.message.trim() : ''; - return message && message !== '请求失败' ? message : httpFallback; + if (isApiResponse(parsed)) { + try { + unwrapApiResponse(parsed); + } catch (error) { + const message = error instanceof Error ? error.message.trim() : ''; + return message && message !== '请求失败' ? message : httpFallback; + } + return httpFallback; } - return httpFallback; + if (!isLegacyApiErrorBody(parsed)) { + return httpFallback; + } + // 旧形态错误体仍按共享契约解析,否则“手机号或密码错误”这类明确原因会退化成固定文案。 + const legacyMessage = parseApiErrorMessage(text, httpFallback).trim(); + // 共享解析器在认不出结构时会回显原始 JSON,这里不允许把它当成用户可见文案。 + return legacyMessage && legacyMessage !== text.trim() + ? legacyMessage + : httpFallback; } async function requestAuthJson( diff --git a/apps/ai-game-creator-shell/src/services/llmModelCatalog.ts b/apps/ai-game-creator-shell/src/services/llmModelCatalog.ts index b4bd4a25d..9601ff2a3 100644 --- a/apps/ai-game-creator-shell/src/services/llmModelCatalog.ts +++ b/apps/ai-game-creator-shell/src/services/llmModelCatalog.ts @@ -1,7 +1,54 @@ +import { resolveTauriInvoke } from '../app/tauri'; +import type { GameCreatorAppConfigView } from '../app/types'; import { type ClientLlmModelCatalog, loadClientLlmModels } from './clientApi'; let cached: ClientLlmModelCatalog | null = null; let inFlight: Promise | null = null; +let source = ''; +let generation = 0; +let localRevision = 0; +export const LLM_CONFIG_CHANGED_EVENT = 'agc-llm-config-changed'; +export class LlmModelCatalogConfigError extends Error {} + +export function notifyLlmConfigChanged() { + generation += 1; + cached = null; + inFlight = null; + source = ''; + window.dispatchEvent(new Event(LLM_CONFIG_CHANGED_EVENT)); +} + +async function loadEffectiveCatalog(epoch: number) { + const invoke = resolveTauriInvoke(); + let config: GameCreatorAppConfigView | undefined; + try { + config = invoke + ? await invoke('read_game_creator_app_config') + : undefined; + } catch (error) { + if (epoch === generation) cached = null; + throw new LlmModelCatalogConfigError('读取客户端配置失败'); + } + if (epoch !== generation) throw new Error('模型配置已更新'); + const llm = config?.config.llm; + const nextSource = llm?.customEnabled + ? JSON.stringify(['custom', llm.baseUrl, llm.visibleModels ?? []]) + : 'official'; + if (source !== nextSource) { + source = nextSource; + cached = null; + } + if (llm?.customEnabled) { + if (cached) return cached; + const ids = llm.visibleModels ?? []; + return { + defaultModelId: ids[0] ?? '', + models: ids.map((id) => ({ id, displayName: id })), + revision: --localRevision, + }; + } + return loadClientLlmModels(); +} /** 最近一次成功读取的模型目录,用于首屏渲染与刷新失败时兜底。 */ export function cachedLlmModelCatalog() { @@ -14,8 +61,10 @@ export function cachedLlmModelCatalog() { */ export function refreshLlmModelCatalog() { if (inFlight) return inFlight; - const request = loadClientLlmModels() + const epoch = generation; + const request = loadEffectiveCatalog(epoch) .then((catalog) => { + if (epoch !== generation) throw new Error('模型配置已更新'); cached = catalog; return catalog; }) @@ -27,6 +76,8 @@ export function refreshLlmModelCatalog() { } export function resetLlmModelCatalogCacheForTest() { + generation += 1; + source = ''; cached = null; inFlight = null; } diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 0f8ee6817..5b0352492 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -3940,6 +3940,61 @@ h2 { backdrop-filter: blur(6px); } +.runtime-custom-llm { + grid-column: 1 / -1; + display: grid; + gap: 12px; + min-width: 0; +} + +.runtime-custom-llm-discover { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + justify-self: start; + min-height: 34px; + padding: 0 14px; + border: 1px solid var(--platform-surface-border); + border-radius: 9px; + background: var(--platform-button-secondary-fill); + color: var(--platform-button-secondary-text); + font-size: 11px; + font-weight: 700; + cursor: pointer; +} + +.runtime-custom-llm-discover:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.runtime-custom-model-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); + gap: 16px; +} + +.runtime-custom-model-columns > section { + min-width: 0; + padding: 12px; + border: 1px solid var(--platform-subpanel-border); + border-radius: 12px; +} + +.runtime-custom-model-list { + max-height: 240px; + overflow: auto; + overflow-wrap: anywhere; +} + +.runtime-custom-model-list .settings-checkbox { + display: flex; + align-items: start; + gap: 8px; + margin: 8px 0; +} + .runtime-settings-toast { position: absolute; top: 24px; @@ -4357,6 +4412,34 @@ h2 { color: var(--platform-text-base); } +/* 固定项(工作方式 / 智能服务 / 协议 / 推理档)按「标签 → 取值 → 说明」纵向排列。 */ +.runtime-settings-readonly-field { + display: grid; + gap: 3px; + min-width: 0; + padding: 13px; + border: 1px solid var(--platform-subpanel-border); + border-radius: 12px; + background: var(--runtime-settings-subpanel-fill); +} + +.runtime-settings-readonly-field > span { + color: var(--platform-text-soft); + font-size: 11px; +} + +.runtime-settings-readonly-field > strong { + color: var(--platform-text-strong); + font-size: 14px; + line-height: 1.35; +} + +.runtime-settings-readonly-field > small { + color: var(--platform-text-soft); + font-size: 10px; + line-height: 1.4; +} + .runtime-settings-fields input, .runtime-settings-fields select { border-color: var(--platform-surface-border); @@ -10271,6 +10354,15 @@ button.design-workspace-tree__entry:hover, /* 策划聊天区包含阶段控制卡、消息、Runtime 状态和输入框。GameAgent 资源工作台的 消息列表默认占满整个聊天区,策划模式需要单独恢复五行布局,避免输入框被推到视口外。 */ +/* 策划工作台保留标题行,避免共用跨行规则将标题挤到底部。 */ +.game-workbench-layout--design .game-workbench-chat { + grid-template-rows: auto minmax(0, 1fr); +} + +.game-workbench-layout--design .game-workbench-chat .project-supervisor-surface { + grid-row: 2; +} + .game-workbench-layout--design .project-supervisor-surface { display: block; height: 100%; diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index bfb771cb0..586a4e0a7 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -1156,6 +1156,94 @@ export function registerAuthTests() { expect(screen.queryByText(/Unexpected|Failed to deserialize/u)).toBeNull(); }); + it('shows the backend reason when the password login is rejected', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response('', { status: 401 }); + } + if (url === '/api/auth/entry') { + return new Response( + JSON.stringify({ + ok: false, + data: null, + error: { code: 'unauthorized', message: '手机号或密码错误' }, + meta: { apiVersion: '2026-06-16', routeVersion: 'v1' }, + }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + render( + React.createElement(AuthenticatedClient, null, () => + React.createElement('main', { 'aria-label': '已登录' }, 'ready'), + ), + ); + + await screen.findByRole('main', { name: '登录' }); + fireEvent.click(screen.getByRole('button', { name: '密码登录' })); + fireEvent.change(screen.getByLabelText('手机号'), { + target: { value: '15801783533' }, + }); + fireEvent.change(screen.getByLabelText('密码'), { + target: { value: 'wrong-password' }, + }); + fireEvent.click(screen.getByRole('button', { name: '登录' })); + + expect(await screen.findByText('手机号或密码错误')).not.toBeNull(); + expect(screen.queryByText('登录失败')).toBeNull(); + }); + + it('keeps the backend reason when the error body carries no envelope', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response('', { status: 401 }); + } + if (url === '/api/auth/entry') { + return new Response( + JSON.stringify({ + error: { code: 'unauthorized', message: '手机号或密码错误' }, + meta: { apiVersion: '2026-06-16', routeVersion: 'v1' }, + }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + render( + React.createElement(AuthenticatedClient, null, () => + React.createElement('main', { 'aria-label': '已登录' }, 'ready'), + ), + ); + + await screen.findByRole('main', { name: '登录' }); + fireEvent.click(screen.getByRole('button', { name: '密码登录' })); + fireEvent.change(screen.getByLabelText('手机号'), { + target: { value: '15801783533' }, + }); + fireEvent.change(screen.getByLabelText('密码'), { + target: { value: 'wrong-password' }, + }); + fireEvent.click(screen.getByRole('button', { name: '登录' })); + + expect(await screen.findByText('手机号或密码错误')).not.toBeNull(); + expect(screen.queryByText('登录失败')).toBeNull(); + }); + it('shows a clear login service error instead of raw Load failed', async () => { vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { diff --git a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx index 9ee9e06d0..a506c23ae 100644 --- a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx +++ b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx @@ -22,7 +22,10 @@ import { loadClientLlmModels, } from '../src/services/clientApi'; import { ClientHttpTimeoutError } from '../src/services/clientHttp'; -import { resetLlmModelCatalogCacheForTest } from '../src/services/llmModelCatalog'; +import { + notifyLlmConfigChanged, + resetLlmModelCatalogCacheForTest, +} from '../src/services/llmModelCatalog'; vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: vi.fn() })); const MockClientAuthRequestError = vi.hoisted( @@ -79,6 +82,100 @@ beforeEach(() => { }); afterEach(cleanup); +test('turning custom mode off cannot reuse custom models when the official catalog fails', async () => { + let customEnabled = true; + savedModelId = 'custom-model'; + invoke.mockImplementation(async (command, input) => { + if (command === 'select_game_creator_model') { + savedModelId = input.modelId; + savedModelIsDefault = input.isDefault; + } + return { + config: { + llm: { + customEnabled, + baseUrl: 'https://custom.example/v1', + visibleModels: ['custom-model'], + }, + selectedModelId: savedModelId, + selectedModelIsDefault: savedModelIsDefault, + }, + }; + }); + const onReady = await renderReadyModelMenu(); + expect(screen.getByRole('option', { name: /custom-model/ })).not.toBeNull(); + customEnabled = false; + vi.mocked(loadClientLlmModels).mockRejectedValueOnce( + new Error('unavailable'), + ); + fireEvent(window, new Event('focus')); + await screen.findByText('模型列表加载失败'); + expect(onReady).toHaveBeenLastCalledWith(false); + expect(screen.queryByRole('option', { name: /custom-model/ })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' })); + await screen.findByRole('option', { name: /高质量/ }); + expect(savedModelId).toBe('quality'); +}); + +test('custom mode only shows checked endpoint model IDs and never requests the platform catalog', async () => { + savedModelId = 'vendor/model.v1'; + const models = ['vendor/model.v1', 'vendor/fast:latest']; + invoke.mockImplementation(async (command, input) => { + if (command === 'select_game_creator_model') { + savedModelId = input.modelId; + savedModelIsDefault = input.isDefault; + } + return { + config: { + llm: { + customEnabled: true, + baseUrl: 'https://custom.example/v1', + visibleModels: models, + }, + selectedModelId: savedModelId, + selectedModelIsDefault: savedModelIsDefault, + }, + }; + }); + await renderReadyModelMenu(); + expect(screen.getAllByRole('option')).toHaveLength(2); + fireEvent.click(screen.getByRole('option', { name: 'vendor/fast:latest' })); + await waitFor(() => expect(savedModelId).toBe('vendor/fast:latest')); + expect(loadClientLlmModels).not.toHaveBeenCalled(); +}); + +test('saving a custom catalog replaces official models and falls back when the old selection is unchecked', async () => { + await renderReadyModelMenu(); + vi.mocked(loadClientLlmModels).mockClear(); + let models = ['custom.v1', 'custom.v2']; + invoke.mockImplementation(async (command, input) => { + if (command === 'select_game_creator_model') { + savedModelId = input.modelId; + savedModelIsDefault = input.isDefault; + } + return { + config: { + llm: { + customEnabled: true, + baseUrl: 'https://custom.example/v1', + visibleModels: models, + }, + selectedModelId: savedModelId, + selectedModelIsDefault: savedModelIsDefault, + }, + }; + }); + act(() => notifyLlmConfigChanged()); + await waitFor(() => expect(savedModelId).toBe('custom.v1')); + expect(screen.queryByRole('option', { name: /高质量/ })).toBeNull(); + expect(screen.getAllByRole('option')).toHaveLength(2); + models = ['custom.v2']; + act(() => notifyLlmConfigChanged()); + await waitFor(() => expect(savedModelId).toBe('custom.v2')); + expect(screen.getAllByRole('option')).toHaveLength(1); + expect(loadClientLlmModels).not.toHaveBeenCalled(); +}); + async function renderReadyModelMenu() { const onReady = vi.fn(); render(); @@ -111,6 +208,7 @@ test('shows manual refresh progress immediately without clearing the selected mo fireEvent.keyDown(document, { key: 'Escape' }); expect(screen.getByRole('status').textContent).toBe('正在刷新模型列表'); + await waitFor(() => expect(resolveRefresh).toBeTypeOf('function')); await act(async () => { resolveRefresh({ defaultModelId: 'quality', @@ -498,11 +596,11 @@ test('recovers the selector when reading the native config fails', async () => { await screen.findByText('读取客户端配置失败'); await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(false)); - // 失败后必须恢复可交互:选项与刷新按钮都不能被永久禁用。 + // 配置不可读时不能猜测官方路由;恢复读取后选项与刷新按钮重新可用。 + expect(loadClientLlmModels).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: '对话模型' })); - expect( - screen.getByRole('option', { name: /高质量/ }).hasAttribute('disabled'), - ).toBe(false); + const option = await screen.findByRole('option', { name: /高质量/ }); + expect(option.hasAttribute('disabled')).toBe(false); fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' })); await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); expect(screen.queryByText('读取客户端配置失败')).toBeNull(); diff --git a/apps/ai-game-creator-shell/tests/customLlmSettings.test.tsx b/apps/ai-game-creator-shell/tests/customLlmSettings.test.tsx new file mode 100644 index 000000000..3a14cdc6a --- /dev/null +++ b/apps/ai-game-creator-shell/tests/customLlmSettings.test.tsx @@ -0,0 +1,157 @@ +// @vitest-environment jsdom +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { useState } from 'react'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; + +import defaults from '../game-creator.config.json'; +import type { + GameCreatorAppConfig, + GameCreatorLlmConfig, +} from '../src/app/types'; +import { CustomLlmSettings } from '../src/features/runtime-config/CustomLlmSettings'; +import { RuntimeConfigDialog } from '../src/features/runtime-config/RuntimeConfigDialog'; + +const invoke = vi.fn(); +const llm = { + ...defaults.llm, + customEnabled: true, + baseUrl: 'https://models.example/v1', + apiKey: 'test-custom-key', + visibleModels: ['vendor/model.v1'], +} as GameCreatorLlmConfig; +function Harness() { + const [draft, setDraft] = useState(llm); + return ; +} + +beforeEach(() => { + invoke.mockReset(); + window.__TAURI__ = { core: { invoke } }; +}); +afterEach(() => { + cleanup(); + delete window.__TAURI__; +}); + +test('reads endpoint models, filters candidates, and previews only checked models', async () => { + invoke.mockResolvedValue([ + 'vendor/model.v1', + 'vendor/fast:latest', + 'hidden-model', + ]); + render(); + fireEvent.click(screen.getByRole('button', { name: '读取模型列表' })); + await screen.findByText('已读取 3 个模型'); + expect(invoke).toHaveBeenCalledWith('discover_game_creator_llm_models', { + llm, + }); + fireEvent.click(screen.getByRole('checkbox', { name: 'vendor/fast:latest' })); + const preview = screen.getByRole('region', { name: '已勾选模型预览' }); + expect( + within(preview) + .getAllByRole('listitem') + .map((item) => item.textContent), + ).toEqual(['vendor/model.v1 默认', 'vendor/fast:latest']); + expect(within(preview).queryByText('hidden-model')).toBeNull(); + fireEvent.change(screen.getByRole('textbox', { name: '搜索模型' }), { + target: { value: 'FAST' }, + }); + expect(screen.getAllByRole('checkbox')).toHaveLength(1); + fireEvent.click(screen.getByRole('checkbox', { name: 'vendor/fast:latest' })); + expect(within(preview).getAllByRole('listitem')).toHaveLength(1); +}); + +test('failed discovery preserves checked models and allows retry', async () => { + invoke + .mockRejectedValueOnce('模型列表读取失败(HTTP 401)') + .mockResolvedValueOnce(['vendor/model.v1']); + render(); + fireEvent.click(screen.getByRole('button', { name: '读取模型列表' })); + await screen.findByRole('alert'); + expect( + within(screen.getByRole('region', { name: '已勾选模型预览' })).getByText( + /vendor\/model.v1/, + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '读取模型列表' })); + await screen.findByText('已读取 1 个模型'); + expect(screen.queryByRole('alert')).toBeNull(); +}); + +test('changing endpoint discards an old in-flight result and clears old selections', async () => { + let finish!: (models: string[]) => void; + invoke.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + render(); + fireEvent.click(screen.getByRole('button', { name: '读取模型列表' })); + fireEvent.change(screen.getByLabelText('自定义 LLM API 地址'), { + target: { value: 'https://new.example/v1' }, + }); + await act(async () => finish(['old-model'])); + expect(screen.queryByRole('checkbox', { name: 'old-model' })).toBeNull(); + expect(screen.getByText('尚未勾选模型')).not.toBeNull(); + expect(screen.getByRole('button', { name: '读取模型列表' })).toHaveProperty( + 'disabled', + false, + ); +}); + +test('settings save retains custom credentials and checked models, and reopening restores them', async () => { + let config = { + ...defaults, + llm, + agentLlm: {}, + editorApi: { baseUrl: 'https://platform.example', apiKey: '' }, + } as GameCreatorAppConfig; + invoke.mockImplementation(async (command, args) => { + if (command === 'write_game_creator_app_config') config = args.config; + if ( + command === 'read_game_creator_app_config' || + command === 'write_game_creator_app_config' + ) + return { path: '/private/game-creator.config.json', config }; + if (command === 'discover_game_creator_llm_models') + return ['vendor/model.v1', 'vendor/fast:latest']; + return []; + }); + const first = render( {}} />); + await screen.findByLabelText('自定义 LLM API 地址'); + expect(screen.getByLabelText('自定义 LLM API Key')).toHaveProperty( + 'type', + 'password', + ); + expect(screen.getByText('OpenAI Responses')).not.toBeNull(); + expect(screen.getByText('最高')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '读取模型列表' })); + await screen.findByText('已读取 2 个模型'); + fireEvent.click(screen.getByRole('checkbox', { name: 'vendor/fast:latest' })); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + await waitFor(() => + expect(config.llm.visibleModels).toEqual([ + 'vendor/model.v1', + 'vendor/fast:latest', + ]), + ); + expect(config.llm.apiKey).toBe('test-custom-key'); + expect(config.llm.customEnabled).toBe(true); + first.unmount(); + render( {}} />); + await screen.findByLabelText('自定义 LLM API 地址'); + expect( + within(screen.getByRole('region', { name: '已勾选模型预览' })).getAllByRole( + 'listitem', + ), + ).toHaveLength(2); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceTagStatsRefresh.test.tsx b/apps/ai-game-creator-shell/tests/resourceTagStatsRefresh.test.tsx index b21591830..950c78f6e 100644 --- a/apps/ai-game-creator-shell/tests/resourceTagStatsRefresh.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceTagStatsRefresh.test.tsx @@ -89,9 +89,10 @@ function graphFor(manifest: GameCreationAppManifest) { * * 这里只把壳换成用例自己的 `useState`(壳那份 CAS 归并由 * `workspaceLauncherManifestMerge.test.tsx` 单独钉住),画布、聊天输入区、 - * 标签统计与候选全部是被测的真实实现。 + * 标签统计与候选全部是被测的真实实现。策划 Agent 会隐藏 @ 入口,这条链路钉住 + * game Agent 的既有行为。 */ -function TagStatsHost({ planningStartMode }: { planningStartMode: boolean }) { +function TagStatsHost() { const [manifest, setManifest] = useState(() => { const initial = createFixtureManifest(); disk.manifest = initial; @@ -117,7 +118,6 @@ function TagStatsHost({ planningStartMode }: { planningStartMode: boolean }) { initialProjectPath={PROJECT_PATH} initialProjectManifest={manifest} projectSupervisorOnly - planningStartMode={planningStartMode} onManifestChange={onManifestChange} /> } @@ -251,7 +251,7 @@ describe('改完素材标签后聊天 @ 选择器的标签统计与候选跟着 it('在资源画布改完标签保存后,聊天 @ 选择器出现该标签及其计数,并按它收窄候选', async () => { const { classificationWrites } = installHostTauri(); - render(); + render(); // 先在画布上打开「编辑素材标签」面板改标签:与用户的操作路径一致。 fireEvent.click( @@ -294,7 +294,7 @@ describe('改完素材标签后聊天 @ 选择器的标签统计与候选跟着 it('未标注标签时聊天 @ 选择器不渲染任何标签 chip', async () => { installHostTauri(); - render(); + render(); await openChatPicker(); expect(pickerTagChips()).toEqual([]); diff --git a/docs/README.md b/docs/README.md index 6a6bc67eb..6450b77c5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -102,7 +102,7 @@ - [后台 Dashboard 运营看板方案](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md) - [后台多账号与 Tab 访问权限方案](./technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md) - [Pingora 独立网关试点](<./technical/【开发运维】Pingora独立网关试点-2026-06-11.md>) -- [AGC 后台模型别名与对话选择](./technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md) +- [AGC 后台模型别名与对话选择](./technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md):官方目录、本地自定义 LLM 开关、端点模型勾选与预览。 - [UI 编辑器工作流完成通知弹窗](./technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md) - [官网 SEO 地基实施约定](./technical/【SEO】官网SEO地基实施约定-2026-07-10.md) - [UI 编辑器拖动变换提交边界](./【UI编辑器】拖动变换提交边界-2026-09-03.md) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d778bcade..360df2121 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2,6 +2,18 @@ > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 + +## 2026-09-16 AGC 同 AppData 多窗口共享 Agent Runner + +- 背景:双击或再次启动 AGC 客户端时报「应用启动失败」,启动日志为 `startup.runner.owner-lock.failed details=AI 游戏创作界面已由同一 AppData 目录中的其他进程运行`。原设计(2026-07-27 / 2026-08-23)要求同一 AppData 只有一个 GUI owner,第二个界面进程在 setup 阶段就失败退出。 +- 决策(锁语义):`agent-runner.gui-owner.lock` 改为**界面参与锁** `agent-runner.gui-participant.lock`,以共享句柄打开,同一 AppData 的任意数量窗口可同时持有;Runner 的启动检查、attach 门禁与 watchdog 只用“能否独占取得该文件”判断是否仍有窗口存活。全部窗口退出后才关停 Runner 并清理 endpoint。 +- 决策(claim 采纳与发布):窗口启动先**采纳**durable claim(同一 epoch/revision),只有 claim 缺失或不可读才发布新 claim;登录、refresh、退出或换号才发布新 claim(新 epoch + 本窗口 revision),成为新的登录态权威。同一 claim 的重复 attach 是幂等空操作,不再清空 Runner 登录态;只有 epoch 变化或携带明确登出参数才允许替换 / 清空。并发发布以最后一次成功写入的 claim 为准,落败窗口按最新 claim 有界重试。 +- 决策(事件与退出):manifest 失效与 Runtime update relay 的接收端从单槽改为按 `event_sink_token` 去重的注册表并广播,发送失败只淘汰该接收端;GUI 退出先释放本窗口参与锁,仍有其它窗口时保留 Runner(`agent.runner.gui_exit.retained_for_other_windows`),最后一个窗口才请求关闭。Runner 启动失败时先按最新 endpoint 复用一次,避免两个窗口同时冷启动时的实例锁竞争被误报成启动失败。 +- 边界:本机 GUI ↔ Runner 协议方法与参数不变,不引入多 Runner、不做跨 AppData 会话共享;项目级 `.agent/project.lock` 不变,多窗口仍不能并行写同一项目;平台登录态 generation 单调与 claim 失配失败关闭语义保持不变;混用新旧版本二进制访问同一 AppData 不属于支持场景。 +- 验证:定向 Rust `runner::tests::gui_owner_*` 11 条与新增的参与锁多窗口 / 存活判定 / claim 采纳与轮换 / 同 claim 第二个窗口不清空登录态用例全部通过;真实 debug 二进制 Windows smoke 证明同一 AppData 两个 GUI 都完成 `startup.setup.complete`、只存在一个 `--agent-runner` 进程、关闭一个窗口后另一个窗口与 Runner 继续存活、最后一个窗口退出后 Runner 退出并删除 endpoint;`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。 +- 未验证 / 已知环境问题:真实安装包双开需要重新构建发布后才能验证;`durable_provider_handoff_prevents_shutdown_even_when_corrupt`、`durable_provider_retry_prevents_shutdown_and_reopens_writes`、`runtime_interrupt_for_true_steer_decision_only_interrupts_older_provider_cursor` 三条用例在本机改动前的基线上即失败(Windows 安全对象 owner 校验与 Provider 请求重复),与本决策无关。 +- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`(2026-09-16 节)、`docs/project-memory/plans/【里程碑】AGC同AppData多窗口共享Runner-2026-09-16.md`。 + ## 2026-09-16 图标图集自动拆图上限提高到 256 - 背景:AGC 图标图集自动连通域识别在一次生成中识别出 86 个区域,原有 64 片上限在后处理阶段阻断了请求;该上限同时影响 api-server 自动 / 手动切片、SpacetimeDB 批量落库和统一生成结果 item 数量。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 41361097c..8cb5e234e 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -8,6 +8,12 @@ JSON 的文本读取分支不等于卡面应该展示原始 State 摘要。卡 工作台向窗口标题栏发布运行项目时,若 effect 依赖普通函数派生的回调,发布 Context 会重新渲染工作台,进而再次发布并清理,形成更新深度循环。转发入口须稳定,并在提交阶段更新实际处理器引用;发布数据变化与卸载清理分开。回归测试必须组合真实窗口 Provider 和工作台消费者,只有独立画布测试无法覆盖这条反馈链;回归时用有界发布次数阻止测试失控。画布快速操作时暴露的更新深度错误,也须检查外层状态同步,不能直接归因于滚轮频率。 +## 2026-09-16 从 Codex 里启动 AGC 客户端会看到被重定向的 `%APPDATA%` + +- **现象**:在 Codex 会话里用 `Start-Process` 启动 `genarrative-ai-game-creator-shell.exe` 做排障时,子进程写 `C:\Users\\AppData\Roaming\world.genarrative.ai-game-creator\...` 的内容会落到 `C:\Users\\AppData\Local\Packages\OpenAI.Codex_2p2nqsd0c76g0\LocalCache\Roaming\...`;同一个 `Test-Path` / `Get-ChildItem` 命中的是重定向视图,只有 `\\?\C:\Users\...` 形式能区分真实路径。 +- **影响**:用 agent 拉起的客户端复现「多开 / 登录态冲突 / 锁文件被占用」类问题时,可能与用户双击开始菜单快捷方式的真实进程不是同一份 AppData,从而得出「两个实例没有互相冲突」或「日志里没有那条失败」的错误结论。 +- **处理**:把用户双击快捷方式(或从 Codex 之外启动)的进程作为唯一用户侧证据;核对待查文件时同时比对真实 `AppData\Roaming` 路径与 `Packages\...\LocalCache\Roaming` 路径;排障结论里写明客户端是「谁启动的」。 + ## Windows 已登记生图资产未刷新 Direct 工具桥会 canonicalize 项目根,事件中的路径可能带 `\\?\` / `\\?\UNC\`,而前端项目路径仍是普通盘符或 UNC。失效监听不能直接比较原始字符串;识别为同一项目后,用当前项目路径重读 manifest,保留项目切换与 revision 门禁。普通 `agc_generate_image` 成功提交也必须发出失效通知,不能依赖整轮 Agent 结束。回归需覆盖两种 Windows 前缀、其它项目事件拒收,以及 Agent 尚未结束和后续失败时已登记图片卡片仍可见。 diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index 8fbc7cdb0..7c1044fba 100644 --- a/docs/project-memory/shared-memory/project-overview.md +++ b/docs/project-memory/shared-memory/project-overview.md @@ -51,6 +51,8 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.8.3` 对齐 ## AGC DirectProject 与 UI workflow +- AGC 的本地 `llm.customEnabled` 默认关闭,只能手动修改配置文件;开启后设置支持自定义 Responses 端点、读取 `/models`、勾选和预览 `visibleModels`。对话下拉只显示勾选项,LLM 请求经客户端凭据代理直连自定义上游;不会回退官方中转,平台资源服务仍使用账号权限。详见 AGC 后台模型别名与对话选择规范。 + - DirectProject 对话先在完整历史中按回合/原始 item 身份关联,再分页渲染;每个回合只有一个呈现入口。有流按 item `seq` 交替文本和工具,无流采用历史正文;禁止位置猜配或同时展示累计回复与 item 正文。流写入单调归并,收尾等待落盘任务,不按磁盘“最后一段”猜最终回复位置。详见 AGC 实施计划的“DirectProject 回合展示唯一归属”。 - 回合生命周期只由活动 client 回合快照和 Direct 事件恢复;Provider 的历史终态通知不能创建活动 client 回合。消息发送时间保存在历史信封,原始 item 不混入宿主字段;完成后的中间文本和工具默认收进“执行过程”,最终回复及失败提示保持可见。 diff --git a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md index 48f119e49..cad3e359d 100644 --- a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md +++ b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md @@ -1,6 +1,36 @@ # AGC 后台模型别名与对话选择 -## 契约 +## 本地自定义 LLM + +- 本地 `game-creator.config.json` 的 `llm.customEnabled` 默认 `false`;显式设为 `true` 后,常用设置展示 API 地址、API Key、读取模型列表与勾选区域。DirectProject 沿用 OpenAI Responses 协议,地址填写 API 根地址(例如 `https://provider.example/v1`)。开关只由配置文件控制。 +- 点击读取后,客户端原生侧直接请求该地址下的 `GET /models`,使用自定义 Bearer Key,读取 OpenAI 兼容的 `data[].id`。请求有超时与响应大小上限,禁止携带平台登录凭据、禁止重定向;错误仅展示安全状态,不回显上游响应体或 Key。 +- 模型支持搜索、逐项勾选和独立的已勾选列表预览。`llm.visibleModels` 按勾选顺序保存模型 ID,第一项作为默认项。至少勾选一项才可保存;读取失败保留草稿和已勾选列表,不替用户清空或新增选择。 +- 首页与项目对话复用现有模型选择器;自定义模式只读取本地勾选目录,不请求平台模型目录。模型 ID 原样用于上游请求(允许 `/`、`.`、`:`),所选项被取消勾选时回退第一项。保存后刷新目录,下一回合使用新连接与模型,活动回合继续使用原快照。 +- 开启后,Codex 客户端凭据代理和 Rust LLM 调用均直连自定义端点,不走平台 `/api/llm` 或内置中转;配置缺失或请求失败明确报错,不回退官方路由。真实 Key 留在客户端,不进入 Codex 子进程环境、参数或模型上下文。平台图片、音频、账户和计费契约不变。 +- 配置加载、迁移和覆盖文件写入保留显式开启的连接和勾选列表;关闭时仍使用官方模型目录与官方路由。缓存不能跨自定义/官方目录或不同自定义连接复用。旧配置缺少新字段时保持官方行为。 +- 验收覆盖开关默认值、配置合并/保存/重载、直连凭据与模型传递、模型发现成功/失败/超时、勾选预览、仅显示勾选模型、目录切换、无平台目录请求和设置保存失败。自动化本地 HTTP 验证与真实供应商 smoke 分开报告。 + +在设置页显示的本地配置文件路径中,手动把现有 `llm` 对象的 `customEnabled` 设为 `true`(保留其它字段),重新打开常用设置即可配置连接和读取模型。界面没有开启开关,保存设置也不能把关闭状态改为开启。配置文件也支持直接填写以下字段: + +```json +{ + "llm": { + "customEnabled": true, + "baseUrl": "https://provider.example/v1", + "apiKey": "填写自己的密钥", + "apiKind": "openai_responses", + "visibleModels": ["provider/model-name", "another-model"] + } +} +``` + +`visibleModels` 可先留空,再从端点读取并勾选;未完成勾选前不能发起自定义 LLM 对话。关闭时手动改回 `false`,后续回合使用官方目录和路由。 + +本地配置文件始终保留 `customEnabled`、`visibleModels`、`apiKey`、`baseUrl`、`model`、`apiKind`、`reasoningEffort` 七个键:官方路由下连接字段写官方地址与空 Key、协议写 `openai_responses`,便于手写自定义连接时对照;切换为自定义后这些值原样保留而不被启动清理。 + +自定义连接固定使用 OpenAI Responses 协议(`apiKind` 只接受 `openai_responses`),设置页把协议展示为只读项;推理档沿用对话输入盒中按回合生效的选择器,设置页只读展示当前档位,不新增第二处写入入口。 + +## 官方路由契约 - 后台 owner 在“AGC 模型”维护列表;每项包含稳定 `id`、必填 `alias`、服务端 `modelId`、`enabled`。默认项必须启用。标识唯一,别名唯一,列表最多 32 项。 - 配置保存到私有 `agc_model_catalog` 单例表,使用 revision 乐观锁,重启及多 api-server 实例共享同一事实。缺少配置时使用初始目录,高质量对应 `gpt-6-astra`,快速对应 `gpt-5.6-luna`。 @@ -14,7 +44,7 @@ - `selectedModelIsDefault` 为真表示选择由平台默认项驱动(首次进入、默认项变化、所选模型失效回退),后台默认项变化时客户端跟随切换并提示;用户手动选择后置为假,不再被默认项变化覆盖。 - 首页聊天框架的右下角同样提供模型选择入口(与项目对话右侧一致)。首页入口与项目对话共用同一份目录缓存、挂载即加载(失败时沿用上一次成功目录),选择仅影响后续创建/发送的轮次,不阻塞「开启创作」,因此模型目录不可用时仍可创建项目并使用后台默认项。 - 项目右侧对话的模型选择器在对话进行中保持可交互:切换模型只写回客户端配置并作用于下一轮,当前回合不受影响;发送按钮仍由 `controlBusy` / `modelReady` 把关。 -- 设置页恢复到布局改版前的官方代理版本,不包含模型管理或模型选择,保留配置安全清理和官方代理锁定。 +- 自定义开关关闭时,设置页不包含模型管理或模型选择,使用官方代理锁定。 ## 验收 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 84214f5d2..e024c1788 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -71,9 +71,11 @@ DirectProject 自身的 `read_direct_project_conversation` 也必须在 blocking ## 常用设置职责 +本地配置 `llm.customEnabled` 为 `true` 时,常用设置开放自定义 Responses 连接、端点模型发现与勾选预览;开关只允许手动修改配置文件。模型选择、直连与凭据边界以 [AGC 后台模型别名与对话选择](./【技术方案】AGC后台模型别名与对话选择-2026-09-05.md) 的“本地自定义 LLM”为准,默认仍使用官方服务。 + 常用设置负责运行参数的读取、编辑和保存,配置读写独立于账号权限诊断。账号权限由登录会话与实际智能服务请求链路处理,设置面板只维护配置草稿与读写反馈。 -保存配置复用写入前读取的高优先级本地覆盖内容:常用设置同步覆盖文件中已有的对应配置项,并保留当前模型 ID 和默认模型标记;模型选择仅同步 `selectedModelId` 与 `selectedModelIsDefault`;无冲突时不写覆盖文件。所有内容先完成序列化,多文件写入前保存原始内容,任一写入失败时逆序恢复已变更文件,回滚失败须明确报告。各文件沿用现有原子写入,不提供断电或进程崩溃下的多文件事务保证。全部成功后直接返回规范化配置,不执行保存后回读或外部诊断;单文件保存保持原路径。 +保存配置复用写入前读取的高优先级本地覆盖内容:常用设置同步覆盖文件中已有的对应配置项,并保留当前模型 ID 和默认模型标记;自定义模式取消勾选当前模型时回退第一项,并同步覆盖文件中的选择。模型选择仅同步 `selectedModelId` 与 `selectedModelIsDefault`;无冲突时不写覆盖文件。所有内容先完成序列化,多文件写入前保存原始内容,任一写入失败时逆序恢复已变更文件,回滚失败须明确报告。各文件沿用现有原子写入,不提供断电或进程崩溃下的多文件事务保证。全部成功后直接返回规范化配置,不执行保存后回读或外部诊断;单文件保存保持原路径。 ## 2026-09-09 manifest 资源功能分类与自定义标签(UI 与写入) @@ -559,6 +561,7 @@ Agent Runtime 负责: - 2026-07-10 补充:`agent.delegate` 已形成可恢复的父子任务闭环。`delegationId` 由 durable pending action 的 `actionId` 派生,子任务记录会保存 `parentAgentId / parentRunId / delegationId`,终态记录额外保存经过统一凭据清洗和安全截断的 `terminalDetail`;同一委派的提交和回执分别受 delegation 级 OS 文件锁保护,同一目标 Agent 的 runId 分配与 pending 追加还受任务账本 OS 锁保护。子任务进入 `completed / failed / cancelled / budget-exhausted` 任一终态时,Runtime 按 `delegationId` 幂等生成且至多生成一次 `agent.delegate.result` 回执,失败、排队或活跃取消、预算耗尽都必须回传,不能只覆盖成功。回执会向父 Agent 既有队列追加固定 runId、`source=agent-delegate-receipt` 的续跑任务,把完整的已清洗 `terminalDetail` 交回父 run,不再只保留 80 字符 UI 摘要;回执 prompt 明确禁止重复同一委派,排队期间不提前写入父会话,真正开始执行时才幂等落盘,用户消息或回执消息落盘失败时不会进入 LLM。回执任务保留父 run 关联,并在真正开始或恢复前再次检查父 run 状态,关联缺失或父 run 不存在时失败关闭;该续跑仍受父 Agent 原有 FIFO、per-Agent OS 锁、权限确认、取消、恢复和 `needs-reconciliation` 屏障约束,不直接重入父 run、不插队、不新增独立 worker;父 run 已取消或普通失败时只保留 suppressed receipt 审计,不自动复活,父 Session 归档与切换会被未结束委派阻止,极端归档竞态下回执回落到父 Agent 当前可写 Session。恢复先恢复 pending action / reconciliation 屏障,再扫描“子任务终态已落盘但回执未提交”的窗口并补齐缺失回执;`needs-reconciliation` 本身不回执,只有人工核对后最终取消才回传 `cancelled`。 - 历史记录(已由 V1.1 独立 Runner 替代):Runtime 最初通过 `resume_game_creator_agent_runtime_tasks` 把本地 JSONL 队列重接到当前 App 进程。当前恢复入口仍保留权限、任务顺序和 `agent.runtime.background_task.recovered` 审计语义,但实际由独立 Runner 接管原 run / session;已发出的上游 LLM 请求仍不能从网络中间点续传。2026-07-27 起,Runner 归 Tauri GUI 生命周期所有,同一 AppData 只允许一个 GUI owner。GUI 启动子进程会显式声明 `--gui-owner-required` 并在就绪后 attach owner;Runner 若在启动检查前已发现 owner 释放则直接失败,不得退化成 CLI-owned Runner。Runner 使用独立 watchdog 线程每 100ms 监控 owner OS 锁,不依赖服务端主循环继续推进;owner 丢失后先触发 1.5 秒共享 deadline 的 draining、Provider 中断和 process session 回收,若主循环或排空链路卡死则在 1.75 秒后由 Runner 自身进程安全硬退出并清理匹配 bootId 的 endpoint。GUI 客户端还必须把完整 `runner.attach_gui_owner` 参数作为绑定规范化 AppData 的进程内登记保存;`ensure_external_agent_runner` 无论复用既有 endpoint 还是启动新 Runner,都要在把 endpoint 交给 Runtime 写请求前按新 `bootId` 补登记。同一登记 generation 在同一 boot 上幂等,补登记失败不得记录成功 boot 且本次 `ensure` 失败关闭;未建立 GUI 登记的普通 CLI 不执行该重放。OS owner 锁与 watchdog 已成立只代表进程受 GUI 生命周期约束,不能替代事件 sink 等进程内附加能力的逐 boot 恢复。因此正常最终退出、panic、SIGKILL 和 setup 中途失败都不会再因 busy 或主循环卡死而残留后台进程。endpoint 缺失 / 读取失败必须结合 Runner 实例锁判断;GUI 客户端强制兜底在 Linux 使用 pidfd、Windows 使用稳定进程 handle。macOS 没有等价稳定句柄,客户端不得在 start identity 检查后按裸 PID 强杀,而由跨平台 Runner 自身 watchdog 提供硬退出兜底。旧 endpoint 缺 start identity 时,只有认证 ping 精确匹配 PID + bootId 才允许迁移 busy 旧 Runner。未完成任务保持 durable 状态并在下一次启动走 reconciliation / recovery,不能伪造 completed 或重放副作用。关闭单个 WebView / 子窗口和普通 CLI 退出不触发该行为,版本切换与人工命令仍可使用只关闭空闲实例的 `runner.shutdown_if_idle`。 - 2026-08-23 Runner 协议 v7 GUI owner 会话权威补充:GUI 取得 owner OS 锁时产生随机 `owner epoch`,并在私有 AppData 持久化只含 `owner epoch + session revision` 的 claim;每次登录、refresh、退出或换号都必须先单调推进 durable session revision,再同步 Runner。`runner.attach_gui_owner` 是 Runner 接受平台会话快照的唯一授权入口;只有 attach 携带的 epoch/revision 与 durable claim 完全一致才可安装或清除会话,新 GUI epoch 可替换旧进程留下的高 `authGeneration`,不用可在新 WebView 重置的 generation 猜测进程所有权。Runner 在 claim 缺失、不可读或与当前 attach 身份失配时立即清空进程内平台会话,并阻断除重新 attach 及必要管理请求以外的 Runtime 工作;旧 `platform.session.install/clear` 协议不再是授权入口。GUI 会话同步未得到完整 attach 确认时本地变更必须失败,并隔离或停止旧 Runner;即使进程终止失败,claim 失配门禁也不允许旧账号继续发起 Runtime 请求。claim 不保存 Access Token,Token 只随当次受保护的 attach IPC 进入 Runner 内存。 +- 2026-09-16 多窗口更正:同一 AppData 不再只允许一个 GUI 界面进程。原 owner OS 锁改为可被多个界面进程同时持有的参与者锁(`agent-runner.gui-participant.lock`),Runner 以“能否独占取得该锁”判断是否仍有界面进程存活,watchdog 与 attach 门禁都改用该判定。新增窗口默认只**采纳** durable claim(读同一 epoch/revision 并 attach),只有登录、refresh、退出或换号才发布新 claim(新 epoch + 本窗口 revision),因此同 claim 的重复 attach 不再清空平台会话,epoch 变化才允许强制替换。事件接收端从单槽改为按 token 去重的注册表并广播,保证第二个窗口 attach 后第一个窗口仍收到 manifest 失效与 Runtime update relay。claim 一致性与失败关闭语义不变。详见同文档「2026-09-16 AGC 同 AppData 多窗口共享 Agent Runner」。 - 2026-08-05 GUI owner attachment 确认补充:登记参数必须保存 GUI manifest 事件接收端的真实 `event_sink_port` 与 `event_sink_token`,不得借用 actionId 等无关字段作为测试替身。每次 attach RPC 只有同时返回 `attached=true` 与 `eventSinkAttached=true` 才能把当前 `bootId` 标记为已登记;`eventSinkAttached` 缺失、为 false 或普通 RPC 失败都保持当前 boot 待重试。sink token 只留在私有进程内登记和 RPC 参数中,不进入日志、错误文本或公共状态。 - 2026-07-10 补充,2026-07-16 由 V1.28 澄清:后台 planning 与预算内 final reply 使用专用最小上下文,只预置 Agent 身份、sessionId、runId、执行模式和工具策略;Agent 私有记忆、项目记忆、黑板、对话、资产、项目索引与文件正文只能经对应工具通过权限 gate 后作为 observation 进入下一轮。只有开发窗口的专业 Agent 前台直调可使用对应角色上下文;正式用户前台现已统一进入 `project-supervisor`。长黑板、记忆和对话按尾部截断,确保最新结论与最新定向消息优先保留。 - 2026-07-10 补充,2026-07-16 由 V1.28 澄清:同一 Agent 的开发前台直调、流式调试和后台任务统一使用 `.agent/runtime/locks/.lock` OS 文件锁。开发前台不再在整个 LLM 请求期间占用项目级写锁;同 Agent 后台任务在开发前台运行时只入队,前台成功或失败后把当前 Agent 锁直接移交给 drain,不重新抢锁,也不允许 drain 启动异常把已经完成的调试结果改判为失败。正式用户 GUI 不通过该入口直聊专业 Agent;不同 Agent 继续并行,真实项目写工具只在副作用执行期间短暂申请项目写锁。 @@ -1458,3 +1461,42 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面 - 运行中的正文和工具按原有唯一回合流实时显示;完成后,除最终回复和失败提示外,中间文本与所有工具调用统一放入默认收起的“执行过程”,允许手动展开,刷新或重新进入仍默认收起。 - 最终回复沿用 Runtime 的最后一个 assistant item 合同,不按文本长度或相似度判断。失败回合不把最后一句过程输出伪装成最终回复。无流历史按同一用户消息边界划分,只保留最后一条 assistant 回复在外;用户消息与失败提示始终保留。 - 验收覆盖已完成回合重进、真实活动回合恢复、跨项目迟到快照、运行到完成自动收起、历史无流、失败、发送时间刷新和旧记录时间缺失。不改变实际工具执行、鉴权、数据库或用户项目内容。 + +## 2026-09-16 AGC 同 AppData 多窗口共享 Agent Runner + +### 目标与非目标 + +- 目标:同一个 AppData 可以同时运行多个 AGC 界面进程,它们共享同一个 Agent Runner、同一份平台登录态权威和同一份项目事实,并且都继续收到 manifest 失效与 Runtime update relay。 +- 非目标:不引入多 Runner、不做跨 AppData 的会话共享、不改变渲染层 generation 语义、不修改平台 HTTP 契约、SpacetimeDB schema 与 `/api/external/v1`。 + +### 参与入口、状态与跨模块边界 + +- 界面进程持有的 OS 锁改为**参与者锁** `agent-runner.gui-participant.lock`:它以共享句柄打开,任意数量的界面进程可同时持有;Runner、watchdog 与 attach 门禁只做“能否独占取得该锁”的探测,独占成功即表示已无界面进程存活。 +- durable claim `agent-runner.gui-owner.claim.json` 仍是唯一的 owner 授权记录,内容仍为 `ownerEpoch + sessionRevision`;claim 不保存 Access Token。 +- 项目级 `.agent/project.lock` 不变:多窗口共享 Runner 不等于共享项目写权,同一项目同一时刻仍只有一个写者。 + +### 正常、失败、重试与幂等行为 + +- 采纳:窗口启动时先读取 durable claim 并以同一 `epoch/revision` attach;claim 缺失或不可读时才发布新 claim。采纳路径不写 claim。 +- 发布:登录、refresh、退出或换号时,窗口写入新 claim(新 epoch + 本窗口 revision)再 attach;同一次发布在多个窗口并发发生时以最后一次成功写入的 claim 为准,落败窗口按最新 claim 重试,重试仍有界失败时向用户暴露错误,不做隐式合并。 +- 幂等:同一 claim 的重复 attach 是空操作,不得清空或替换 Runner 平台登录态;只有 epoch 变化或携带明确登出参数的 attach 才允许替换 / 清空。 +- 失败关闭:attach 携带的 epoch/revision 与 durable claim 不一致、claim 不可读、或 claim 在 attach 提交期间变化时,Runner 继续清空进程内平台会话并阻断除重新 attach 与必要管理请求以外的 Runtime 工作。 +- 事件:manifest 失效与 Runtime update relay 广播到全部已登记接收端(按 `event_sink_token` 去重,发送失败即淘汰该接收端),单个窗口退出或接收端失效不得影响其它窗口。 +- 生命周期:Runner 跟随“是否仍有界面进程存活”,不跟随某一个窗口;全部窗口退出后必须在有界时间内关停并清理 endpoint、释放实例锁。 + +### 契约与兼容 + +- 本机 GUI ↔ Runner 协议方法与参数不变;变化只在参与锁语义、claim 采纳/发布时机与事件接收端注册表。 +- 已退役的“同一 AppData 只允许一个 GUI owner”行为不再保留兼容分支;`startup.runner.owner-lock.failed` 诊断分类改名为参与者锁失败,仅在真正无法建立参与锁时出现。 +- 混用新旧版本二进制访问同一 AppData 不属于支持场景:旧版本仍以独占方式持有旧锁文件,可能让新版本判定为“仍有界面进程存活”。 + +### 验收标准与证据来源 + +- 定向 Rust 测试:参与者锁可多进程同时持有、全部释放后存活判定为假;同 claim 重复 attach 不改写登录态;epoch 变化才强制替换;两个接收端都收到同一事件;claim 采纳与发布的失败关闭路径。 +- 运行时 smoke:同一 AppData 启动两个真实 GUI,两个进程都完成 `startup.setup.complete`,`--agent-runner` 进程只有一个,关闭其中一个后另一个仍可继续使用 Runner。 +- 边界:项目级写锁继续拒绝两个窗口同时写同一项目;新增日志与错误文案不含 Token、Access Token、API Key 与绝对路径。 + +### 未决问题 + +- 两个窗口同时对同一项目发起 Runtime 写请求时,用户体验仍由项目级写锁串行决定;本次不引入跨窗口排队提示。 +- 平台会话在窗口间传播依赖共享 localStorage 与 Runner 权威;渲染层不做跨窗口事件推送,另一个窗口在下一次会话校验或刷新时收敛。 diff --git a/package-lock.json b/package-lock.json index 506617a20..54339f53d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,7 +95,7 @@ }, "apps/ai-game-creator-shell": { "name": "@genarrative/ai-game-creator-shell", - "version": "0.1.45", + "version": "0.1.47", "dependencies": { "@cubone/react-file-manager": "^1.35.0", "@genarrative/image-canvas-core": "0.1.0",