diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index f186d150d..8f4f07790 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -36,9 +36,11 @@ pub fn add_provider( state: State<'_, AppState>, app: String, provider: Provider, + #[allow(non_snake_case)] addToLive: Option, ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; - ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string()) + ProviderService::add(state.inner(), app_type, provider, addToLive.unwrap_or(true)) + .map_err(|e| e.to_string()) } #[tauri::command] @@ -46,9 +48,11 @@ pub fn update_provider( state: State<'_, AppState>, app: String, provider: Provider, + #[allow(non_snake_case)] originalId: Option, ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; - ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string()) + ProviderService::update(state.inner(), app_type, originalId.as_deref(), provider) + .map_err(|e| e.to_string()) } #[tauri::command] diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index cdb0f3e39..d94189481 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -110,7 +110,7 @@ pub fn import_provider_from_deeplink( let provider_id = provider.id.clone(); // Use ProviderService to add the provider - ProviderService::add(state, app_type.clone(), provider)?; + ProviderService::add(state, app_type.clone(), provider, true)?; // Add extra endpoints as custom endpoints (skip first one as it's the primary) for ep in all_endpoints.iter().skip(1) { diff --git a/src-tauri/src/openclaw_config.rs b/src-tauri/src/openclaw_config.rs index 24ef7999c..e8a801325 100644 --- a/src-tauri/src/openclaw_config.rs +++ b/src-tauri/src/openclaw_config.rs @@ -8,7 +8,6 @@ use crate::error::AppError; use crate::settings::{effective_backup_retain_count, get_openclaw_override_dir}; use chrono::Local; use indexmap::IndexMap; -use json_five::parser::{FormatConfiguration, TrailingComma}; use json_five::rt::parser::{ from_str as rt_from_str, JSONKeyValuePair as RtJSONKeyValuePair, JSONObjectContext as RtJSONObjectContext, JSONText as RtJSONText, JSONValue as RtJSONValue, @@ -490,11 +489,11 @@ fn derive_entry_separator(leading_ws: &str) -> String { } fn value_to_rt_value(value: &Value, parent_indent: &str) -> Result { - let source = json_five::to_string_formatted( - value, - FormatConfiguration::with_indent(2, TrailingComma::NONE), - ) - .map_err(|e| AppError::Config(format!("Failed to serialize JSON5 section: {e}")))?; + // `json-five` 0.3.1 can panic when pretty-printing nested empty maps/arrays. + // Serialize with `serde_json` instead; the resulting JSON is valid JSON5 and + // can still be parsed back into the round-trip AST we use for insertion. + let source = serde_json::to_string_pretty(value) + .map_err(|e| AppError::Config(format!("Failed to serialize JSON section: {e}")))?; let adjusted = reindent_json5_block(&source, parent_indent); let text = rt_from_str(&adjusted).map_err(|e| { @@ -1051,4 +1050,37 @@ mod tests { assert!(err.to_string().contains("OpenClaw config changed on disk")); }); } + + #[test] + fn remove_last_provider_writes_empty_providers_without_panic() { + let source = r#"{ + models: { + mode: 'merge', + providers: { + '1-copy': { + api: 'anthropic-messages', + }, + }, + }, +} +"#; + + with_test_paths(source, |_| { + let outcome = remove_provider("1-copy").unwrap(); + assert!(outcome.backup_path.is_some()); + + let config = read_openclaw_config().unwrap(); + let providers = config + .get("models") + .and_then(|models| models.get("providers")) + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + assert!(providers.is_empty()); + + let written = fs::read_to_string(get_openclaw_config_path()).unwrap(); + assert!(written.contains("\"providers\": {}")); + }); + } } diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 39ece0ed8..9b624a214 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -283,6 +283,10 @@ pub struct ProviderMeta { /// If not set, provider ID is used automatically during format conversion. #[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")] pub prompt_cache_key: Option, + /// 累加模式应用中,该 provider 是否已写入 live config。 + /// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。 + #[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")] + pub live_config_managed: Option, /// 供应商类型标识(用于特殊供应商检测) /// - "github_copilot": GitHub Copilot 供应商 #[serde(rename = "providerType", skip_serializing_if = "Option::is_none")] diff --git a/src-tauri/src/services/omo.rs b/src-tauri/src/services/omo.rs index 9e534cee4..a91a35a5a 100644 --- a/src-tauri/src/services/omo.rs +++ b/src-tauri/src/services/omo.rs @@ -1,6 +1,7 @@ -use crate::config::write_json_file; +use crate::config::{atomic_write, write_json_file}; use crate::error::AppError; use crate::opencode_config::get_opencode_dir; +use crate::provider::Provider; use crate::store::AppState; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -133,6 +134,68 @@ impl OmoService { } } + fn profile_data_from_provider(provider: &Provider, v: &OmoVariant) -> OmoProfileData { + let agents = provider.settings_config.get("agents").cloned(); + let categories = if v.has_categories { + provider.settings_config.get("categories").cloned() + } else { + None + }; + let other_fields = provider.settings_config.get("otherFields").cloned(); + (agents, categories, other_fields) + } + + fn snapshot_config_file(path: &Path) -> Result>, AppError> { + if !path.exists() { + return Ok(None); + } + + std::fs::read(path) + .map(Some) + .map_err(|e| AppError::io(path, e)) + } + + fn restore_config_file(path: &Path, snapshot: Option<&[u8]>) -> Result<(), AppError> { + match snapshot { + Some(bytes) => atomic_write(path, bytes), + None => { + if path.exists() { + std::fs::remove_file(path).map_err(|e| AppError::io(path, e))?; + } + Ok(()) + } + } + } + + fn write_profile_config( + v: &OmoVariant, + profile_data: Option<&OmoProfileData>, + ) -> Result<(), AppError> { + let merged = Self::build_config(v, profile_data); + let config_path = Self::config_path(v); + + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + let previous_contents = Self::snapshot_config_file(&config_path)?; + write_json_file(&config_path, &merged)?; + if let Err(err) = crate::opencode_config::add_plugin(v.plugin_name) { + if let Err(rollback_err) = + Self::restore_config_file(&config_path, previous_contents.as_deref()) + { + log::warn!( + "Failed to roll back {} config after plugin sync error: {}", + v.label, + rollback_err + ); + } + return Err(err); + } + log::info!("{} config written to {config_path:?}", v.label); + Ok(()) + } + // ── Public API (variant-parameterized) ───────────────── pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> { @@ -153,28 +216,18 @@ impl OmoService { pub fn write_config_to_file(state: &AppState, v: &OmoVariant) -> Result<(), AppError> { let current_omo = state.db.get_current_omo_provider("opencode", v.category)?; - let profile_data = current_omo.as_ref().map(|p| { - let agents = p.settings_config.get("agents").cloned(); - let categories = if v.has_categories { - p.settings_config.get("categories").cloned() - } else { - None - }; - let other_fields = p.settings_config.get("otherFields").cloned(); - (agents, categories, other_fields) - }); + let profile_data = current_omo + .as_ref() + .map(|provider| Self::profile_data_from_provider(provider, v)); + Self::write_profile_config(v, profile_data.as_ref()) + } - let merged = Self::build_config(v, profile_data.as_ref()); - let config_path = Self::config_path(v); - - if let Some(parent) = config_path.parent() { - std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; - } - - write_json_file(&config_path, &merged)?; - crate::opencode_config::add_plugin(v.plugin_name)?; - log::info!("{} config written to {config_path:?}", v.label); - Ok(()) + pub fn write_provider_config_to_file( + provider: &Provider, + v: &OmoVariant, + ) -> Result<(), AppError> { + let profile_data = Self::profile_data_from_provider(provider, v); + Self::write_profile_config(v, Some(&profile_data)) } fn build_config(v: &OmoVariant, profile_data: Option<&OmoProfileData>) -> Value { diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index b4b3aa0fd..d040f4ea6 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -33,6 +33,19 @@ pub(crate) fn sanitize_claude_settings_for_live(settings: &Value) -> Value { v } +pub(crate) fn provider_exists_in_live_config( + app_type: &AppType, + provider_id: &str, +) -> Result { + match app_type { + AppType::OpenCode => crate::opencode_config::get_providers() + .map(|providers| providers.contains_key(provider_id)), + AppType::OpenClaw => crate::openclaw_config::get_providers() + .map(|providers| providers.contains_key(provider_id)), + _ => Ok(false), + } +} + fn json_is_subset(target: &Value, source: &Value) -> bool { match source { Value::Object(source_map) => { @@ -727,10 +740,10 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re provider.id ); } else { - log::error!( - "OpenCode provider '{}' has invalid config structure, skipping write", + return Err(AppError::Message(format!( + "OpenCode provider '{}' has invalid config structure for live config (must contain 'npm' or 'options')", provider.id - ); + ))); } } } @@ -769,10 +782,10 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re provider.id ); } else { - log::error!( - "OpenClaw provider '{}' has invalid config structure, skipping write", + return Err(AppError::Message(format!( + "OpenClaw provider '{}' has invalid config structure for live config (must contain 'baseUrl', 'api', or 'models')", provider.id - ); + ))); } } } @@ -787,23 +800,30 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re /// Used for OpenCode and other additive mode applications. fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<(), AppError> { let providers = state.db.get_all_providers(app_type.as_str())?; + let mut synced_count = 0usize; for provider in providers.values() { + if provider + .meta + .as_ref() + .and_then(|meta| meta.live_config_managed) + == Some(false) + { + continue; + } + if let Err(e) = write_live_with_common_config(state.db.as_ref(), app_type, provider) { log::warn!( "Failed to sync {:?} provider '{}' to live: {e}", app_type, provider.id ); - // Continue syncing other providers, don't abort + continue; } + synced_count += 1; } - log::info!( - "Synced {} {:?} providers to live config", - providers.len(), - app_type - ); + log::info!("Synced {synced_count} {app_type:?} providers to live config"); Ok(()) } @@ -1207,12 +1227,16 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result Result std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|err| err.into_inner()) + } + + fn with_test_home(test: impl FnOnce(&AppState, &Path) -> T) -> T { + let _guard = test_guard(); + let temp = tempfile::tempdir().expect("tempdir"); + let old_test_home = std::env::var_os("CC_SWITCH_TEST_HOME"); + let old_home = std::env::var_os("HOME"); + std::env::set_var("CC_SWITCH_TEST_HOME", temp.path()); + std::env::set_var("HOME", temp.path()); + + let db = Arc::new(Database::memory().expect("in-memory database")); + let state = AppState::new(db); + let result = test(&state, temp.path()); + + match old_test_home { + Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value), + None => std::env::remove_var("CC_SWITCH_TEST_HOME"), + } + match old_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + + result + } + + fn openclaw_provider(id: &str) -> Provider { + Provider { + id: id.to_string(), + name: format!("Provider {id}"), + settings_config: json!({ + "baseUrl": "https://api.deepseek.com", + "apiKey": "test-key", + "api": "openai-completions", + "models": [], + }), + website_url: None, + category: Some("custom".to_string()), + created_at: Some(1), + sort_index: Some(0), + notes: None, + meta: None, + icon: None, + icon_color: None, + in_failover_queue: false, + } + } + + fn opencode_provider(id: &str) -> Provider { + Provider { + id: id.to_string(), + name: format!("Provider {id}"), + settings_config: json!({ + "npm": "@ai-sdk/openai-compatible", + "name": format!("Provider {id}"), + "options": { + "baseURL": "https://api.example.com/v1", + "apiKey": "test-key" + }, + "models": { + "gpt-4o": { + "name": "GPT-4o" + } + } + }), + website_url: None, + category: Some("custom".to_string()), + created_at: Some(1), + sort_index: Some(0), + notes: None, + meta: None, + icon: None, + icon_color: None, + in_failover_queue: false, + } + } + + fn opencode_omo_provider(id: &str, category: &str) -> Provider { + let mut settings = serde_json::Map::new(); + settings.insert( + "agents".to_string(), + json!({ + "writer": { + "model": "gpt-4o-mini" + } + }), + ); + if category == "omo" { + settings.insert( + "categories".to_string(), + json!({ + "default": ["writer"] + }), + ); + } + settings.insert( + "otherFields".to_string(), + json!({ + "theme": "dark" + }), + ); + + Provider { + id: id.to_string(), + name: format!("Provider {id}"), + settings_config: Value::Object(settings), + website_url: None, + category: Some(category.to_string()), + created_at: Some(1), + sort_index: Some(0), + notes: None, + meta: None, + icon: None, + icon_color: None, + in_failover_queue: false, + } + } + + fn omo_config_path(home: &Path, category: &str) -> PathBuf { + home.join(".config").join("opencode").join(match category { + "omo" => crate::services::omo::STANDARD.preferred_filename, + "omo-slim" => crate::services::omo::SLIM.preferred_filename, + other => panic!("unexpected OMO category in test: {other}"), + }) + } #[test] fn validate_provider_settings_rejects_missing_auth() { @@ -129,6 +266,477 @@ base_url = "http://localhost:8080" "should keep mcp_servers.* base_url" ); } + + #[test] + fn rename_rejects_missing_original_provider() { + with_test_home(|state, _| { + let original = openclaw_provider("deepseek"); + ProviderService::add(state, AppType::OpenClaw, original.clone(), false) + .expect("seed db-only provider"); + + let mut renamed = original.clone(); + renamed.id = "deepseek-copy".to_string(); + + let err = ProviderService::update( + state, + AppType::OpenClaw, + Some("missing-provider"), + renamed, + ) + .expect_err("stale originalId should be rejected"); + + assert!( + err.to_string().contains("Original provider"), + "expected missing original provider error, got {err:?}" + ); + assert!( + state + .db + .get_provider_by_id("deepseek-copy", AppType::OpenClaw.as_str()) + .expect("query renamed provider") + .is_none(), + "rename must not create a new row when originalId is stale" + ); + }); + } + + #[test] + fn db_only_additive_update_survives_live_config_parse_errors() { + with_test_home(|state, home| { + let provider = openclaw_provider("deepseek"); + ProviderService::add(state, AppType::OpenClaw, provider.clone(), false) + .expect("seed db-only provider"); + + let stored = state + .db + .get_provider_by_id("deepseek", AppType::OpenClaw.as_str()) + .expect("query stored provider") + .expect("provider should exist"); + assert_eq!( + stored + .meta + .as_ref() + .and_then(|meta| meta.live_config_managed), + Some(false), + "db-only provider should be marked as not live-managed" + ); + + let openclaw_dir = home.join(".openclaw"); + fs::create_dir_all(&openclaw_dir).expect("create openclaw dir"); + fs::write(openclaw_dir.join("openclaw.json"), "{ invalid json5") + .expect("write malformed config"); + + let mut updated = stored.clone(); + updated.name = "DeepSeek Edited".to_string(); + updated.meta.get_or_insert_with(ProviderMeta::default); + + ProviderService::update(state, AppType::OpenClaw, None, updated) + .expect("db-only update should ignore live parse errors"); + + let saved = state + .db + .get_provider_by_id("deepseek", AppType::OpenClaw.as_str()) + .expect("query updated provider") + .expect("updated provider should exist"); + assert_eq!(saved.name, "DeepSeek Edited"); + }); + } + + #[test] + fn sync_current_provider_for_app_skips_db_only_opencode_provider() { + with_test_home(|state, _| { + let provider = opencode_provider("db-only-opencode"); + ProviderService::add(state, AppType::OpenCode, provider.clone(), false) + .expect("seed db-only opencode provider"); + + ProviderService::sync_current_provider_for_app(state, AppType::OpenCode) + .expect("sync additive opencode providers"); + + let live_providers = crate::opencode_config::get_providers() + .expect("read opencode providers after sync"); + assert!( + !live_providers.contains_key(&provider.id), + "db-only opencode provider should not be written to live during sync" + ); + }); + } + + #[test] + fn sync_current_provider_for_app_skips_db_only_openclaw_provider() { + with_test_home(|state, _| { + let provider = openclaw_provider("db-only-openclaw"); + ProviderService::add(state, AppType::OpenClaw, provider.clone(), false) + .expect("seed db-only openclaw provider"); + + ProviderService::sync_current_provider_for_app(state, AppType::OpenClaw) + .expect("sync additive openclaw providers"); + + let live_providers = crate::openclaw_config::get_providers() + .expect("read openclaw providers after sync"); + assert!( + !live_providers.contains_key(&provider.id), + "db-only openclaw provider should not be written to live during sync" + ); + }); + } + + #[test] + fn sync_current_provider_for_app_preserves_legacy_live_opencode_provider() { + with_test_home(|state, _| { + let provider = opencode_provider("legacy-opencode"); + crate::opencode_config::set_provider(&provider.id, provider.settings_config.clone()) + .expect("seed opencode live provider"); + state + .db + .save_provider(AppType::OpenCode.as_str(), &provider) + .expect("seed legacy opencode provider in db"); + + let mut updated = provider.clone(); + updated.settings_config["options"]["apiKey"] = Value::String("updated-key".to_string()); + state + .db + .save_provider(AppType::OpenCode.as_str(), &updated) + .expect("update legacy opencode provider in db"); + + ProviderService::sync_current_provider_for_app(state, AppType::OpenCode) + .expect("sync legacy opencode provider"); + + let live_providers = + crate::opencode_config::get_providers().expect("read opencode providers"); + assert_eq!( + live_providers + .get(&provider.id) + .and_then(|config| config.get("options")) + .and_then(|options| options.get("apiKey")), + Some(&Value::String("updated-key".to_string())), + "legacy provider that already exists in live should still be synced" + ); + }); + } + + #[test] + fn sync_current_provider_for_app_restores_legacy_opencode_provider_after_live_reset() { + with_test_home(|state, _| { + let provider = opencode_provider("legacy-opencode-reset"); + state + .db + .save_provider(AppType::OpenCode.as_str(), &provider) + .expect("seed legacy opencode provider in db"); + + ProviderService::sync_current_provider_for_app(state, AppType::OpenCode) + .expect("sync legacy opencode provider after reset"); + + let live_providers = + crate::opencode_config::get_providers().expect("read opencode providers"); + assert!( + live_providers.contains_key(&provider.id), + "legacy opencode provider should be restored when live config is reset" + ); + }); + } + + #[test] + fn sync_current_provider_for_app_restores_legacy_openclaw_provider_after_live_reset() { + with_test_home(|state, _| { + let mut provider = openclaw_provider("legacy-openclaw-reset"); + provider.settings_config["models"] = json!([ + { + "id": "claude-sonnet-4", + "name": "Claude Sonnet 4" + } + ]); + state + .db + .save_provider(AppType::OpenClaw.as_str(), &provider) + .expect("seed legacy openclaw provider in db"); + + ProviderService::sync_current_provider_for_app(state, AppType::OpenClaw) + .expect("sync legacy openclaw provider after reset"); + + let live_providers = + crate::openclaw_config::get_providers().expect("read openclaw providers"); + assert!( + live_providers.contains_key(&provider.id), + "legacy openclaw provider should be restored when live config is reset" + ); + }); + } + + #[test] + fn import_opencode_providers_from_live_marks_provider_as_live_managed() { + with_test_home(|state, _| { + let provider = opencode_provider("imported-opencode"); + crate::opencode_config::set_provider(&provider.id, provider.settings_config.clone()) + .expect("seed opencode live provider"); + + let imported = import_opencode_providers_from_live(state) + .expect("import opencode providers from live"); + assert_eq!(imported, 1); + + let saved = state + .db + .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) + .expect("query imported opencode provider") + .expect("imported opencode provider should exist"); + assert_eq!( + saved + .meta + .as_ref() + .and_then(|meta| meta.live_config_managed), + Some(true), + "providers imported from live should be treated as live-managed" + ); + }); + } + + #[test] + fn import_openclaw_providers_from_live_marks_provider_as_live_managed() { + with_test_home(|state, _| { + let mut provider = openclaw_provider("imported-openclaw"); + provider.settings_config["models"] = json!([ + { + "id": "claude-sonnet-4", + "name": "Claude Sonnet 4" + } + ]); + crate::openclaw_config::set_provider(&provider.id, provider.settings_config.clone()) + .expect("seed openclaw live provider"); + + let imported = import_openclaw_providers_from_live(state) + .expect("import openclaw providers from live"); + assert_eq!(imported, 1); + + let saved = state + .db + .get_provider_by_id(&provider.id, AppType::OpenClaw.as_str()) + .expect("query imported openclaw provider") + .expect("imported openclaw provider should exist"); + assert_eq!( + saved + .meta + .as_ref() + .and_then(|meta| meta.live_config_managed), + Some(true), + "providers imported from live should be treated as live-managed" + ); + }); + } + + #[test] + fn legacy_additive_provider_still_errors_on_live_config_parse_failure() { + with_test_home(|state, home| { + let provider = openclaw_provider("legacy-provider"); + state + .db + .save_provider(AppType::OpenClaw.as_str(), &provider) + .expect("seed legacy provider without live_config_managed marker"); + + let openclaw_dir = home.join(".openclaw"); + fs::create_dir_all(&openclaw_dir).expect("create openclaw dir"); + fs::write(openclaw_dir.join("openclaw.json"), "{ invalid json5") + .expect("write malformed config"); + + let mut updated = provider.clone(); + updated.name = "Legacy Edited".to_string(); + + let err = ProviderService::update(state, AppType::OpenClaw, None, updated) + .expect_err("legacy providers should still surface live parse errors"); + assert!( + err.to_string().contains("Failed to parse OpenClaw config"), + "expected parse error, got {err:?}" + ); + }); + } + + #[test] + fn update_persists_non_current_omo_variants_in_database() { + with_test_home(|state, _| { + for category in ["omo", "omo-slim"] { + let provider = opencode_omo_provider(&format!("{category}-provider"), category); + state + .db + .save_provider(AppType::OpenCode.as_str(), &provider) + .unwrap_or_else(|err| panic!("seed {category} provider: {err}")); + + let mut updated = provider.clone(); + updated.name = format!("Updated {category}"); + updated.settings_config["agents"]["writer"]["model"] = + Value::String(format!("{category}-next-model")); + + ProviderService::update(state, AppType::OpenCode, None, updated) + .unwrap_or_else(|err| panic!("update {category} provider: {err}")); + + let saved = state + .db + .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) + .unwrap_or_else(|err| panic!("query updated {category} provider: {err}")) + .unwrap_or_else(|| panic!("{category} provider should exist")); + + assert_eq!(saved.name, format!("Updated {category}")); + assert_eq!( + saved.settings_config["agents"]["writer"]["model"], + Value::String(format!("{category}-next-model")), + "{category} updates should persist in the database" + ); + } + }); + } + + #[test] + fn update_current_omo_variant_rewrites_config_from_saved_provider() { + with_test_home(|state, home| { + for category in ["omo", "omo-slim"] { + let provider = opencode_omo_provider(&format!("{category}-current"), category); + state + .db + .save_provider(AppType::OpenCode.as_str(), &provider) + .unwrap_or_else(|err| panic!("seed current {category} provider: {err}")); + state + .db + .set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, category) + .unwrap_or_else(|err| panic!("set current {category} provider: {err}")); + + let mut updated = provider.clone(); + updated.name = format!("Current {category} updated"); + updated.settings_config["agents"]["writer"]["model"] = + Value::String(format!("{category}-saved-model")); + updated.settings_config["otherFields"]["theme"] = + Value::String(format!("{category}-light")); + + ProviderService::update(state, AppType::OpenCode, None, updated) + .unwrap_or_else(|err| panic!("update current {category} provider: {err}")); + + let saved = state + .db + .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) + .unwrap_or_else(|err| panic!("query current {category} provider: {err}")) + .unwrap_or_else(|| panic!("current {category} provider should exist")); + assert_eq!(saved.name, format!("Current {category} updated")); + + let written = fs::read_to_string(omo_config_path(home, category)) + .unwrap_or_else(|err| panic!("read written {category} config: {err}")); + let written_json: Value = serde_json::from_str(&written) + .unwrap_or_else(|err| panic!("parse written {category} config: {err}")); + + assert_eq!( + written_json["agents"]["writer"]["model"], + Value::String(format!("{category}-saved-model")), + "{category} config should be written from the saved provider state" + ); + assert_eq!( + written_json["theme"], + Value::String(format!("{category}-light")), + "{category} top-level config should reflect updated otherFields" + ); + } + }); + } + + #[test] + fn update_current_omo_variant_does_not_persist_database_when_file_write_fails() { + with_test_home(|state, home| { + let provider = opencode_omo_provider("omo-current", "omo"); + state + .db + .save_provider(AppType::OpenCode.as_str(), &provider) + .unwrap_or_else(|err| panic!("seed current omo provider: {err}")); + state + .db + .set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, "omo") + .unwrap_or_else(|err| panic!("set current omo provider: {err}")); + + let config_dir = home.join(".config").join("opencode"); + fs::create_dir_all(config_dir.parent().expect("config dir parent")) + .expect("create .config dir"); + fs::write(&config_dir, "not a directory").expect("block opencode config dir"); + + let mut updated = provider.clone(); + updated.name = "Current omo updated".to_string(); + updated.settings_config["agents"]["writer"]["model"] = + Value::String("omo-saved-model".to_string()); + + ProviderService::update(state, AppType::OpenCode, None, updated) + .expect_err("update should fail when current omo file write fails"); + + let saved = state + .db + .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) + .unwrap_or_else(|err| panic!("query current omo provider: {err}")) + .unwrap_or_else(|| panic!("current omo provider should exist")); + + assert_eq!(saved.name, provider.name); + assert_eq!( + saved.settings_config["agents"]["writer"]["model"], + provider.settings_config["agents"]["writer"]["model"], + "database should remain unchanged when file write fails" + ); + }); + } + + #[test] + fn update_current_omo_variant_rolls_back_file_when_plugin_sync_fails() { + with_test_home(|state, home| { + let provider = opencode_omo_provider("omo-current", "omo"); + state + .db + .save_provider(AppType::OpenCode.as_str(), &provider) + .unwrap_or_else(|err| panic!("seed current omo provider: {err}")); + state + .db + .set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, "omo") + .unwrap_or_else(|err| panic!("set current omo provider: {err}")); + + let config_path = omo_config_path(home, "omo"); + fs::create_dir_all(config_path.parent().expect("omo config parent")) + .expect("create omo config dir"); + let previous_content = serde_json::to_string_pretty(&json!({ + "theme": "legacy-live-theme", + "agents": { + "writer": { + "model": "legacy-live-model" + } + }, + "categories": { + "default": ["writer"] + } + })) + .expect("serialize previous config"); + fs::write(&config_path, &previous_content).expect("seed previous omo config"); + + let opencode_config_path = home.join(".config").join("opencode").join("opencode.json"); + fs::write(&opencode_config_path, "{ invalid json").expect("seed malformed opencode"); + + let mut updated = provider.clone(); + updated.name = "Current omo updated".to_string(); + updated.settings_config["agents"]["writer"]["model"] = + Value::String("omo-saved-model".to_string()); + updated.settings_config["otherFields"]["theme"] = + Value::String("omo-light".to_string()); + + ProviderService::update(state, AppType::OpenCode, None, updated) + .expect_err("update should fail when plugin sync fails"); + + let saved = state + .db + .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) + .unwrap_or_else(|err| panic!("query current omo provider: {err}")) + .unwrap_or_else(|| panic!("current omo provider should exist")); + + assert_eq!(saved.name, provider.name); + assert_eq!( + saved.settings_config["agents"]["writer"]["model"], + provider.settings_config["agents"]["writer"]["model"], + "database should remain unchanged when plugin sync fails" + ); + + let written = + fs::read_to_string(&config_path).expect("read rolled back omo config content"); + assert_eq!( + written, previous_content, + "OMO config should roll back to its previous on-disk contents" + ); + }); + } } impl ProviderService { @@ -141,6 +749,34 @@ impl ProviderService { } } + /// Check whether a provider exists in live config, tolerating parse errors + /// only for providers that are explicitly marked as DB-only. + fn check_live_config_exists( + app_type: &AppType, + provider_id: &str, + live_config_managed: Option, + ) -> Result { + if live_config_managed == Some(false) { + Ok(provider_exists_in_live_config(app_type, provider_id).unwrap_or(false)) + } else { + provider_exists_in_live_config(app_type, provider_id) + } + } + + fn provider_live_config_managed(provider: &Provider) -> Option { + provider + .meta + .as_ref() + .and_then(|meta| meta.live_config_managed) + } + + fn set_provider_live_config_managed(provider: &mut Provider, managed: bool) { + provider + .meta + .get_or_insert_with(Default::default) + .live_config_managed = Some(managed); + } + /// List all providers for an app type pub fn list( state: &AppState, @@ -166,17 +802,25 @@ impl ProviderService { } /// Add a new provider - pub fn add(state: &AppState, app_type: AppType, provider: Provider) -> Result { + pub fn add( + state: &AppState, + app_type: AppType, + provider: Provider, + add_to_live: bool, + ) -> Result { let mut provider = provider; // Normalize Claude model keys Self::normalize_provider_if_claude(&app_type, &mut provider); Self::validate_provider_settings(&app_type, &provider)?; normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?; + if app_type.is_additive_mode() { + Self::set_provider_live_config_managed(&mut provider, add_to_live); + } // Save to database state.db.save_provider(app_type.as_str(), &provider)?; - // Additive mode apps (OpenCode, OpenClaw) - always write to live config + // Additive mode apps (OpenCode, OpenClaw): optionally write to live config. if app_type.is_additive_mode() { // OMO / OMO Slim providers use exclusive mode and write to dedicated config file. if matches!(app_type, AppType::OpenCode) @@ -186,6 +830,9 @@ impl ProviderService { // Users must explicitly switch/apply an OMO provider to activate it. return Ok(true); } + if !add_to_live { + return Ok(true); + } write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?; return Ok(true); } @@ -207,53 +854,153 @@ impl ProviderService { pub fn update( state: &AppState, app_type: AppType, + original_id: Option<&str>, provider: Provider, ) -> Result { let mut provider = provider; + let original_id = original_id.unwrap_or(provider.id.as_str()).to_string(); + let provider_id_changed = original_id != provider.id; + let existing_provider = state + .db + .get_provider_by_id(&original_id, app_type.as_str())?; // Normalize Claude model keys Self::normalize_provider_if_claude(&app_type, &mut provider); Self::validate_provider_settings(&app_type, &provider)?; normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?; - // Save to database - state.db.save_provider(app_type.as_str(), &provider)?; - - // Additive mode apps (OpenCode, OpenClaw) - always update in live config - if app_type.is_additive_mode() { - if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo") - { - let is_omo_current = - state - .db - .is_omo_provider_current(app_type.as_str(), &provider.id, "omo")?; - if is_omo_current { - crate::services::OmoService::write_config_to_file( - state, - &crate::services::omo::STANDARD, - )?; - } - return Ok(true); + if provider_id_changed { + if !app_type.is_additive_mode() { + return Err(AppError::Message( + "Only additive-mode providers support changing provider key".to_string(), + )); } + + let Some(existing_provider) = existing_provider else { + return Err(AppError::Message(format!( + "Original provider '{}' does not exist in app '{}'", + original_id, + app_type.as_str() + ))); + }; + + // OMO / OMO Slim providers are activated via a dedicated current-state mechanism + // (set_omo_provider_current) that is NOT captured by provider_exists_in_live_config, + // which only checks opencode.json. A rename would orphan that current-state marker + // and silently break subsequent OMO file syncs. Block it unconditionally. if matches!(app_type, AppType::OpenCode) - && provider.category.as_deref() == Some("omo-slim") + && matches!( + existing_provider.category.as_deref(), + Some("omo") | Some("omo-slim") + ) { + return Err(AppError::Message( + "Provider key cannot be changed for OMO/OMO Slim providers".to_string(), + )); + } + + let original_in_live = Self::check_live_config_exists( + &app_type, + &original_id, + Self::provider_live_config_managed(&existing_provider), + )?; + if original_in_live { + return Err(AppError::Message( + "Provider key cannot be changed after the provider has been added to the app config" + .to_string(), + )); + } + + let next_id_in_live = Self::check_live_config_exists( + &app_type, + &provider.id, + Self::provider_live_config_managed(&existing_provider), + )?; + if state + .db + .get_provider_by_id(&provider.id, app_type.as_str())? + .is_some() + || next_id_in_live + { + return Err(AppError::Message(format!( + "Provider '{}' already exists in app '{}'", + provider.id, + app_type.as_str() + ))); + } + + Self::set_provider_live_config_managed(&mut provider, false); + state.db.save_provider(app_type.as_str(), &provider)?; + state.db.delete_provider(app_type.as_str(), &original_id)?; + + if crate::settings::get_current_provider(&app_type).as_deref() == Some(&original_id) { + crate::settings::set_current_provider(&app_type, Some(provider.id.as_str()))?; + } + + return Ok(true); + } + + // Additive mode apps (OpenCode, OpenClaw): only sync to live when the provider + // already exists in live config. Editing a DB-only provider must not auto-add it. + if app_type.is_additive_mode() { + let omo_variant = if matches!(app_type, AppType::OpenCode) { + match provider.category.as_deref() { + Some("omo") => Some(&crate::services::omo::STANDARD), + Some("omo-slim") => Some(&crate::services::omo::SLIM), + _ => None, + } + } else { + None + }; + if let Some(variant) = omo_variant { let is_current = state.db.is_omo_provider_current( app_type.as_str(), &provider.id, - "omo-slim", + variant.category, )?; if is_current { - crate::services::OmoService::write_config_to_file( - state, - &crate::services::omo::SLIM, - )?; + crate::services::OmoService::write_provider_config_to_file(&provider, variant)?; } + if let Err(err) = state.db.save_provider(app_type.as_str(), &provider) { + if is_current { + if let Err(rollback_err) = + crate::services::OmoService::write_config_to_file(state, variant) + { + log::warn!( + "Failed to roll back {} config after DB save error: {}", + variant.label, + rollback_err + ); + } + } + return Err(err); + } + return Ok(true); + } + let live_config_managed = Self::check_live_config_exists( + &app_type, + &provider.id, + Self::provider_live_config_managed(&provider).or_else(|| { + existing_provider + .as_ref() + .and_then(Self::provider_live_config_managed) + }), + )?; + Self::set_provider_live_config_managed(&mut provider, live_config_managed); + + // Save to database after live-config presence is resolved so parse errors + // do not report failure after already mutating DB state. + state.db.save_provider(app_type.as_str(), &provider)?; + + if !live_config_managed { return Ok(true); } write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?; return Ok(true); } + // Save to database + state.db.save_provider(app_type.as_str(), &provider)?; + // For other apps: Check if this is current provider (use effective current, not just DB) let effective_current = crate::settings::get_effective_current_provider(&state.db, &app_type)?; @@ -295,50 +1042,48 @@ impl ProviderService { pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { // Additive mode apps - no current provider concept if app_type.is_additive_mode() { + // Single DB read shared across all additive-mode sub-paths below. + let existing = state.db.get_provider_by_id(id, app_type.as_str())?; + if matches!(app_type, AppType::OpenCode) { - let provider_category = state - .db - .get_provider_by_id(id, app_type.as_str())? - .and_then(|p| p.category); - - if provider_category.as_deref() == Some("omo") { - let was_current = - state - .db - .is_omo_provider_current(app_type.as_str(), id, "omo")?; - + let provider_category = existing.as_ref().and_then(|p| p.category.clone()); + let omo_variant = match provider_category.as_deref() { + Some("omo") => Some(&crate::services::omo::STANDARD), + Some("omo-slim") => Some(&crate::services::omo::SLIM), + _ => None, + }; + if let Some(variant) = omo_variant { + let was_current = state.db.is_omo_provider_current( + app_type.as_str(), + id, + variant.category, + )?; state.db.delete_provider(app_type.as_str(), id)?; if was_current { - crate::services::OmoService::delete_config_file( - &crate::services::omo::STANDARD, - )?; - } - return Ok(()); - } - - if provider_category.as_deref() == Some("omo-slim") { - let was_current = - state - .db - .is_omo_provider_current(app_type.as_str(), id, "omo-slim")?; - - state.db.delete_provider(app_type.as_str(), id)?; - if was_current { - crate::services::OmoService::delete_config_file( - &crate::services::omo::SLIM, - )?; + crate::services::OmoService::delete_config_file(variant)?; } return Ok(()); } } - // Remove from database + + // Non-OMO path for both OpenCode and OpenClaw: + // remove from live first (atomicity), then DB. + // + // Use check_live_config_exists rather than trusting the flag alone: the flag + // can be stale (Some(false) for a provider that was written to live before the + // live_config_managed flip was introduced). check_live_config_exists reads the + // actual file when the flag is Some(false), so it handles historical data correctly. + let live_managed = existing + .as_ref() + .and_then(Self::provider_live_config_managed); + if Self::check_live_config_exists(&app_type, id, live_managed)? { + match app_type { + AppType::OpenCode => remove_opencode_provider_from_live(id)?, + AppType::OpenClaw => remove_openclaw_provider_from_live(id)?, + _ => {} + } + } state.db.delete_provider(app_type.as_str(), id)?; - // Also remove from live config - match app_type { - AppType::OpenCode => remove_opencode_provider_from_live(id)?, - AppType::OpenClaw => remove_openclaw_provider_from_live(id)?, - _ => {} // Should not reach here - } return Ok(()); } @@ -372,41 +1117,23 @@ impl ProviderService { .get_provider_by_id(id, app_type.as_str())? .and_then(|p| p.category); - if provider_category.as_deref() == Some("omo") { + let omo_variant = match provider_category.as_deref() { + Some("omo") => Some(&crate::services::omo::STANDARD), + Some("omo-slim") => Some(&crate::services::omo::SLIM), + _ => None, + }; + if let Some(variant) = omo_variant { state .db - .clear_omo_provider_current(app_type.as_str(), id, "omo")?; + .clear_omo_provider_current(app_type.as_str(), id, variant.category)?; let still_has_current = state .db - .get_current_omo_provider("opencode", "omo")? + .get_current_omo_provider("opencode", variant.category)? .is_some(); if still_has_current { - crate::services::OmoService::write_config_to_file( - state, - &crate::services::omo::STANDARD, - )?; + crate::services::OmoService::write_config_to_file(state, variant)?; } else { - crate::services::OmoService::delete_config_file( - &crate::services::omo::STANDARD, - )?; - } - } else if provider_category.as_deref() == Some("omo-slim") { - state - .db - .clear_omo_provider_current(app_type.as_str(), id, "omo-slim")?; - let still_has_current = state - .db - .get_current_omo_provider("opencode", "omo-slim")? - .is_some(); - if still_has_current { - crate::services::OmoService::write_config_to_file( - state, - &crate::services::omo::SLIM, - )?; - } else { - crate::services::OmoService::delete_config_file( - &crate::services::omo::SLIM, - )?; + crate::services::OmoService::delete_config_file(variant)?; } } else { remove_opencode_provider_from_live(id)?; @@ -422,6 +1149,12 @@ impl ProviderService { ))); } } + + if let Some(mut provider) = state.db.get_provider_by_id(id, app_type.as_str())? { + Self::set_provider_live_config_managed(&mut provider, false); + state.db.save_provider(app_type.as_str(), &provider)?; + } + Ok(()) } @@ -507,29 +1240,23 @@ impl ProviderService { .get(id) .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?; - if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo") { - state - .db - .set_omo_provider_current(app_type.as_str(), id, "omo")?; - crate::services::OmoService::write_config_to_file( - state, - &crate::services::omo::STANDARD, - )?; - // OMO ↔ OMO Slim mutually exclusive: remove Slim config - let _ = crate::services::OmoService::delete_config_file(&crate::services::omo::SLIM); - return Ok(SwitchResult::default()); - } - - if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo-slim") - { - state - .db - .set_omo_provider_current(app_type.as_str(), id, "omo-slim")?; - crate::services::OmoService::write_config_to_file(state, &crate::services::omo::SLIM)?; - // OMO ↔ OMO Slim mutually exclusive: remove Standard config - let _ = - crate::services::OmoService::delete_config_file(&crate::services::omo::STANDARD); - return Ok(SwitchResult::default()); + // OMO ↔ OMO Slim are mutually exclusive; activating one removes the other's config file. + if matches!(app_type, AppType::OpenCode) { + let omo_pair = match provider.category.as_deref() { + Some("omo") => Some((&crate::services::omo::STANDARD, &crate::services::omo::SLIM)), + Some("omo-slim") => { + Some((&crate::services::omo::SLIM, &crate::services::omo::STANDARD)) + } + _ => None, + }; + if let Some((enable, disable)) = omo_pair { + state + .db + .set_omo_provider_current(app_type.as_str(), id, enable.category)?; + crate::services::OmoService::write_config_to_file(state, enable)?; + let _ = crate::services::OmoService::delete_config_file(disable); + return Ok(SwitchResult::default()); + } } let mut result = SwitchResult::default(); @@ -579,6 +1306,40 @@ impl ProviderService { // Sync to live (write_gemini_live handles security flag internally for Gemini) write_live_with_common_config(state.db.as_ref(), &app_type, provider)?; + // For additive-mode providers that were DB-only (live_config_managed == Some(false)), + // flip the flag to true now that the provider has been successfully written to the live + // file. This ensures sync_all_providers_to_live() will include it on future syncs. + // + // If persisting the marker fails, roll back the just-written live config so we don't leave + // the provider in a silent inconsistent state (present in live, but still marked DB-only). + if app_type.is_additive_mode() && Self::provider_live_config_managed(provider) != Some(true) + { + let mut updated = provider.clone(); + Self::set_provider_live_config_managed(&mut updated, true); + if let Err(e) = state.db.save_provider(app_type.as_str(), &updated) { + let rollback_result = match app_type { + AppType::OpenCode => remove_opencode_provider_from_live(&provider.id), + AppType::OpenClaw => remove_openclaw_provider_from_live(&provider.id), + _ => Ok(()), + }; + + match rollback_result { + Ok(()) => { + return Err(AppError::Message(format!( + "Failed to persist live_config_managed for '{}' after writing live config; live changes were rolled back: {e}", + provider.id + ))); + } + Err(rollback_err) => { + return Err(AppError::Message(format!( + "Failed to persist live_config_managed for '{}' after writing live config: {e}; additionally failed to roll back live config: {rollback_err}", + provider.id + ))); + } + } + } + } + // Sync MCP McpService::sync_all_enabled(state)?; diff --git a/src/App.tsx b/src/App.tsx index 02b7a5b4d..0665c1889 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -533,8 +533,14 @@ function App() { } }; - const handleEditProvider = async (provider: Provider) => { - await updateProvider(provider); + const handleEditProvider = async ({ + provider, + originalId, + }: { + provider: Provider; + originalId?: string; + }) => { + await updateProvider(provider, originalId); setEditingProvider(null); }; @@ -571,7 +577,7 @@ function App() { setConfirmAction(null); }; - const generateUniqueOpencodeKey = ( + const generateUniqueProviderCopyKey = ( originalKey: string, existingKeys: string[], ): string => { @@ -594,6 +600,7 @@ function App() { const duplicatedProvider: Omit & { providerKey?: string; + addToLive?: boolean; } = { name: `${provider.name} copy`, settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝 @@ -607,12 +614,40 @@ function App() { iconColor: provider.iconColor, }; - if (activeApp === "opencode") { - const existingKeys = Object.keys(providers); - duplicatedProvider.providerKey = generateUniqueOpencodeKey( + if (activeApp === "opencode" || activeApp === "openclaw") { + let liveProviderIds: string[] = []; + try { + liveProviderIds = + activeApp === "opencode" + ? await queryClient.ensureQueryData({ + queryKey: ["opencodeLiveProviderIds"], + queryFn: () => providersApi.getOpenCodeLiveProviderIds(), + }) + : await queryClient.ensureQueryData({ + queryKey: openclawKeys.liveProviderIds, + queryFn: () => providersApi.getOpenClawLiveProviderIds(), + }); + } catch (error) { + console.error( + "[App] Failed to load live provider IDs for duplication", + error, + ); + const errorMessage = extractErrorMessage(error); + toast.error( + t("provider.duplicateLiveIdsLoadFailed", { + defaultValue: "读取配置中的供应商标识失败,请先修复配置后再试", + }) + (errorMessage ? `: ${errorMessage}` : ""), + ); + return; + } + const existingKeys = Array.from( + new Set([...Object.keys(providers), ...liveProviderIds]), + ); + duplicatedProvider.providerKey = generateUniqueProviderCopyKey( provider.id, existingKeys, ); + duplicatedProvider.addToLive = false; } if (provider.sortIndex !== undefined) { diff --git a/src/components/providers/EditProviderDialog.tsx b/src/components/providers/EditProviderDialog.tsx index 072fdb64d..9b2d5bbbe 100644 --- a/src/components/providers/EditProviderDialog.tsx +++ b/src/components/providers/EditProviderDialog.tsx @@ -14,7 +14,10 @@ interface EditProviderDialogProps { open: boolean; provider: Provider | null; onOpenChange: (open: boolean) => void; - onSubmit: (provider: Provider) => Promise | void; + onSubmit: (payload: { + provider: Provider; + originalId?: string; + }) => Promise | void; appId: AppId; isProxyTakeover?: boolean; // 代理接管模式下不读取 live(避免显示被接管后的代理配置) } @@ -165,9 +168,15 @@ export function EditProviderDialog({ string, unknown >; + const nextProviderId = + (appId === "opencode" || appId === "openclaw") && + values.providerKey?.trim() + ? values.providerKey.trim() + : provider.id; const updatedProvider: Provider = { ...provider, + id: nextProviderId, name: values.name.trim(), notes: values.notes?.trim() || undefined, websiteUrl: values.websiteUrl?.trim() || undefined, @@ -179,10 +188,13 @@ export function EditProviderDialog({ ...(values.meta ? { meta: values.meta } : {}), }; - await onSubmit(updatedProvider); + await onSubmit({ + provider: updatedProvider, + originalId: provider.id, + }); onOpenChange(false); }, - [onSubmit, onOpenChange, provider], + [appId, onSubmit, onOpenChange, provider], ); if (!provider || !initialData) { diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index cead4fc5f..3c8a03b29 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -1,13 +1,14 @@ import { useEffect, useMemo, useState, useCallback } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider"; -import type { AppId } from "@/lib/api"; +import { providersApi, type AppId } from "@/lib/api"; import type { ProviderCategory, ProviderMeta, @@ -91,6 +92,7 @@ import { normalizePricingSource, } from "./helpers/opencodeFormUtils"; import { resolveManagedAccountId } from "@/lib/authBinding"; +import { useOpenClawLiveProviderIds } from "@/hooks/useOpenClaw"; type PresetEntry = { id: string; @@ -577,6 +579,15 @@ export function ProviderForm({ existingOpencodeKeys, } = useOmoModelSource({ isOmoCategory: isAnyOmoCategory, providerId }); + const { + data: opencodeLiveProviderIds = [], + isLoading: isOpencodeLiveProviderIdsLoading, + } = useQuery({ + queryKey: ["opencodeLiveProviderIds"], + queryFn: () => providersApi.getOpenCodeLiveProviderIds(), + enabled: appId === "opencode" && !isAnyOmoCategory, + }); + const opencodeForm = useOpencodeFormState({ initialData, appId, @@ -605,6 +616,78 @@ export function ProviderForm({ onSettingsConfigChange: (config) => form.setValue("settingsConfig", config), getSettingsConfig: () => form.getValues("settingsConfig"), }); + const { + data: openclawLiveProviderIds = [], + isLoading: isOpenclawLiveProviderIdsLoading, + } = useOpenClawLiveProviderIds(appId === "openclaw"); + + const additiveExistingProviderKeys = useMemo(() => { + if (appId === "opencode" && !isAnyOmoCategory) { + return Array.from( + new Set( + [...existingOpencodeKeys, ...opencodeLiveProviderIds].filter( + (key) => key !== providerId, + ), + ), + ); + } + + if (appId === "openclaw") { + return Array.from( + new Set( + [ + ...openclawForm.existingOpenclawKeys, + ...openclawLiveProviderIds, + ].filter((key) => key !== providerId), + ), + ); + } + + return []; + }, [ + appId, + existingOpencodeKeys, + isAnyOmoCategory, + openclawForm.existingOpenclawKeys, + openclawLiveProviderIds, + opencodeLiveProviderIds, + providerId, + ]); + + const isProviderKeyLockStateLoading = useMemo(() => { + if (!isEditMode) return false; + if (appId === "opencode" && !isAnyOmoCategory) { + return isOpencodeLiveProviderIdsLoading; + } + if (appId === "openclaw") { + return isOpenclawLiveProviderIdsLoading; + } + return false; + }, [ + appId, + isAnyOmoCategory, + isEditMode, + isOpenclawLiveProviderIdsLoading, + isOpencodeLiveProviderIdsLoading, + ]); + + const isProviderKeyLocked = useMemo(() => { + if (!isEditMode || !providerId) return false; + if (appId === "opencode" && !isAnyOmoCategory) { + return opencodeLiveProviderIds.includes(providerId); + } + if (appId === "openclaw") { + return openclawLiveProviderIds.includes(providerId); + } + return false; + }, [ + appId, + isAnyOmoCategory, + isEditMode, + openclawLiveProviderIds, + opencodeLiveProviderIds, + providerId, + ]); const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false); @@ -641,9 +724,17 @@ export function ProviderForm({ toast.error(t("opencode.providerKeyInvalid")); return; } + if (isProviderKeyLockStateLoading) { + toast.error( + t("providerForm.providerKeyStatusLoading", { + defaultValue: "正在加载供应商标识状态,请稍后再试", + }), + ); + return; + } if ( - !isEditMode && - existingOpencodeKeys.includes(opencodeForm.opencodeProviderKey) + !isProviderKeyLocked && + additiveExistingProviderKeys.includes(opencodeForm.opencodeProviderKey) ) { toast.error(t("opencode.providerKeyDuplicate")); return; @@ -665,11 +756,17 @@ export function ProviderForm({ toast.error(t("openclaw.providerKeyInvalid")); return; } + if (isProviderKeyLockStateLoading) { + toast.error( + t("providerForm.providerKeyStatusLoading", { + defaultValue: "正在加载供应商标识状态,请稍后再试", + }), + ); + return; + } if ( - !isEditMode && - openclawForm.existingOpenclawKeys.includes( - openclawForm.openclawProviderKey, - ) + !isProviderKeyLocked && + additiveExistingProviderKeys.includes(openclawForm.openclawProviderKey) ) { toast.error(t("openclaw.providerKeyDuplicate")); return; @@ -1253,12 +1350,14 @@ export function ProviderForm({ ) } placeholder={t("opencode.providerKeyPlaceholder")} - disabled={isEditMode} + disabled={ + isProviderKeyLocked || isProviderKeyLockStateLoading + } className={ - (existingOpencodeKeys.includes( + (additiveExistingProviderKeys.includes( opencodeForm.opencodeProviderKey, ) && - !isEditMode) || + !isProviderKeyLocked) || (opencodeForm.opencodeProviderKey.trim() !== "" && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( opencodeForm.opencodeProviderKey, @@ -1267,10 +1366,10 @@ export function ProviderForm({ : "" } /> - {existingOpencodeKeys.includes( + {additiveExistingProviderKeys.includes( opencodeForm.opencodeProviderKey, ) && - !isEditMode && ( + !isProviderKeyLocked && (

{t("opencode.providerKeyDuplicate")}

@@ -1284,16 +1383,21 @@ export function ProviderForm({

)} {!( - existingOpencodeKeys.includes( + additiveExistingProviderKeys.includes( opencodeForm.opencodeProviderKey, - ) && !isEditMode + ) && !isProviderKeyLocked ) && (opencodeForm.opencodeProviderKey.trim() === "" || /^[a-z0-9]+(-[a-z0-9]+)*$/.test( opencodeForm.opencodeProviderKey, )) && (

- {t("opencode.providerKeyHint")} + {isProviderKeyLocked + ? t("opencode.providerKeyLockedHint", { + defaultValue: + "该供应商已添加到应用配置中,供应商标识不可修改", + }) + : t("opencode.providerKeyHint")}

)} @@ -1312,12 +1416,14 @@ export function ProviderForm({ ) } placeholder={t("openclaw.providerKeyPlaceholder")} - disabled={isEditMode} + disabled={ + isProviderKeyLocked || isProviderKeyLockStateLoading + } className={ - (openclawForm.existingOpenclawKeys.includes( + (additiveExistingProviderKeys.includes( openclawForm.openclawProviderKey, ) && - !isEditMode) || + !isProviderKeyLocked) || (openclawForm.openclawProviderKey.trim() !== "" && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( openclawForm.openclawProviderKey, @@ -1326,10 +1432,10 @@ export function ProviderForm({ : "" } /> - {openclawForm.existingOpenclawKeys.includes( + {additiveExistingProviderKeys.includes( openclawForm.openclawProviderKey, ) && - !isEditMode && ( + !isProviderKeyLocked && (

{t("openclaw.providerKeyDuplicate")}

@@ -1343,16 +1449,21 @@ export function ProviderForm({

)} {!( - openclawForm.existingOpenclawKeys.includes( + additiveExistingProviderKeys.includes( openclawForm.openclawProviderKey, - ) && !isEditMode + ) && !isProviderKeyLocked ) && (openclawForm.openclawProviderKey.trim() === "" || /^[a-z0-9]+(-[a-z0-9]+)*$/.test( openclawForm.openclawProviderKey, )) && (

- {t("openclaw.providerKeyHint")} + {isProviderKeyLocked + ? t("openclaw.providerKeyLockedHint", { + defaultValue: + "该供应商已添加到应用配置中,供应商标识不可修改", + }) + : t("openclaw.providerKeyHint")}

)} diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 344616843..a694e9484 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -65,6 +65,7 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { provider: Omit & { providerKey?: string; suggestedDefaults?: OpenClawSuggestedDefaults; + addToLive?: boolean; }, ) => { await addProviderMutation.mutateAsync(provider); @@ -120,8 +121,8 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { // 更新供应商 const updateProvider = useCallback( - async (provider: Provider) => { - await updateProviderMutation.mutateAsync(provider); + async (provider: Provider, originalId?: string) => { + await updateProviderMutation.mutateAsync({ provider, originalId }); // 更新托盘菜单(失败不影响主操作) try { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 70b7f85f5..228b474e3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -933,7 +933,8 @@ "modelsRequired": "Please add at least one model", "providerKey": "Provider Key", "providerKeyPlaceholder": "my-provider", - "providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.", + "providerKeyHint": "Unique identifier in config file. Use lowercase letters, numbers, and hyphens only.", + "providerKeyLockedHint": "This provider has already been added to the app config, so its key can no longer be changed.", "providerKeyRequired": "Provider key is required", "providerKeyDuplicate": "This key is already in use", "providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.", @@ -1392,7 +1393,8 @@ "backupCreated": "Backup created: {{path}}", "providerKey": "Provider Key", "providerKeyPlaceholder": "my-provider", - "providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.", + "providerKeyHint": "Unique identifier in config file. Use lowercase letters, numbers, and hyphens only.", + "providerKeyLockedHint": "This provider has already been added to the app config, so its key can no longer be changed.", "providerKeyRequired": "Provider key is required", "providerKeyDuplicate": "This key is already in use", "providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 82fed222f..bf3189a0c 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -933,7 +933,8 @@ "modelsRequired": "モデルを少なくとも1つ追加してください", "providerKey": "プロバイダーキー", "providerKeyPlaceholder": "my-provider", - "providerKeyHint": "設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。", + "providerKeyHint": "設定ファイルの一意の識別子です。小文字、数字、ハイフンのみ使用できます。", + "providerKeyLockedHint": "このプロバイダーは既にアプリ設定へ追加されているため、キーは変更できません。", "providerKeyRequired": "プロバイダーキーを入力してください", "providerKeyDuplicate": "このキーは既に使用されています", "providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。", @@ -1392,7 +1393,8 @@ "backupCreated": "バックアップを作成しました: {{path}}", "providerKey": "プロバイダーキー", "providerKeyPlaceholder": "my-provider", - "providerKeyHint": "設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。", + "providerKeyHint": "設定ファイル内のユニーク識別子。小文字、数字、ハイフンのみ使用可能。", + "providerKeyLockedHint": "このプロバイダーは既にアプリ設定へ追加されているため、キーは変更できません。", "providerKeyRequired": "プロバイダーキーを入力してください", "providerKeyDuplicate": "このキーは既に使用されています", "providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 8c435e5fe..88b6ce9ad 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -933,7 +933,8 @@ "modelsRequired": "请至少添加一个模型配置", "providerKey": "供应商标识", "providerKeyPlaceholder": "my-provider", - "providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符", + "providerKeyHint": "配置文件中的唯一标识符,只能使用小写字母、数字和连字符", + "providerKeyLockedHint": "该供应商已添加到应用配置中,供应商标识不可修改", "providerKeyRequired": "请填写供应商标识", "providerKeyDuplicate": "此标识已被使用,请更换", "providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符", @@ -1392,7 +1393,8 @@ "backupCreated": "已创建备份:{{path}}", "providerKey": "供应商标识", "providerKeyPlaceholder": "my-provider", - "providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符", + "providerKeyHint": "配置文件中的唯一标识符,只能使用小写字母、数字和连字符", + "providerKeyLockedHint": "该供应商已添加到应用配置中,供应商标识不可修改", "providerKeyRequired": "请填写供应商标识", "providerKeyDuplicate": "此标识已被使用,请更换", "providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符", diff --git a/src/lib/api/providers.ts b/src/lib/api/providers.ts index 551b05951..89b6b7c7f 100644 --- a/src/lib/api/providers.ts +++ b/src/lib/api/providers.ts @@ -34,12 +34,24 @@ export const providersApi = { return await invoke("get_current_provider", { app: appId }); }, - async add(provider: Provider, appId: AppId): Promise { - return await invoke("add_provider", { provider, app: appId }); + async add( + provider: Provider, + appId: AppId, + addToLive?: boolean, + ): Promise { + return await invoke("add_provider", { provider, app: appId, addToLive }); }, - async update(provider: Provider, appId: AppId): Promise { - return await invoke("update_provider", { provider, app: appId }); + async update( + provider: Provider, + appId: AppId, + originalId?: string, + ): Promise { + return await invoke("update_provider", { + provider, + app: appId, + originalId, + }); }, async delete(id: string, appId: AppId): Promise { diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index e87ab5c13..e5ab2815c 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -15,7 +15,10 @@ export const useAddProviderMutation = (appId: AppId) => { return useMutation({ mutationFn: async ( - providerInput: Omit & { providerKey?: string }, + providerInput: Omit & { + providerKey?: string; + addToLive?: boolean; + }, ) => { let id: string; @@ -36,7 +39,7 @@ export const useAddProviderMutation = (appId: AppId) => { id = generateUUID(); } - const { providerKey: _providerKey, ...rest } = providerInput; + const { providerKey: _providerKey, addToLive, ...rest } = providerInput; const newProvider: Provider = { ...rest, @@ -45,7 +48,7 @@ export const useAddProviderMutation = (appId: AppId) => { }; delete (newProvider as any).providerKey; - await providersApi.add(newProvider, appId); + await providersApi.add(newProvider, appId, addToLive); return newProvider; }, onSuccess: async () => { @@ -107,8 +110,14 @@ export const useUpdateProviderMutation = (appId: AppId) => { const { t } = useTranslation(); return useMutation({ - mutationFn: async (provider: Provider) => { - await providersApi.update(provider, appId); + mutationFn: async ({ + provider, + originalId, + }: { + provider: Provider; + originalId?: string; + }) => { + await providersApi.update(provider, appId, originalId); return provider; }, onSuccess: async () => { diff --git a/tests/hooks/useProviderActions.test.tsx b/tests/hooks/useProviderActions.test.tsx index d6d858d72..8a7a2e8b4 100644 --- a/tests/hooks/useProviderActions.test.tsx +++ b/tests/hooks/useProviderActions.test.tsx @@ -169,7 +169,10 @@ describe("useProviderActions", () => { await result.current.updateProvider(provider); }); - expect(updateProviderMutateAsync).toHaveBeenCalledWith(provider); + expect(updateProviderMutateAsync).toHaveBeenCalledWith({ + provider, + originalId: undefined, + }); expect(providersApiUpdateTrayMenuMock).toHaveBeenCalledTimes(1); }); diff --git a/tests/integration/App.test.tsx b/tests/integration/App.test.tsx index 14f8439da..e3a26d1c6 100644 --- a/tests/integration/App.test.tsx +++ b/tests/integration/App.test.tsx @@ -2,7 +2,13 @@ import { Suspense, type ComponentType } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import { describe, it, expect, beforeEach, vi } from "vitest"; -import { resetProviderState } from "../msw/state"; +import { providersApi } from "@/lib/api/providers"; +import { + resetProviderState, + setCurrentProviderId, + setLiveProviderIds, + setProviders, +} from "../msw/state"; import { emitTauriEvent } from "../msw/tauriMocks"; const toastSuccessMock = vi.fn(); @@ -75,8 +81,11 @@ vi.mock("@/components/providers/EditProviderDialog", () => ({ + ), })); @@ -230,4 +240,95 @@ describe("App integration with MSW", () => { expect(toastErrorMock).toHaveBeenCalled(); }); }); + + it("duplicates openclaw providers with a generated key that avoids live-only ids", async () => { + setProviders("openclaw", { + deepseek: { + id: "deepseek", + name: "DeepSeek", + settingsConfig: { + baseUrl: "https://api.deepseek.com", + apiKey: "test-key", + api: "openai-completions", + models: [], + }, + category: "custom", + sortIndex: 0, + createdAt: Date.now(), + }, + }); + setCurrentProviderId("openclaw", "deepseek"); + setLiveProviderIds("openclaw", ["deepseek-copy"]); + + const { default: App } = await import("@/App"); + renderApp(App); + + fireEvent.click(screen.getByText("switch-openclaw")); + + await waitFor(() => + expect(screen.getByTestId("provider-list").textContent).toContain( + "deepseek", + ), + ); + + fireEvent.click(screen.getByText("duplicate")); + + await waitFor(() => { + const providerList = screen.getByTestId("provider-list").textContent; + expect(providerList).toContain("deepseek-copy-2"); + expect(providerList).toContain("DeepSeek copy"); + }); + + expect(toastErrorMock).not.toHaveBeenCalledWith( + expect.stringContaining("Provider key is required for openclaw"), + ); + }); + + it("shows toast when duplicate cannot load live provider ids", async () => { + setProviders("openclaw", { + deepseek: { + id: "deepseek", + name: "DeepSeek", + settingsConfig: { + baseUrl: "https://api.deepseek.com", + apiKey: "test-key", + api: "openai-completions", + models: [], + }, + category: "custom", + sortIndex: 0, + createdAt: Date.now(), + }, + }); + setCurrentProviderId("openclaw", "deepseek"); + + const liveIdsSpy = vi + .spyOn(providersApi, "getOpenClawLiveProviderIds") + .mockRejectedValueOnce(new Error("broken config")); + + const { default: App } = await import("@/App"); + renderApp(App); + + fireEvent.click(screen.getByText("switch-openclaw")); + + await waitFor(() => + expect(screen.getByTestId("provider-list").textContent).toContain( + "deepseek", + ), + ); + + fireEvent.click(screen.getByText("duplicate")); + + await waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith( + expect.stringContaining("读取配置中的供应商标识失败"), + ); + }); + + expect(screen.getByTestId("provider-list").textContent).not.toContain( + "deepseek-copy", + ); + + liveIdsSpy.mockRestore(); + }); }); diff --git a/tests/msw/handlers.ts b/tests/msw/handlers.ts index 40231b920..3ae6c2d68 100644 --- a/tests/msw/handlers.ts +++ b/tests/msw/handlers.ts @@ -6,6 +6,7 @@ import { deleteProvider, deleteSession, getCurrentProviderId, + getLiveProviderIds, getSessionMessages, getProviders, listProviders, @@ -67,6 +68,20 @@ export const handlers = [ http.post(`${TAURI_ENDPOINT}/update_tray_menu`, () => success(true)), + http.post(`${TAURI_ENDPOINT}/get_opencode_live_provider_ids`, () => + success(getLiveProviderIds("opencode")), + ), + + http.post(`${TAURI_ENDPOINT}/get_openclaw_live_provider_ids`, () => + success(getLiveProviderIds("openclaw")), + ), + + http.post(`${TAURI_ENDPOINT}/get_openclaw_default_model`, () => + success({ primary: null, fallback: [] }), + ), + + http.post(`${TAURI_ENDPOINT}/scan_openclaw_config_health`, () => success([])), + http.post(`${TAURI_ENDPOINT}/switch_provider`, async ({ request }) => { const { id, app } = await withJson<{ id: string; app: AppId }>(request); const providers = listProviders(app); @@ -197,6 +212,8 @@ export const handlers = [ http.post(`${TAURI_ENDPOINT}/get_settings`, () => success(getSettings())), + http.post(`${TAURI_ENDPOINT}/check_env_conflicts`, () => success([])), + http.post(`${TAURI_ENDPOINT}/save_settings`, async ({ request }) => { const { settings } = await withJson<{ settings: Settings }>(request); setSettings(settings); diff --git a/tests/msw/state.ts b/tests/msw/state.ts index 291d5201b..9768914d9 100644 --- a/tests/msw/state.ts +++ b/tests/msw/state.ts @@ -10,6 +10,7 @@ import type { type ProvidersByApp = Record>; type CurrentProviderState = Record; type McpConfigState = Record>; +type LiveProviderIdsByApp = Record<"opencode" | "openclaw", string[]>; const createDefaultProviders = (): ProvidersByApp => ({ claude: { @@ -77,6 +78,10 @@ const createDefaultCurrent = (): CurrentProviderState => ({ let providers = createDefaultProviders(); let current = createDefaultCurrent(); +let liveProviderIds: LiveProviderIdsByApp = { + opencode: [], + openclaw: [], +}; let settingsState: Settings = { showInTray: true, minimizeToTrayOnClose: true, @@ -184,6 +189,10 @@ const cloneProviders = (value: ProvidersByApp) => export const resetProviderState = () => { providers = createDefaultProviders(); current = createDefaultCurrent(); + liveProviderIds = { + opencode: [], + openclaw: [], + }; sessionsState = createDefaultSessions(); sessionMessagesState = createDefaultSessionMessages(); settingsState = { @@ -243,6 +252,17 @@ export const getProviders = (appType: AppId) => export const getCurrentProviderId = (appType: AppId) => current[appType] ?? ""; +export const getLiveProviderIds = (appType: "opencode" | "openclaw") => [ + ...liveProviderIds[appType], +]; + +export const setLiveProviderIds = ( + appType: "opencode" | "openclaw", + ids: string[], +) => { + liveProviderIds[appType] = [...ids]; +}; + export const setCurrentProviderId = (appType: AppId, providerId: string) => { current[appType] = providerId; };