diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 52437e6d..b9997bb3 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -191,6 +191,45 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re } } } + AppType::OpenClaw => { + // OpenClaw uses additive mode - write provider to config + use crate::openclaw_config; + use crate::openclaw_config::OpenClawProviderConfig; + + // Convert settings_config to OpenClawProviderConfig + let openclaw_config_result = + serde_json::from_value::(provider.settings_config.clone()); + + match openclaw_config_result { + Ok(config) => { + openclaw_config::set_typed_provider(&provider.id, &config)?; + log::info!("OpenClaw provider '{}' written to live config", provider.id); + } + Err(e) => { + log::warn!( + "Failed to parse OpenClaw provider config for '{}': {}", + provider.id, + e + ); + // Try to write as raw JSON if it looks valid + if provider.settings_config.get("baseUrl").is_some() + || provider.settings_config.get("api").is_some() + || provider.settings_config.get("models").is_some() + { + openclaw_config::set_provider(&provider.id, provider.settings_config.clone())?; + log::info!( + "OpenClaw provider '{}' written as raw JSON to live config", + provider.id + ); + } else { + log::error!( + "OpenClaw provider '{}' has invalid config structure, skipping write", + provider.id + ); + } + } + } + } } Ok(()) } @@ -340,6 +379,21 @@ pub fn read_live_settings(app_type: AppType) -> Result { let config = read_opencode_config()?; Ok(config) } + AppType::OpenClaw => { + use crate::openclaw_config::{get_openclaw_config_path, read_openclaw_config}; + + let config_path = get_openclaw_config_path(); + if !config_path.exists() { + return Err(AppError::localized( + "openclaw.config.missing", + "OpenClaw 配置文件不存在", + "OpenClaw configuration file not found", + )); + } + + let config = read_openclaw_config()?; + Ok(config) + } } } @@ -433,6 +487,23 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result { + // OpenClaw uses additive mode - import from live is not the same pattern + use crate::openclaw_config::{get_openclaw_config_path, read_openclaw_config}; + + let config_path = get_openclaw_config_path(); + if !config_path.exists() { + return Err(AppError::localized( + "openclaw.live.missing", + "OpenClaw 配置文件不存在", + "OpenClaw configuration file is missing", + )); + } + + // For OpenClaw, we return the full config - but note that OpenClaw + // uses additive mode, so importing defaults works differently + read_openclaw_config()? + } }; let mut provider = Provider::with_id( @@ -609,3 +680,71 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result Result { + use crate::openclaw_config; + + let providers = openclaw_config::get_typed_providers()?; + if providers.is_empty() { + return Ok(0); + } + + let mut imported = 0; + let existing = state.db.get_all_providers("openclaw")?; + + for (id, config) in providers { + // Skip if already exists in database + if existing.contains_key(&id) { + log::debug!("OpenClaw provider '{id}' already exists in database, skipping"); + continue; + } + + // Convert to Value for settings_config + let settings_config = match serde_json::to_value(&config) { + Ok(v) => v, + Err(e) => { + log::warn!("Failed to serialize OpenClaw provider '{id}': {e}"); + continue; + } + }; + + // Determine display name: use first model name if available, otherwise use id + let display_name = config + .models + .first() + .and_then(|m| m.name.clone()) + .unwrap_or_else(|| id.clone()); + + // Create provider + let provider = Provider::with_id(id.clone(), display_name, settings_config, None); + + // Save to database + if let Err(e) = state.db.save_provider("openclaw", &provider) { + log::warn!("Failed to import OpenClaw provider '{id}': {e}"); + continue; + } + + imported += 1; + log::info!("Imported OpenClaw provider '{id}' from live config"); + } + + Ok(imported) +} + +/// Remove an OpenClaw provider from live config +/// +/// This removes a specific provider from ~/.openclaw/openclaw.json +/// without affecting other providers in the file. +pub fn remove_openclaw_provider_from_live(provider_id: &str) -> Result<(), AppError> { + use crate::openclaw_config; + + openclaw_config::remove_provider(provider_id)?; + log::info!("OpenClaw provider '{provider_id}' removed from live config"); + + Ok(()) +} diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index c5f6abef..aa163ce3 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -21,8 +21,8 @@ use crate::store::AppState; // Re-export sub-module functions for external access pub use live::{ - import_default_config, import_opencode_providers_from_live, read_live_settings, - sync_current_to_live, + import_default_config, import_openclaw_providers_from_live, import_opencode_providers_from_live, + read_live_settings, sync_current_to_live, }; // Internal re-exports (pub(crate)) @@ -30,7 +30,9 @@ pub(crate) use live::sanitize_claude_settings_for_live; pub(crate) use live::write_live_snapshot; // Internal re-exports -use live::{remove_opencode_provider_from_live, write_gemini_live}; +use live::{ + remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live, +}; use usage::validate_usage_script; /// Provider business logic service @@ -142,10 +144,10 @@ impl ProviderService { /// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。 /// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。 /// - /// 对于 OpenCode(累加模式),不存在"当前供应商"概念,直接返回空字符串。 + /// 对于累加模式应用(OpenCode, OpenClaw),不存在"当前供应商"概念,直接返回空字符串。 pub fn current(state: &AppState, app_type: AppType) -> Result { - // OpenCode uses additive mode - no "current" provider concept - if matches!(app_type, AppType::OpenCode) { + // Additive mode apps have no "current" provider concept + if app_type.is_additive_mode() { return Ok(String::new()); } crate::settings::get_effective_current_provider(&state.db, &app_type) @@ -162,10 +164,12 @@ impl ProviderService { // Save to database state.db.save_provider(app_type.as_str(), &provider)?; - // OpenCode uses additive mode - always write to live config - if matches!(app_type, AppType::OpenCode) { + // Additive mode apps (OpenCode, OpenClaw) - always write to live config + if app_type.is_additive_mode() { // OMO providers use exclusive mode and write to dedicated config file. - if provider.category.as_deref() == Some("omo") { + if matches!(app_type, AppType::OpenCode) + && provider.category.as_deref() == Some("omo") + { // Do not auto-enable newly added OMO providers. // Users must explicitly switch/apply an OMO provider to activate it. return Ok(true); @@ -201,9 +205,11 @@ impl ProviderService { // Save to database state.db.save_provider(app_type.as_str(), &provider)?; - // OpenCode uses additive mode - always update in live config - if matches!(app_type, AppType::OpenCode) { - if provider.category.as_deref() == Some("omo") { + // 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)?; @@ -253,43 +259,48 @@ impl ProviderService { /// Delete a provider /// /// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。 - /// 对于 OpenCode(累加模式),可以随时删除任意供应商,同时从 live 配置中移除。 + /// 对于累加模式应用(OpenCode, OpenClaw),可以随时删除任意供应商,同时从 live 配置中移除。 pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { - // OpenCode uses additive mode - no current provider concept - if matches!(app_type, AppType::OpenCode) { - let is_omo = state - .db - .get_provider_by_id(id, app_type.as_str())? - .and_then(|p| p.category) - .as_deref() - == Some("omo"); - - if is_omo { - let was_current = state.db.is_omo_provider_current(app_type.as_str(), id)?; - let omo_count = state + // Additive mode apps - no current provider concept + if app_type.is_additive_mode() { + if matches!(app_type, AppType::OpenCode) { + let is_omo = state .db - .get_all_providers(app_type.as_str())? - .values() - .filter(|p| p.category.as_deref() == Some("omo")) - .count(); + .get_provider_by_id(id, app_type.as_str())? + .and_then(|p| p.category) + .as_deref() + == Some("omo"); - if omo_count <= 1 && was_current { - return Err(AppError::Message( - "无法删除当前启用的最后一个 OMO 配置,请先停用".to_string(), - )); - } + if is_omo { + let was_current = state.db.is_omo_provider_current(app_type.as_str(), id)?; + let omo_count = state + .db + .get_all_providers(app_type.as_str())? + .values() + .filter(|p| p.category.as_deref() == Some("omo")) + .count(); - state.db.delete_provider(app_type.as_str(), id)?; - if was_current { - crate::services::OmoService::delete_config_file()?; + if omo_count <= 1 && was_current { + return Err(AppError::Message( + "无法删除当前启用的最后一个 OMO 配置,请先停用".to_string(), + )); + } + + state.db.delete_provider(app_type.as_str(), id)?; + if was_current { + crate::services::OmoService::delete_config_file()?; + } + return Ok(()); } - return Ok(()); } - // Remove from database state.db.delete_provider(app_type.as_str(), id)?; // Also remove from live config - remove_opencode_provider_from_live(id)?; + 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(()); } @@ -306,7 +317,7 @@ impl ProviderService { state.db.delete_provider(app_type.as_str(), id) } - /// Remove provider from live config only (for additive mode apps like OpenCode) + /// Remove provider from live config only (for additive mode apps like OpenCode, OpenClaw) /// /// Does NOT delete from database - provider remains in the list. /// This is used when user wants to "remove" a provider from active config @@ -338,7 +349,9 @@ impl ProviderService { remove_opencode_provider_from_live(id)?; } } - // Future: add other additive mode apps here + AppType::OpenClaw => { + remove_openclaw_provider_from_live(id)?; + } _ => { return Err(AppError::Message(format!( "App {} does not support remove from live config", @@ -454,22 +467,25 @@ impl ProviderService { // Use effective current provider (validated existence) to ensure backfill targets valid provider let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?; - match (current_id, matches!(app_type, AppType::OpenCode)) { - (Some(current_id), false) if current_id != id => { - // Only backfill when switching to a different provider. - if let Ok(live_config) = read_live_settings(app_type.clone()) { - if let Some(mut current_provider) = providers.get(¤t_id).cloned() { - current_provider.settings_config = live_config; - // Ignore backfill failure, don't affect switch flow. - let _ = state.db.save_provider(app_type.as_str(), ¤t_provider); + if let Some(current_id) = current_id { + if current_id != id { + // Additive mode apps - all providers coexist in the same file, + // no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini) + if !app_type.is_additive_mode() { + // Only backfill when switching to a different provider + if let Ok(live_config) = read_live_settings(app_type.clone()) { + if let Some(mut current_provider) = providers.get(¤t_id).cloned() { + current_provider.settings_config = live_config; + // Ignore backfill failure, don't affect switch flow + let _ = state.db.save_provider(app_type.as_str(), ¤t_provider); + } } } } - _ => {} } - // OpenCode uses additive mode - skip setting is_current (no such concept) - if !matches!(app_type, AppType::OpenCode) { + // Additive mode apps skip setting is_current (no such concept) + if !app_type.is_additive_mode() { // Update local settings (device-level, takes priority) crate::settings::set_current_provider(&app_type, Some(id))?; @@ -515,6 +531,7 @@ impl ProviderService { AppType::Codex => Self::extract_codex_common_config(&provider.settings_config), AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config), AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config), + AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config), } } @@ -528,6 +545,7 @@ impl ProviderService { AppType::Codex => Self::extract_codex_common_config(settings_config), AppType::Gemini => Self::extract_gemini_common_config(settings_config), AppType::OpenCode => Self::extract_opencode_common_config(settings_config), + AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config), } } @@ -684,6 +702,27 @@ impl ProviderService { .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) } + /// Extract common config for OpenClaw (JSON format) + fn extract_openclaw_common_config(settings: &Value) -> Result { + // OpenClaw uses a different config structure with baseUrl, apiKey, api, models + // For common config, we exclude provider-specific fields like apiKey + let mut config = settings.clone(); + + // Remove provider-specific fields + if let Some(obj) = config.as_object_mut() { + obj.remove("apiKey"); + obj.remove("baseUrl"); + // Keep api and models as they might be common + } + + if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) { + return Ok("{}".to_string()); + } + + serde_json::to_string_pretty(&config) + .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) + } + /// Import default configuration from live files (re-export) /// /// Returns `Ok(true)` if imported, `Ok(false)` if skipped. @@ -861,6 +900,17 @@ impl ProviderService { )); } } + AppType::OpenClaw => { + // OpenClaw uses config structure: { baseUrl, apiKey, api, models } + // Basic validation - must be an object + if !provider.settings_config.is_object() { + return Err(AppError::localized( + "provider.openclaw.settings.not_object", + "OpenClaw 配置必须是 JSON 对象", + "OpenClaw configuration must be a JSON object", + )); + } + } } // Validate and clean UsageScript configuration (common for all app types) @@ -1032,6 +1082,30 @@ impl ProviderService { Ok((api_key, base_url)) } + AppType::OpenClaw => { + // OpenClaw uses apiKey and baseUrl directly on the object + let api_key = provider + .settings_config + .get("apiKey") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + AppError::localized( + "provider.openclaw.api_key.missing", + "缺少 API Key", + "API key is missing", + ) + })? + .to_string(); + + let base_url = provider + .settings_config + .get("baseUrl") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + Ok((api_key, base_url)) + } } } } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index e1ae3b21..1735cac9 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -34,6 +34,8 @@ pub struct VisibleApps { pub gemini: bool, #[serde(default = "default_true")] pub opencode: bool, + #[serde(default = "default_true")] + pub openclaw: bool, } impl Default for VisibleApps { @@ -43,6 +45,7 @@ impl Default for VisibleApps { codex: true, gemini: true, opencode: true, + openclaw: true, } } } @@ -55,6 +58,7 @@ impl VisibleApps { AppType::Codex => self.codex, AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, + AppType::OpenClaw => self.openclaw, } } } @@ -194,6 +198,8 @@ pub struct AppSettings { pub gemini_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub opencode_config_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub openclaw_config_dir: Option, // ===== 当前供应商 ID(设备级)===== /// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current) @@ -208,6 +214,9 @@ pub struct AppSettings { /// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致) #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_opencode: Option, + /// 当前 OpenClaw 供应商 ID(本地存储,对 OpenClaw 可能无意义,但保持结构一致) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_provider_openclaw: Option, // ===== Skill 同步设置 ===== /// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy @@ -254,10 +263,12 @@ impl Default for AppSettings { codex_config_dir: None, gemini_config_dir: None, opencode_config_dir: None, + openclaw_config_dir: None, current_provider_claude: None, current_provider_codex: None, current_provider_gemini: None, current_provider_opencode: None, + current_provider_openclaw: None, skill_sync_method: SyncMethod::default(), webdav_sync: None, webdav_backup: None, @@ -305,6 +316,13 @@ impl AppSettings { .filter(|s| !s.is_empty()) .map(|s| s.to_string()); + self.openclaw_config_dir = self + .openclaw_config_dir + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + self.language = self .language .as_ref() @@ -497,6 +515,14 @@ pub fn get_opencode_override_dir() -> Option { .map(|p| resolve_override_path(p)) } +pub fn get_openclaw_override_dir() -> Option { + let settings = settings_store().read().ok()?; + settings + .openclaw_config_dir + .as_ref() + .map(|p| resolve_override_path(p)) +} + // ===== 当前供应商管理函数 ===== /// 获取指定应用类型的当前供应商 ID(从本地 settings 读取) @@ -510,6 +536,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option { AppType::Codex => settings.current_provider_codex.clone(), AppType::Gemini => settings.current_provider_gemini.clone(), AppType::OpenCode => settings.current_provider_opencode.clone(), + AppType::OpenClaw => settings.current_provider_openclaw.clone(), } } @@ -525,6 +552,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()), AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()), AppType::OpenCode => settings.current_provider_opencode = id.map(|s| s.to_string()), + AppType::OpenClaw => settings.current_provider_openclaw = id.map(|s| s.to_string()), } update_settings(settings)