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/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/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index ca97077f1..fcf26b2cb 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..dc26656c2 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(), @@ -2721,6 +2731,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/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/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/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/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/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/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..651000f9c 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); 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/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/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 e861ab2f6..06089fbd6 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -58,9 +58,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 与写入)