diff --git a/package.json b/package.json index 147441196..62c1c7177 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@lobehub/icons-static-svg": "^1.73.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d82cfdc38..6c791d5e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: '@radix-ui/react-checkbox': specifier: ^1.3.3 version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collapsible': + specifier: ^1.1.12 + version: 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 49e1759f0..bd5fd8c38 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -714,6 +714,7 @@ dependencies = [ "futures", "hyper", "indexmap 2.11.4", + "json5", "log", "objc2 0.5.2", "objc2-app-kit 0.2.2", @@ -727,6 +728,7 @@ dependencies = [ "serde_json", "serde_yaml", "serial_test", + "sha2", "tauri", "tauri-build", "tauri-plugin-deep-link", @@ -2520,6 +2522,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + [[package]] name = "jsonptr" version = "0.6.3" @@ -3404,6 +3417,49 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "pest_meta" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +dependencies = [ + "pest", + "sha2", +] + [[package]] name = "phf" version = "0.8.0" @@ -5887,6 +5943,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.1.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7346899a6..9547eb342 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -61,6 +61,8 @@ rusqlite = { version = "0.31", features = ["bundled", "backup"] } indexmap = { version = "2", features = ["serde"] } rust_decimal = "1.33" uuid = { version = "1.11", features = ["v4"] } +sha2 = "0.10" +json5 = "0.4" [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 41ade990f..18e3fbec8 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -25,6 +25,7 @@ impl McpApps { AppType::Codex => self.codex, AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, + AppType::OpenClaw => false, // OpenClaw doesn't support MCP } } @@ -35,6 +36,7 @@ impl McpApps { AppType::Codex => self.codex = enabled, AppType::Gemini => self.gemini = enabled, AppType::OpenCode => self.opencode = enabled, + AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore } } @@ -83,6 +85,7 @@ impl SkillApps { AppType::Codex => self.codex, AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, + AppType::OpenClaw => false, // OpenClaw doesn't support Skills } } @@ -93,6 +96,7 @@ impl SkillApps { AppType::Codex => self.codex = enabled, AppType::Gemini => self.gemini = enabled, AppType::OpenCode => self.opencode = enabled, + AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore } } @@ -238,6 +242,9 @@ pub struct McpRoot { /// OpenCode MCP 配置(v4.0.0+,实际使用 opencode.json) #[serde(default, skip_serializing_if = "McpConfig::is_empty")] pub opencode: McpConfig, + /// OpenClaw MCP 配置(v4.1.0+,实际使用 openclaw.json) + #[serde(default, skip_serializing_if = "McpConfig::is_empty")] + pub openclaw: McpConfig, } impl Default for McpRoot { @@ -250,6 +257,7 @@ impl Default for McpRoot { codex: McpConfig::default(), gemini: McpConfig::default(), opencode: McpConfig::default(), + openclaw: McpConfig::default(), } } } @@ -272,6 +280,8 @@ pub struct PromptRoot { pub gemini: PromptConfig, #[serde(default)] pub opencode: PromptConfig, + #[serde(default)] + pub openclaw: PromptConfig, } use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file}; @@ -287,6 +297,7 @@ pub enum AppType { Codex, Gemini, OpenCode, + OpenClaw, } impl AppType { @@ -296,15 +307,16 @@ impl AppType { AppType::Codex => "codex", AppType::Gemini => "gemini", AppType::OpenCode => "opencode", + AppType::OpenClaw => "openclaw", } } /// Check if this app uses additive mode /// /// - Switch mode (false): Only the current provider is written to live config (Claude, Codex, Gemini) - /// - Additive mode (true): All providers are written to live config (OpenCode) + /// - Additive mode (true): All providers are written to live config (OpenCode, OpenClaw) pub fn is_additive_mode(&self) -> bool { - matches!(self, AppType::OpenCode) + matches!(self, AppType::OpenCode | AppType::OpenClaw) } /// Return an iterator over all app types @@ -314,6 +326,7 @@ impl AppType { AppType::Codex, AppType::Gemini, AppType::OpenCode, + AppType::OpenClaw, ] .into_iter() } @@ -329,10 +342,11 @@ impl FromStr for AppType { "codex" => Ok(AppType::Codex), "gemini" => Ok(AppType::Gemini), "opencode" => Ok(AppType::OpenCode), + "openclaw" => Ok(AppType::OpenClaw), other => Err(AppError::localized( "unsupported_app", - format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode。"), - format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode."), + format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw。"), + format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw."), )), } } @@ -352,6 +366,9 @@ pub struct CommonConfigSnippets { #[serde(default, skip_serializing_if = "Option::is_none")] pub opencode: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub openclaw: Option, } impl CommonConfigSnippets { @@ -362,6 +379,7 @@ impl CommonConfigSnippets { AppType::Codex => self.codex.as_ref(), AppType::Gemini => self.gemini.as_ref(), AppType::OpenCode => self.opencode.as_ref(), + AppType::OpenClaw => self.openclaw.as_ref(), } } @@ -372,6 +390,7 @@ impl CommonConfigSnippets { AppType::Codex => self.codex = snippet, AppType::Gemini => self.gemini = snippet, AppType::OpenCode => self.opencode = snippet, + AppType::OpenClaw => self.openclaw = snippet, } } } @@ -412,6 +431,7 @@ impl Default for MultiAppConfig { apps.insert("codex".to_string(), ProviderManager::default()); apps.insert("gemini".to_string(), ProviderManager::default()); apps.insert("opencode".to_string(), ProviderManager::default()); + apps.insert("openclaw".to_string(), ProviderManager::default()); Self { version: 2, @@ -571,6 +591,7 @@ impl MultiAppConfig { AppType::Codex => &self.mcp.codex, AppType::Gemini => &self.mcp.gemini, AppType::OpenCode => &self.mcp.opencode, + AppType::OpenClaw => &self.mcp.openclaw, } } @@ -581,6 +602,7 @@ impl MultiAppConfig { AppType::Codex => &mut self.mcp.codex, AppType::Gemini => &mut self.mcp.gemini, AppType::OpenCode => &mut self.mcp.opencode, + AppType::OpenClaw => &mut self.mcp.openclaw, } } @@ -595,6 +617,7 @@ impl MultiAppConfig { Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?; Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?; Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?; Ok(config) } @@ -615,6 +638,7 @@ impl MultiAppConfig { || !self.prompts.codex.prompts.is_empty() || !self.prompts.gemini.prompts.is_empty() || !self.prompts.opencode.prompts.is_empty() + || !self.prompts.openclaw.prompts.is_empty() { return Ok(false); } @@ -627,6 +651,7 @@ impl MultiAppConfig { AppType::Codex, AppType::Gemini, AppType::OpenCode, + AppType::OpenClaw, ] { // 复用已有的单应用导入逻辑 if Self::auto_import_prompt_if_exists(self, app)? { @@ -697,6 +722,7 @@ impl MultiAppConfig { AppType::Codex => &mut config.prompts.codex.prompts, AppType::Gemini => &mut config.prompts.gemini.prompts, AppType::OpenCode => &mut config.prompts.opencode.prompts, + AppType::OpenClaw => &mut config.prompts.openclaw.prompts, }; prompts.insert(id, prompt); @@ -725,12 +751,18 @@ impl MultiAppConfig { let mut conflicts = Vec::new(); // 收集所有应用的 MCP - for app in [AppType::Claude, AppType::Codex, AppType::Gemini] { + for app in [ + AppType::Claude, + AppType::Codex, + AppType::Gemini, + AppType::OpenCode, + ] { let old_servers = match app { AppType::Claude => &self.mcp.claude.servers, AppType::Codex => &self.mcp.codex.servers, AppType::Gemini => &self.mcp.gemini.servers, AppType::OpenCode => &self.mcp.opencode.servers, + AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip }; for (id, entry) in old_servers { diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index c70939e04..87430b46f 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -59,6 +59,15 @@ pub async fn get_config_status(app: String) -> Result { Ok(ConfigStatus { exists, path }) } + AppType::OpenClaw => { + let config_path = crate::openclaw_config::get_openclaw_config_path(); + let exists = config_path.exists(); + let path = crate::openclaw_config::get_openclaw_dir() + .to_string_lossy() + .to_string(); + + Ok(ConfigStatus { exists, path }) + } } } @@ -74,6 +83,7 @@ pub async fn get_config_dir(app: String) -> Result { AppType::Codex => codex_config::get_codex_config_dir(), AppType::Gemini => crate::gemini_config::get_gemini_dir(), AppType::OpenCode => crate::opencode_config::get_opencode_dir(), + AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(), }; Ok(dir.to_string_lossy().to_string()) @@ -86,6 +96,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result codex_config::get_codex_config_dir(), AppType::Gemini => crate::gemini_config::get_gemini_dir(), AppType::OpenCode => crate::opencode_config::get_opencode_dir(), + AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(), }; if !config_dir.exists() { diff --git a/src-tauri/src/commands/import_export.rs b/src-tauri/src/commands/import_export.rs index 5e944bdc9..c4482629d 100644 --- a/src-tauri/src/commands/import_export.rs +++ b/src-tauri/src/commands/import_export.rs @@ -5,10 +5,15 @@ use std::path::PathBuf; use tauri::State; use tauri_plugin_dialog::DialogExt; +use crate::commands::sync_support::{ + post_sync_warning_from_result, run_post_import_sync, success_payload_with_warning, +}; use crate::error::AppError; use crate::services::provider::ProviderService; use crate::store::AppState; +// ─── File import/export ────────────────────────────────────── + /// 导出数据库为 SQL 备份 #[tauri::command] pub async fn export_config_to_file( @@ -37,27 +42,15 @@ pub async fn import_config_from_file( state: State<'_, AppState>, ) -> Result { let db = state.db.clone(); - let db_for_state = db.clone(); + let db_for_sync = db.clone(); tauri::async_runtime::spawn_blocking(move || { let path_buf = PathBuf::from(&filePath); let backup_id = db.import_sql(&path_buf)?; - - // 导入后同步当前供应商到各自的 live 配置 - let app_state = AppState::new(db_for_state); - if let Err(err) = ProviderService::sync_current_to_live(&app_state) { - log::warn!("导入后同步 live 配置失败: {err}"); + let warning = post_sync_warning_from_result(Ok(run_post_import_sync(db_for_sync))); + if let Some(msg) = warning.as_ref() { + log::warn!("[Import] post-import sync warning: {msg}"); } - - // 重新加载设置到内存缓存,确保导入的设置生效 - if let Err(err) = crate::settings::reload_settings() { - log::warn!("导入后重载设置失败: {err}"); - } - - Ok::<_, AppError>(json!({ - "success": true, - "message": "SQL imported successfully", - "backupId": backup_id - })) + Ok::<_, AppError>(success_payload_with_warning(backup_id, warning)) }) .await .map_err(|e| format!("导入配置失败: {e}"))? @@ -80,6 +73,8 @@ pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result( diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 7976c4316..5de019f96 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -9,6 +9,7 @@ mod import_export; mod mcp; mod misc; mod omo; +mod openclaw; mod plugin; mod prompt; mod provider; @@ -17,7 +18,10 @@ mod session_manager; mod settings; pub mod skill; mod stream_check; +mod sync_support; mod usage; +mod webdav_sync; +mod workspace; pub use config::*; pub use deeplink::*; @@ -28,6 +32,7 @@ pub use import_export::*; pub use mcp::*; pub use misc::*; pub use omo::*; +pub use openclaw::*; pub use plugin::*; pub use prompt::*; pub use provider::*; @@ -37,3 +42,5 @@ pub use settings::*; pub use skill::*; pub use stream_check::*; pub use usage::*; +pub use webdav_sync::*; +pub use workspace::*; diff --git a/src-tauri/src/commands/openclaw.rs b/src-tauri/src/commands/openclaw.rs new file mode 100644 index 000000000..f9d0c9f7b --- /dev/null +++ b/src-tauri/src/commands/openclaw.rs @@ -0,0 +1,108 @@ +use std::collections::HashMap; +use tauri::State; + +use crate::openclaw_config; +use crate::store::AppState; + +// ============================================================================ +// OpenClaw Provider Commands (migrated from provider.rs) +// ============================================================================ + +/// Import providers from OpenClaw live config to database. +/// +/// OpenClaw uses additive mode — users may already have providers +/// configured in openclaw.json. +#[tauri::command] +pub fn import_openclaw_providers_from_live(state: State<'_, AppState>) -> Result { + crate::services::provider::import_openclaw_providers_from_live(state.inner()) + .map_err(|e| e.to_string()) +} + +/// Get provider IDs in the OpenClaw live config. +#[tauri::command] +pub fn get_openclaw_live_provider_ids() -> Result, String> { + openclaw_config::get_providers() + .map(|providers| providers.keys().cloned().collect()) + .map_err(|e| e.to_string()) +} + +// ============================================================================ +// Agents Configuration Commands +// ============================================================================ + +/// Get OpenClaw default model config (agents.defaults.model) +#[tauri::command] +pub fn get_openclaw_default_model() -> Result, String> +{ + openclaw_config::get_default_model().map_err(|e| e.to_string()) +} + +/// Set OpenClaw default model config (agents.defaults.model) +#[tauri::command] +pub fn set_openclaw_default_model( + model: openclaw_config::OpenClawDefaultModel, +) -> Result<(), String> { + openclaw_config::set_default_model(&model).map_err(|e| e.to_string()) +} + +/// Get OpenClaw model catalog/allowlist (agents.defaults.models) +#[tauri::command] +pub fn get_openclaw_model_catalog( +) -> Result>, String> { + openclaw_config::get_model_catalog().map_err(|e| e.to_string()) +} + +/// Set OpenClaw model catalog/allowlist (agents.defaults.models) +#[tauri::command] +pub fn set_openclaw_model_catalog( + catalog: HashMap, +) -> Result<(), String> { + openclaw_config::set_model_catalog(&catalog).map_err(|e| e.to_string()) +} + +/// Get full agents.defaults config (all fields) +#[tauri::command] +pub fn get_openclaw_agents_defaults( +) -> Result, String> { + openclaw_config::get_agents_defaults().map_err(|e| e.to_string()) +} + +/// Set full agents.defaults config (all fields) +#[tauri::command] +pub fn set_openclaw_agents_defaults( + defaults: openclaw_config::OpenClawAgentsDefaults, +) -> Result<(), String> { + openclaw_config::set_agents_defaults(&defaults).map_err(|e| e.to_string()) +} + +// ============================================================================ +// Env Configuration Commands +// ============================================================================ + +/// Get OpenClaw env config (env section of openclaw.json) +#[tauri::command] +pub fn get_openclaw_env() -> Result { + openclaw_config::get_env_config().map_err(|e| e.to_string()) +} + +/// Set OpenClaw env config (env section of openclaw.json) +#[tauri::command] +pub fn set_openclaw_env(env: openclaw_config::OpenClawEnvConfig) -> Result<(), String> { + openclaw_config::set_env_config(&env).map_err(|e| e.to_string()) +} + +// ============================================================================ +// Tools Configuration Commands +// ============================================================================ + +/// Get OpenClaw tools config (tools section of openclaw.json) +#[tauri::command] +pub fn get_openclaw_tools() -> Result { + openclaw_config::get_tools_config().map_err(|e| e.to_string()) +} + +/// Set OpenClaw tools config (tools section of openclaw.json) +#[tauri::command] +pub fn set_openclaw_tools(tools: openclaw_config::OpenClawToolsConfig) -> Result<(), String> { + openclaw_config::set_tools_config(&tools).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 47de928f5..da9a7ea96 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -318,3 +318,7 @@ pub fn get_opencode_live_provider_ids() -> Result, String> { .map(|providers| providers.keys().cloned().collect()) .map_err(|e| e.to_string()) } + +// ============================================================================ +// OpenClaw 专属命令 → 已迁移至 commands/openclaw.rs +// ============================================================================ diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 2a6ca5039..8aac400f9 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -2,16 +2,28 @@ use tauri::AppHandle; +fn merge_settings_for_save( + mut incoming: crate::settings::AppSettings, + existing: &crate::settings::AppSettings, +) -> crate::settings::AppSettings { + if incoming.webdav_sync.is_none() { + incoming.webdav_sync = existing.webdav_sync.clone(); + } + incoming +} + /// 获取设置 #[tauri::command] pub async fn get_settings() -> Result { - Ok(crate::settings::get_settings()) + Ok(crate::settings::get_settings_for_frontend()) } /// 保存设置 #[tauri::command] pub async fn save_settings(settings: crate::settings::AppSettings) -> Result { - crate::settings::update_settings(settings).map_err(|e| e.to_string())?; + let existing = crate::settings::get_settings(); + let merged = merge_settings_for_save(settings, &existing); + crate::settings::update_settings(merged).map_err(|e| e.to_string())?; Ok(true) } @@ -54,6 +66,58 @@ pub async fn set_auto_launch(enabled: bool) -> Result { Ok(true) } +#[cfg(test)] +mod tests { + use super::merge_settings_for_save; + use crate::settings::{AppSettings, WebDavSyncSettings}; + + #[test] + fn save_settings_should_preserve_existing_webdav_when_payload_omits_it() { + let mut existing = AppSettings::default(); + existing.webdav_sync = Some(WebDavSyncSettings { + base_url: "https://dav.example.com".to_string(), + username: "alice".to_string(), + password: "secret".to_string(), + ..WebDavSyncSettings::default() + }); + + let incoming = AppSettings::default(); + let merged = merge_settings_for_save(incoming, &existing); + + assert!(merged.webdav_sync.is_some()); + assert_eq!( + merged.webdav_sync.as_ref().map(|v| v.base_url.as_str()), + Some("https://dav.example.com") + ); + } + + #[test] + fn save_settings_should_keep_incoming_webdav_when_present() { + let mut existing = AppSettings::default(); + existing.webdav_sync = Some(WebDavSyncSettings { + base_url: "https://dav.old.example.com".to_string(), + username: "old".to_string(), + password: "old-pass".to_string(), + ..WebDavSyncSettings::default() + }); + + let mut incoming = AppSettings::default(); + incoming.webdav_sync = Some(WebDavSyncSettings { + base_url: "https://dav.new.example.com".to_string(), + username: "new".to_string(), + password: "new-pass".to_string(), + ..WebDavSyncSettings::default() + }); + + let merged = merge_settings_for_save(incoming, &existing); + + assert_eq!( + merged.webdav_sync.as_ref().map(|v| v.base_url.as_str()), + Some("https://dav.new.example.com") + ); + } +} + /// 获取开机自启状态 #[tauri::command] pub async fn get_auto_launch_status() -> Result { diff --git a/src-tauri/src/commands/sync_support.rs b/src-tauri/src/commands/sync_support.rs new file mode 100644 index 000000000..00793b3b8 --- /dev/null +++ b/src-tauri/src/commands/sync_support.rs @@ -0,0 +1,97 @@ +use serde_json::{json, Value}; +use std::sync::Arc; + +use crate::database::Database; +use crate::error::AppError; +use crate::services::provider::ProviderService; +use crate::settings; +use crate::store::AppState; + +pub(crate) fn run_post_import_sync(db: Arc) -> Result<(), AppError> { + let app_state = AppState::new(db); + ProviderService::sync_current_to_live(&app_state)?; + settings::reload_settings()?; + Ok(()) +} + +fn post_sync_warning(err: E) -> String { + AppError::localized( + "sync.post_operation_sync_failed", + format!("后置同步状态失败: {err}"), + format!("Post-operation synchronization failed: {err}"), + ) + .to_string() +} + +pub(crate) fn post_sync_warning_from_result( + result: Result, String>, +) -> Option { + match result { + Ok(Ok(())) => None, + Ok(Err(err)) => Some(post_sync_warning(err)), + Err(err) => Some(post_sync_warning(err)), + } +} + +pub(crate) fn attach_warning(mut value: Value, warning: Option) -> Value { + if let Some(message) = warning { + if let Some(obj) = value.as_object_mut() { + obj.insert("warning".to_string(), Value::String(message)); + } + } + value +} + +pub(crate) fn success_payload_with_warning(backup_id: String, warning: Option) -> Value { + attach_warning( + json!({ + "success": true, + "message": "SQL imported successfully", + "backupId": backup_id + }), + warning, + ) +} + +#[cfg(test)] +mod tests { + use super::{attach_warning, post_sync_warning_from_result}; + use serde_json::json; + + #[test] + fn post_sync_warning_from_result_returns_none_on_success() { + let warning = post_sync_warning_from_result(Ok(Ok(()))); + assert!(warning.is_none()); + } + + #[test] + fn post_sync_warning_from_result_returns_some_on_sync_error() { + let warning = + post_sync_warning_from_result(Ok(Err(crate::error::AppError::Config("boom".into())))); + assert!(warning.is_some()); + } + + #[tokio::test] + async fn post_sync_warning_from_result_returns_some_on_join_error() { + let handle = tokio::spawn(async move { + panic!("forced join error"); + }); + let join_err = handle.await.expect_err("task should panic"); + let warning = post_sync_warning_from_result(Err(join_err.to_string())); + assert!(warning.is_some()); + } + + #[test] + fn attach_warning_adds_warning_without_dropping_existing_fields() { + let payload = json!({ "status": "downloaded" }); + let updated = attach_warning(payload, Some("post sync warning".to_string())); + assert_eq!( + updated.get("status").and_then(|v| v.as_str()), + Some("downloaded") + ); + assert_eq!( + updated.get("warning").and_then(|v| v.as_str()), + Some("post sync warning") + ); + } +} diff --git a/src-tauri/src/commands/webdav_sync.rs b/src-tauri/src/commands/webdav_sync.rs new file mode 100644 index 000000000..e1bbd70d7 --- /dev/null +++ b/src-tauri/src/commands/webdav_sync.rs @@ -0,0 +1,357 @@ +#![allow(non_snake_case)] + +use serde_json::{Value, json}; +use std::future::Future; +use std::sync::OnceLock; +use tauri::State; + +use crate::commands::sync_support::{ + attach_warning, post_sync_warning_from_result, run_post_import_sync, +}; +use crate::error::AppError; +use crate::services::webdav_sync as webdav_sync_service; +use crate::settings::{self, WebDavSyncSettings}; +use crate::store::AppState; + +fn persist_sync_error(settings: &mut WebDavSyncSettings, error: &AppError) { + settings.status.last_error = Some(error.to_string()); + let _ = settings::update_webdav_sync_status(settings.status.clone()); +} + +fn webdav_not_configured_error() -> String { + AppError::localized( + "webdav.sync.not_configured", + "未配置 WebDAV 同步", + "WebDAV sync is not configured.", + ) + .to_string() +} + +fn webdav_sync_disabled_error() -> String { + AppError::localized( + "webdav.sync.disabled", + "WebDAV 同步未启用", + "WebDAV sync is disabled.", + ) + .to_string() +} + +fn require_enabled_webdav_settings() -> Result { + let settings = settings::get_webdav_sync_settings().ok_or_else(webdav_not_configured_error)?; + if !settings.enabled { + return Err(webdav_sync_disabled_error()); + } + Ok(settings) +} + +fn resolve_password_for_request( + mut incoming: WebDavSyncSettings, + existing: Option, + preserve_empty_password: bool, +) -> WebDavSyncSettings { + if let Some(existing_settings) = existing { + if preserve_empty_password && incoming.password.is_empty() { + incoming.password = existing_settings.password; + } + } + incoming +} + +fn webdav_sync_mutex() -> &'static tokio::sync::Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| tokio::sync::Mutex::new(())) +} + +async fn run_with_webdav_lock(operation: Fut) -> Result +where + Fut: Future>, +{ + let result = { + let _guard = webdav_sync_mutex().lock().await; + operation.await + }; + result +} + +fn map_sync_result(result: Result, on_error: F) -> Result +where + F: FnOnce(&AppError), +{ + match result { + Ok(value) => Ok(value), + Err(err) => { + on_error(&err); + Err(err.to_string()) + } + } +} + +#[tauri::command] +pub async fn webdav_test_connection( + settings: WebDavSyncSettings, + #[allow(non_snake_case)] preserveEmptyPassword: Option, +) -> Result { + let preserve_empty = preserveEmptyPassword.unwrap_or(true); + let resolved = resolve_password_for_request( + settings, + settings::get_webdav_sync_settings(), + preserve_empty, + ); + webdav_sync_service::check_connection(&resolved) + .await + .map_err(|e| e.to_string())?; + Ok(json!({ + "success": true, + "message": "WebDAV connection ok" + })) +} + +#[tauri::command] +pub async fn webdav_sync_upload(state: State<'_, AppState>) -> Result { + let db = state.db.clone(); + let mut settings = require_enabled_webdav_settings()?; + + let result = run_with_webdav_lock(webdav_sync_service::upload(&db, &mut settings)).await; + map_sync_result(result, |error| persist_sync_error(&mut settings, error)) +} + +#[tauri::command] +pub async fn webdav_sync_download(state: State<'_, AppState>) -> Result { + let db = state.db.clone(); + let db_for_sync = db.clone(); + let mut settings = require_enabled_webdav_settings()?; + + let sync_result = run_with_webdav_lock(webdav_sync_service::download(&db, &mut settings)).await; + let mut result = map_sync_result(sync_result, |error| { + persist_sync_error(&mut settings, error) + })?; + + // Post-download sync is best-effort: snapshot restore has already succeeded. + let warning = post_sync_warning_from_result( + tauri::async_runtime::spawn_blocking(move || run_post_import_sync(db_for_sync)) + .await + .map_err(|e| e.to_string()), + ); + if let Some(msg) = warning.as_ref() { + log::warn!("[WebDAV] post-download sync warning: {msg}"); + } + result = attach_warning(result, warning); + + Ok(result) +} + +#[tauri::command] +pub async fn webdav_sync_save_settings( + settings: WebDavSyncSettings, + #[allow(non_snake_case)] passwordTouched: Option, +) -> Result { + let password_touched = passwordTouched.unwrap_or(false); + let existing = settings::get_webdav_sync_settings(); + let mut sync_settings = + resolve_password_for_request(settings, existing.clone(), !password_touched); + + // Preserve server-owned fields that the frontend does not manage + if let Some(existing_settings) = existing { + sync_settings.status = existing_settings.status; + } + + sync_settings.normalize(); + sync_settings.validate().map_err(|e| e.to_string())?; + settings::set_webdav_sync_settings(Some(sync_settings)).map_err(|e| e.to_string())?; + Ok(json!({ "success": true })) +} + +#[tauri::command] +pub async fn webdav_sync_fetch_remote_info() -> Result { + let settings = require_enabled_webdav_settings()?; + let info = webdav_sync_service::fetch_remote_info(&settings) + .await + .map_err(|e| e.to_string())?; + Ok(info.unwrap_or(json!({ "empty": true }))) +} + +#[cfg(test)] +mod tests { + use super::{ + map_sync_result, persist_sync_error, require_enabled_webdav_settings, + resolve_password_for_request, run_with_webdav_lock, webdav_sync_mutex, + }; + use crate::error::AppError; + use crate::settings::{AppSettings, WebDavSyncSettings}; + use serial_test::serial; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::Duration; + + #[tokio::test] + async fn webdav_sync_mutex_is_singleton() { + let a = webdav_sync_mutex() as *const _; + let b = webdav_sync_mutex() as *const _; + assert_eq!(a, b); + } + + #[tokio::test] + #[serial] + async fn webdav_sync_mutex_serializes_concurrent_access() { + let guard = webdav_sync_mutex().lock().await; + let acquired = Arc::new(AtomicBool::new(false)); + let acquired_bg = Arc::clone(&acquired); + + let waiter = tokio::spawn(async move { + let _inner_guard = webdav_sync_mutex().lock().await; + acquired_bg.store(true, Ordering::SeqCst); + }); + + tokio::time::sleep(Duration::from_millis(40)).await; + assert!(!acquired.load(Ordering::SeqCst)); + + drop(guard); + tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("background task should complete after lock release") + .expect("background task should not panic"); + + assert!(acquired.load(Ordering::SeqCst)); + } + + #[tokio::test] + #[serial] + async fn map_sync_result_runs_error_handler_after_lock_release() { + let result = run_with_webdav_lock(async { + Err::<(), AppError>(AppError::Config("boom".to_string())) + }) + .await; + + let mut lock_released = false; + let mapped = map_sync_result(result, |_| { + lock_released = webdav_sync_mutex().try_lock().is_ok(); + }); + + assert!(mapped.is_err()); + assert!(lock_released); + } + + #[test] + fn resolve_password_for_request_preserves_existing_when_requested() { + let incoming = WebDavSyncSettings { + base_url: "https://dav.example.com".to_string(), + username: "alice".to_string(), + password: String::new(), + ..WebDavSyncSettings::default() + }; + let existing = Some(WebDavSyncSettings { + password: "secret".to_string(), + ..WebDavSyncSettings::default() + }); + let resolved = resolve_password_for_request(incoming, existing, true); + assert_eq!(resolved.password, "secret"); + } + + #[test] + fn resolve_password_for_request_allows_explicit_empty_password() { + let incoming = WebDavSyncSettings { + base_url: "https://dav.example.com".to_string(), + username: "alice".to_string(), + password: String::new(), + ..WebDavSyncSettings::default() + }; + let existing = Some(WebDavSyncSettings { + password: "secret".to_string(), + ..WebDavSyncSettings::default() + }); + let resolved = resolve_password_for_request(incoming, existing, false); + assert!(resolved.password.is_empty()); + } + + #[test] + #[serial] + fn persist_sync_error_updates_status_without_overwriting_credentials() { + let test_home = std::env::temp_dir().join("cc-switch-sync-error-status-test"); + let _ = std::fs::remove_dir_all(&test_home); + std::fs::create_dir_all(&test_home).expect("create test home"); + std::env::set_var("CC_SWITCH_TEST_HOME", &test_home); + + crate::settings::update_settings(AppSettings::default()).expect("reset settings"); + let mut current = WebDavSyncSettings { + enabled: true, + base_url: "https://dav.example.com/dav/".to_string(), + username: "alice".to_string(), + password: "secret".to_string(), + remote_root: "cc-switch-sync".to_string(), + profile: "default".to_string(), + ..WebDavSyncSettings::default() + }; + crate::settings::set_webdav_sync_settings(Some(current.clone())) + .expect("seed webdav settings"); + + persist_sync_error( + &mut current, + &crate::error::AppError::Config("boom".to_string()), + ); + + let after = crate::settings::get_webdav_sync_settings().expect("read webdav settings"); + assert_eq!(after.base_url, "https://dav.example.com/dav/"); + assert_eq!(after.username, "alice"); + assert_eq!(after.password, "secret"); + assert_eq!(after.remote_root, "cc-switch-sync"); + assert_eq!(after.profile, "default"); + assert!( + after + .status + .last_error + .as_deref() + .unwrap_or_default() + .contains("boom"), + "status error should be updated" + ); + } + + #[test] + #[serial] + fn require_enabled_webdav_settings_rejects_disabled_config() { + let test_home = std::env::temp_dir().join("cc-switch-sync-enabled-disabled-test"); + let _ = std::fs::remove_dir_all(&test_home); + std::fs::create_dir_all(&test_home).expect("create test home"); + std::env::set_var("CC_SWITCH_TEST_HOME", &test_home); + + crate::settings::update_settings(AppSettings::default()).expect("reset settings"); + crate::settings::set_webdav_sync_settings(Some(WebDavSyncSettings { + enabled: false, + base_url: "https://dav.example.com/dav/".to_string(), + username: "alice".to_string(), + password: "secret".to_string(), + ..WebDavSyncSettings::default() + })) + .expect("seed disabled webdav settings"); + + let err = require_enabled_webdav_settings().expect_err("disabled settings should fail"); + assert!( + err.contains("disabled") || err.contains("未启用"), + "unexpected error: {err}" + ); + } + + #[test] + #[serial] + fn require_enabled_webdav_settings_returns_settings_when_enabled() { + let test_home = std::env::temp_dir().join("cc-switch-sync-enabled-ok-test"); + let _ = std::fs::remove_dir_all(&test_home); + std::fs::create_dir_all(&test_home).expect("create test home"); + std::env::set_var("CC_SWITCH_TEST_HOME", &test_home); + + crate::settings::update_settings(AppSettings::default()).expect("reset settings"); + crate::settings::set_webdav_sync_settings(Some(WebDavSyncSettings { + enabled: true, + base_url: "https://dav.example.com/dav/".to_string(), + username: "alice".to_string(), + password: "secret".to_string(), + ..WebDavSyncSettings::default() + })) + .expect("seed enabled webdav settings"); + + let settings = + require_enabled_webdav_settings().expect("enabled settings should be accepted"); + assert!(settings.enabled); + assert_eq!(settings.base_url, "https://dav.example.com/dav/"); + } +} diff --git a/src-tauri/src/commands/workspace.rs b/src-tauri/src/commands/workspace.rs new file mode 100644 index 000000000..95c27a322 --- /dev/null +++ b/src-tauri/src/commands/workspace.rs @@ -0,0 +1,60 @@ +use crate::config::write_text_file; +use crate::openclaw_config::get_openclaw_dir; + +/// Allowed workspace filenames (whitelist for security) +const ALLOWED_FILES: &[&str] = &[ + "AGENTS.md", + "SOUL.md", + "USER.md", + "IDENTITY.md", + "TOOLS.md", + "MEMORY.md", + "HEARTBEAT.md", + "BOOTSTRAP.md", + "BOOT.md", +]; + +fn validate_filename(filename: &str) -> Result<(), String> { + if !ALLOWED_FILES.contains(&filename) { + return Err(format!( + "Invalid workspace filename: {filename}. Allowed: {}", + ALLOWED_FILES.join(", ") + )); + } + Ok(()) +} + +/// Read an OpenClaw workspace file content. +/// Returns None if the file does not exist. +#[tauri::command] +pub async fn read_workspace_file(filename: String) -> Result, String> { + validate_filename(&filename)?; + + let path = get_openclaw_dir().join("workspace").join(&filename); + + if !path.exists() { + return Ok(None); + } + + std::fs::read_to_string(&path) + .map(Some) + .map_err(|e| format!("Failed to read workspace file {filename}: {e}")) +} + +/// Write content to an OpenClaw workspace file (atomic write). +/// Creates the workspace directory if it does not exist. +#[tauri::command] +pub async fn write_workspace_file(filename: String, content: String) -> Result<(), String> { + validate_filename(&filename)?; + + let workspace_dir = get_openclaw_dir().join("workspace"); + + // Ensure workspace directory exists + std::fs::create_dir_all(&workspace_dir) + .map_err(|e| format!("Failed to create workspace directory: {e}"))?; + + let path = workspace_dir.join(&filename); + + write_text_file(&path, &content) + .map_err(|e| format!("Failed to write workspace file {filename}: {e}")) +} diff --git a/src-tauri/src/database/backup.rs b/src-tauri/src/database/backup.rs index bd3df4181..484b22569 100644 --- a/src-tauri/src/database/backup.rs +++ b/src-tauri/src/database/backup.rs @@ -16,10 +16,15 @@ use tempfile::NamedTempFile; const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出"; impl Database { + /// 导出为 SQLite 兼容的 SQL 文本(内存字符串) + pub fn export_sql_string(&self) -> Result { + let snapshot = self.snapshot_to_memory()?; + Self::dump_sql(&snapshot) + } + /// 导出为 SQLite 兼容的 SQL 文本 pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> { - let snapshot = self.snapshot_to_memory()?; - let dump = Self::dump_sql(&snapshot)?; + let dump = self.export_sql_string()?; if let Some(parent) = target_path.parent() { fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; @@ -38,6 +43,12 @@ impl Database { } let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?; + let sql_content = sql_raw.trim_start_matches('\u{feff}'); + self.import_sql_string(sql_content) + } + + /// 从 SQL 字符串导入,返回生成的备份 ID(若无备份则为空字符串) + pub fn import_sql_string(&self, sql_raw: &str) -> Result { let sql_content = sql_raw.trim_start_matches('\u{feff}'); Self::validate_cc_switch_sql_export(sql_content)?; diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index a690cd0ef..8e37b3d60 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -297,6 +297,15 @@ fn schema_migration_v4_adds_pricing_model_columns() { r#" CREATE TABLE proxy_config (app_type TEXT PRIMARY KEY); CREATE TABLE proxy_request_logs (request_id TEXT PRIMARY KEY, model TEXT NOT NULL); + CREATE TABLE mcp_servers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + server_config TEXT NOT NULL, + enabled_claude INTEGER NOT NULL DEFAULT 0, + enabled_codex INTEGER NOT NULL DEFAULT 0, + enabled_gemini INTEGER NOT NULL DEFAULT 0, + enabled_opencode INTEGER NOT NULL DEFAULT 0 + ); "#, ) .expect("seed v4 schema"); diff --git a/src-tauri/src/deeplink/mcp.rs b/src-tauri/src/deeplink/mcp.rs index 04d3f11b9..37df96579 100644 --- a/src-tauri/src/deeplink/mcp.rs +++ b/src-tauri/src/deeplink/mcp.rs @@ -175,6 +175,10 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result { "codex" => apps.codex = true, "gemini" => apps.gemini = true, "opencode" => apps.opencode = true, + "openclaw" => { + // OpenClaw doesn't support MCP, ignore silently + log::debug!("OpenClaw doesn't support MCP, ignoring in apps parameter"); + } other => { return Err(AppError::InvalidInput(format!( "Invalid app in 'apps': {other}" diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index ec75d55e5..cdb0f3e39 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -146,6 +146,7 @@ pub(crate) fn build_provider_from_request( AppType::Codex => build_codex_settings(request), AppType::Gemini => build_gemini_settings(request), AppType::OpenCode => build_opencode_settings(request), + AppType::OpenClaw => build_openclaw_settings(request), }; // Build usage script configuration if provided @@ -391,6 +392,35 @@ fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value }) } +fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value { + let endpoint = get_primary_endpoint(request); + + // Build OpenClaw provider config + // Format: { baseUrl, apiKey, api, models } + let mut config = serde_json::Map::new(); + + if !endpoint.is_empty() { + config.insert("baseUrl".to_string(), json!(endpoint)); + } + + if let Some(api_key) = &request.api_key { + config.insert("apiKey".to_string(), json!(api_key)); + } + + // Default to OpenAI-compatible API + config.insert("api".to_string(), json!("openai-completions")); + + // Build models array + if let Some(model) = &request.model { + config.insert( + "models".to_string(), + json!([{ "id": model, "name": model }]), + ); + } + + json!(config) +} + // ============================================================================= // Config Merge Logic // ============================================================================= @@ -452,6 +482,10 @@ pub fn parse_and_merge_config( "claude" => merge_claude_config(&mut merged, &config_value)?, "codex" => merge_codex_config(&mut merged, &config_value)?, "gemini" => merge_gemini_config(&mut merged, &config_value)?, + // Additive mode apps use JSON config directly; pass through as-is + "openclaw" | "opencode" => { + merge_additive_config(&mut merged, &config_value)?; + } "" => { // No app specified, skip merging return Ok(merged); @@ -623,6 +657,47 @@ fn merge_gemini_config( Ok(()) } +/// Merge configuration for additive mode apps (OpenClaw, OpenCode) +/// +/// These apps use JSON config directly, so we only extract common fields +/// (api_key, endpoint, model) from the config if not already set in URL params. +fn merge_additive_config( + request: &mut DeepLinkImportRequest, + config: &serde_json::Value, +) -> Result<(), AppError> { + // Extract api_key from config if not provided in URL + if request.api_key.as_ref().is_none_or(|s| s.is_empty()) { + if let Some(api_key) = config + .get("apiKey") + .or_else(|| config.get("api_key")) + .and_then(|v| v.as_str()) + { + request.api_key = Some(api_key.to_string()); + } + } + + // Extract endpoint from config if not provided in URL + if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) { + if let Some(base_url) = config + .get("baseUrl") + .or_else(|| config.get("base_url")) + .or_else(|| config.get("options").and_then(|o| o.get("baseURL"))) + .and_then(|v| v.as_str()) + { + request.endpoint = Some(base_url.to_string()); + } + } + + // Auto-fill homepage from endpoint + if request.homepage.as_ref().is_none_or(|s| s.is_empty()) { + if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) { + request.homepage = infer_homepage_from_endpoint(endpoint); + } + } + + Ok(()) +} + /// Extract base_url from Codex TOML config fn extract_codex_base_url(toml_value: &toml::Value) -> Option { // Try to find base_url in model_providers section diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e2657745a..92edd4aaf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ mod gemini_config; mod gemini_mcp; mod init_status; mod mcp; +mod openclaw_config; mod opencode_config; mod panic_hook; mod prompt; @@ -500,7 +501,7 @@ pub fn run() { log::info!("✓ Imported {count} OpenCode provider(s) from live config"); } Ok(_) => log::debug!("○ No OpenCode providers found to import"), - Err(e) => log::debug!("○ Failed to import OpenCode providers: {e}"), + Err(e) => log::warn!("○ Failed to import OpenCode providers: {e}"), } // 2.2 OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入) @@ -525,6 +526,17 @@ pub fn run() { } } + // 2.3 OpenClaw 供应商导入(累加式模式,需特殊处理) + // OpenClaw 与 OpenCode 类似:配置文件中可同时存在多个供应商 + // 需要遍历 models.providers 字段下的每个供应商并导入 + match crate::services::provider::import_openclaw_providers_from_live(&app_state) { + Ok(count) if count > 0 => { + log::info!("✓ Imported {count} OpenClaw provider(s) from live config"); + } + Ok(_) => log::debug!("○ No OpenClaw providers found to import"), + Err(e) => log::warn!("○ Failed to import OpenClaw providers: {e}"), + } + // 3. 导入 MCP 服务器配置(表空时触发) if app_state.db.is_mcp_table_empty().unwrap_or(false) { log::info!("MCP table empty, importing from live configurations..."); @@ -570,6 +582,8 @@ pub fn run() { crate::app_config::AppType::Claude, crate::app_config::AppType::Codex, crate::app_config::AppType::Gemini, + crate::app_config::AppType::OpenCode, + crate::app_config::AppType::OpenClaw, ] { match crate::services::prompt::PromptService::import_from_file_on_first_launch( &app_state, @@ -886,6 +900,11 @@ pub fn run() { // theirs: config import/export and dialogs commands::export_config_to_file, commands::import_config_from_file, + commands::webdav_test_connection, + commands::webdav_sync_upload, + commands::webdav_sync_download, + commands::webdav_sync_save_settings, + commands::webdav_sync_fetch_remote_info, commands::save_file_dialog, commands::open_file_dialog, commands::open_zip_file_dialog, @@ -987,6 +1006,19 @@ pub fn run() { // OpenCode specific commands::import_opencode_providers_from_live, commands::get_opencode_live_provider_ids, + // OpenClaw specific + commands::import_openclaw_providers_from_live, + commands::get_openclaw_live_provider_ids, + commands::get_openclaw_default_model, + commands::set_openclaw_default_model, + commands::get_openclaw_model_catalog, + commands::set_openclaw_model_catalog, + commands::get_openclaw_agents_defaults, + commands::set_openclaw_agents_defaults, + commands::get_openclaw_env, + commands::set_openclaw_env, + commands::get_openclaw_tools, + commands::set_openclaw_tools, // Global upstream proxy commands::get_global_proxy_url, commands::set_global_proxy_url, @@ -999,6 +1031,9 @@ pub fn run() { commands::get_current_omo_provider_id, commands::get_omo_provider_count, commands::disable_current_omo, + // Workspace files (OpenClaw) + commands::read_workspace_file, + commands::write_workspace_file, ]); let app = builder diff --git a/src-tauri/src/openclaw_config.rs b/src-tauri/src/openclaw_config.rs new file mode 100644 index 000000000..20b18ccf9 --- /dev/null +++ b/src-tauri/src/openclaw_config.rs @@ -0,0 +1,546 @@ +//! OpenClaw 配置文件读写模块 +//! +//! 处理 `~/.openclaw/openclaw.json` 配置文件的读写操作(JSON5 格式)。 +//! OpenClaw 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。 +//! +//! ## 配置文件格式 +//! +//! ```json5 +//! { +//! // 模型供应商配置(映射为 CC Switch 的"供应商") +//! models: { +//! mode: "merge", +//! providers: { +//! "custom-provider": { +//! baseUrl: "https://api.example.com/v1", +//! apiKey: "${API_KEY}", +//! api: "openai-completions", +//! models: [{ id: "model-id", name: "Model Name" }] +//! } +//! } +//! }, +//! // 环境变量配置 +//! env: { +//! ANTHROPIC_API_KEY: "sk-...", +//! vars: { ... } +//! }, +//! // Agent 默认模型配置 +//! agents: { +//! defaults: { +//! model: { +//! primary: "provider/model", +//! fallbacks: ["provider2/model2"] +//! } +//! } +//! } +//! } +//! ``` + +use crate::config::write_json_file; +use crate::error::AppError; +use crate::settings::get_openclaw_override_dir; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use std::collections::HashMap; +use std::path::PathBuf; + +// ============================================================================ +// Path Functions +// ============================================================================ + +/// 获取 OpenClaw 配置目录 +/// +/// 默认路径: `~/.openclaw/` +/// 可通过 settings.openclaw_config_dir 覆盖 +pub fn get_openclaw_dir() -> PathBuf { + if let Some(override_dir) = get_openclaw_override_dir() { + return override_dir; + } + + // 所有平台统一使用 ~/.openclaw + dirs::home_dir() + .map(|h| h.join(".openclaw")) + .unwrap_or_else(|| PathBuf::from(".openclaw")) +} + +/// 获取 OpenClaw 配置文件路径 +/// +/// 返回 `~/.openclaw/openclaw.json` +pub fn get_openclaw_config_path() -> PathBuf { + get_openclaw_dir().join("openclaw.json") +} + +// ============================================================================ +// Type Definitions +// ============================================================================ + +/// OpenClaw 供应商配置(对应 models.providers 中的条目) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenClawProviderConfig { + /// API 基础 URL + #[serde(skip_serializing_if = "Option::is_none")] + pub base_url: Option, + + /// API Key(支持环境变量引用 ${VAR_NAME}) + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key: Option, + + /// API 类型(如 "openai-completions", "anthropic" 等) + #[serde(skip_serializing_if = "Option::is_none")] + pub api: Option, + + /// 支持的模型列表 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub models: Vec, + + /// Other custom fields (preserve unknown fields) + #[serde(flatten)] + pub extra: HashMap, +} + +/// OpenClaw 模型条目 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenClawModelEntry { + /// 模型 ID + pub id: String, + + /// 模型显示名称 + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// 模型别名(用于快捷引用) + #[serde(skip_serializing_if = "Option::is_none")] + pub alias: Option, + + /// 模型成本(输入/输出价格) + #[serde(skip_serializing_if = "Option::is_none")] + pub cost: Option, + + /// 上下文窗口大小 + #[serde(skip_serializing_if = "Option::is_none")] + pub context_window: Option, + + /// Other custom fields (preserve unknown fields) + #[serde(flatten)] + pub extra: HashMap, +} + +/// OpenClaw 模型成本配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenClawModelCost { + /// 输入价格(每百万 token) + pub input: f64, + + /// 输出价格(每百万 token) + pub output: f64, + + /// Other custom fields (preserve unknown fields) + #[serde(flatten)] + pub extra: HashMap, +} + +/// OpenClaw 默认模型配置(agents.defaults.model) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenClawDefaultModel { + /// 主模型 ID(格式:provider/model) + pub primary: String, + + /// 回退模型列表 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fallbacks: Vec, + + /// Other custom fields (preserve unknown fields) + #[serde(flatten)] + pub extra: HashMap, +} + +/// OpenClaw 模型目录条目(agents.defaults.models 中的值) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenClawModelCatalogEntry { + /// 模型别名(用于 UI 显示) + #[serde(skip_serializing_if = "Option::is_none")] + pub alias: Option, + + /// Other custom fields (preserve unknown fields) + #[serde(flatten)] + pub extra: HashMap, +} + +/// OpenClaw agents.defaults 配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenClawAgentsDefaults { + /// 默认模型配置 + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// 模型目录/允许列表(键为 provider/model 格式) + #[serde(skip_serializing_if = "Option::is_none")] + pub models: Option>, + + /// Other custom fields (preserve unknown fields) + #[serde(flatten)] + pub extra: HashMap, +} + +/// OpenClaw agents 顶层配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] +pub struct OpenClawAgents { + /// 默认配置 + #[serde(skip_serializing_if = "Option::is_none")] + pub defaults: Option, + + /// Other custom fields (preserve unknown fields) + #[serde(flatten)] + pub extra: HashMap, +} + +// ============================================================================ +// Core Read/Write Functions +// ============================================================================ + +/// 读取 OpenClaw 配置文件 +/// +/// 支持 JSON5 格式,返回完整的配置 JSON 对象 +pub fn read_openclaw_config() -> Result { + let path = get_openclaw_config_path(); + + if !path.exists() { + // Return empty config structure + return Ok(json!({ + "models": { + "mode": "merge", + "providers": {} + } + })); + } + + let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + + // 尝试 JSON5 解析(支持注释和尾随逗号) + json5::from_str(&content) + .map_err(|e| AppError::Config(format!("Failed to parse OpenClaw config as JSON5: {}", e))) +} + +/// 写入 OpenClaw 配置文件(原子写入) +/// +/// 使用标准 JSON 格式写入(JSON5 是 JSON 的超集) +pub fn write_openclaw_config(config: &Value) -> Result<(), AppError> { + let path = get_openclaw_config_path(); + + // 确保目录存在 + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + // 复用统一的原子写入逻辑 + write_json_file(&path, config)?; + + log::debug!("OpenClaw config written to {path:?}"); + Ok(()) +} + +// ============================================================================ +// Provider Functions (Untyped - for raw JSON operations) +// ============================================================================ + +/// 获取所有供应商配置(原始 JSON) +/// +/// 从 `models.providers` 读取 +pub fn get_providers() -> Result, AppError> { + let config = read_openclaw_config()?; + Ok(config + .get("models") + .and_then(|m| m.get("providers")) + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default()) +} + +/// 设置供应商配置(原始 JSON) +/// +/// 写入到 `models.providers` +pub fn set_provider(id: &str, provider_config: Value) -> Result<(), AppError> { + let mut full_config = read_openclaw_config()?; + + // 确保 models 结构存在 + if full_config.get("models").is_none() { + full_config["models"] = json!({ + "mode": "merge", + "providers": {} + }); + } + + // 确保 providers 对象存在 + if full_config["models"].get("providers").is_none() { + full_config["models"]["providers"] = json!({}); + } + + // 设置供应商 + if let Some(providers) = full_config["models"] + .get_mut("providers") + .and_then(|v| v.as_object_mut()) + { + providers.insert(id.to_string(), provider_config); + } + + write_openclaw_config(&full_config) +} + +/// 删除供应商配置 +pub fn remove_provider(id: &str) -> Result<(), AppError> { + let mut config = read_openclaw_config()?; + + if let Some(providers) = config + .get_mut("models") + .and_then(|m| m.get_mut("providers")) + .and_then(|v| v.as_object_mut()) + { + providers.remove(id); + } + + write_openclaw_config(&config) +} + +// ============================================================================ +// Provider Functions (Typed) +// ============================================================================ + +/// 获取所有供应商配置(类型化) +pub fn get_typed_providers() -> Result, AppError> { + let providers = get_providers()?; + let mut result = IndexMap::new(); + + for (id, value) in providers { + match serde_json::from_value::(value.clone()) { + Ok(config) => { + result.insert(id, config); + } + Err(e) => { + log::warn!("Failed to parse OpenClaw provider '{id}': {e}"); + // Skip invalid providers but continue + } + } + } + + Ok(result) +} + +/// 设置供应商配置(类型化) +pub fn set_typed_provider(id: &str, config: &OpenClawProviderConfig) -> Result<(), AppError> { + let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?; + set_provider(id, value) +} + +// ============================================================================ +// Agents Configuration Functions +// ============================================================================ + +/// 读取默认模型配置(agents.defaults.model) +pub fn get_default_model() -> Result, AppError> { + let config = read_openclaw_config()?; + + let Some(model_value) = config + .get("agents") + .and_then(|a| a.get("defaults")) + .and_then(|d| d.get("model")) + else { + return Ok(None); + }; + + let model = serde_json::from_value(model_value.clone()) + .map_err(|e| AppError::Config(format!("Failed to parse agents.defaults.model: {e}")))?; + + Ok(Some(model)) +} + +/// 设置默认模型配置(agents.defaults.model) +pub fn set_default_model(model: &OpenClawDefaultModel) -> Result<(), AppError> { + let mut config = read_openclaw_config()?; + + // Ensure agents.defaults path exists, preserving unknown fields + ensure_agents_defaults_path(&mut config); + + let model_value = + serde_json::to_value(model).map_err(|e| AppError::JsonSerialize { source: e })?; + + config["agents"]["defaults"]["model"] = model_value; + + write_openclaw_config(&config) +} + +/// 读取模型目录/允许列表(agents.defaults.models) +pub fn get_model_catalog() -> Result>, AppError> { + let config = read_openclaw_config()?; + + let Some(models_value) = config + .get("agents") + .and_then(|a| a.get("defaults")) + .and_then(|d| d.get("models")) + else { + return Ok(None); + }; + + let catalog = serde_json::from_value(models_value.clone()) + .map_err(|e| AppError::Config(format!("Failed to parse agents.defaults.models: {e}")))?; + + Ok(Some(catalog)) +} + +/// 设置模型目录/允许列表(agents.defaults.models) +pub fn set_model_catalog( + catalog: &HashMap, +) -> Result<(), AppError> { + let mut config = read_openclaw_config()?; + + // Ensure agents.defaults path exists, preserving unknown fields + ensure_agents_defaults_path(&mut config); + + let catalog_value = + serde_json::to_value(catalog).map_err(|e| AppError::JsonSerialize { source: e })?; + + config["agents"]["defaults"]["models"] = catalog_value; + + write_openclaw_config(&config) +} + +/// Ensure the `agents.defaults` path exists in the config, +/// preserving any existing unknown fields. +fn ensure_agents_defaults_path(config: &mut Value) { + if config.get("agents").is_none() { + config["agents"] = json!({}); + } + if config["agents"].get("defaults").is_none() { + config["agents"]["defaults"] = json!({}); + } +} + +// ============================================================================ +// Full Agents Defaults Functions +// ============================================================================ + +/// Read the full agents.defaults config +pub fn get_agents_defaults() -> Result, AppError> { + let config = read_openclaw_config()?; + + let Some(defaults_value) = config.get("agents").and_then(|a| a.get("defaults")) else { + return Ok(None); + }; + + let defaults = serde_json::from_value(defaults_value.clone()) + .map_err(|e| AppError::Config(format!("Failed to parse agents.defaults: {e}")))?; + + Ok(Some(defaults)) +} + +/// Write the full agents.defaults config +pub fn set_agents_defaults(defaults: &OpenClawAgentsDefaults) -> Result<(), AppError> { + let mut config = read_openclaw_config()?; + + if config.get("agents").is_none() { + config["agents"] = json!({}); + } + + let value = + serde_json::to_value(defaults).map_err(|e| AppError::JsonSerialize { source: e })?; + + config["agents"]["defaults"] = value; + + write_openclaw_config(&config) +} + +// ============================================================================ +// Env Configuration +// ============================================================================ + +/// OpenClaw env configuration (env section of openclaw.json) +/// +/// Stores environment variables like API keys and custom vars. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenClawEnvConfig { + /// All environment variable key-value pairs + #[serde(flatten)] + pub vars: HashMap, +} + +/// Read the env config section +pub fn get_env_config() -> Result { + let config = read_openclaw_config()?; + + let Some(env_value) = config.get("env") else { + return Ok(OpenClawEnvConfig { + vars: HashMap::new(), + }); + }; + + serde_json::from_value(env_value.clone()) + .map_err(|e| AppError::Config(format!("Failed to parse env config: {e}"))) +} + +/// Write the env config section +pub fn set_env_config(env: &OpenClawEnvConfig) -> Result<(), AppError> { + let mut config = read_openclaw_config()?; + + let value = serde_json::to_value(env).map_err(|e| AppError::JsonSerialize { source: e })?; + + config["env"] = value; + + write_openclaw_config(&config) +} + +// ============================================================================ +// Tools Configuration +// ============================================================================ + +/// OpenClaw tools configuration (tools section of openclaw.json) +/// +/// Controls tool permissions with profile-based allow/deny lists. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenClawToolsConfig { + /// Active permission profile (e.g. "default", "strict", "permissive") + #[serde(skip_serializing_if = "Option::is_none")] + pub profile: Option, + + /// Allowed tool patterns + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allow: Vec, + + /// Denied tool patterns + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub deny: Vec, + + /// Other custom fields (preserve unknown fields) + #[serde(flatten)] + pub extra: HashMap, +} + +/// Read the tools config section +pub fn get_tools_config() -> Result { + let config = read_openclaw_config()?; + + let Some(tools_value) = config.get("tools") else { + return Ok(OpenClawToolsConfig { + profile: None, + allow: Vec::new(), + deny: Vec::new(), + extra: HashMap::new(), + }); + }; + + serde_json::from_value(tools_value.clone()) + .map_err(|e| AppError::Config(format!("Failed to parse tools config: {e}"))) +} + +/// Write the tools config section +pub fn set_tools_config(tools: &OpenClawToolsConfig) -> Result<(), AppError> { + let mut config = read_openclaw_config()?; + + let value = serde_json::to_value(tools).map_err(|e| AppError::JsonSerialize { source: e })?; + + config["tools"] = value; + + write_openclaw_config(&config) +} diff --git a/src-tauri/src/prompt_files.rs b/src-tauri/src/prompt_files.rs index 1395599a3..24e6a9b90 100644 --- a/src-tauri/src/prompt_files.rs +++ b/src-tauri/src/prompt_files.rs @@ -5,6 +5,7 @@ use crate::codex_config::get_codex_auth_path; use crate::config::get_claude_settings_path; use crate::error::AppError; use crate::gemini_config::get_gemini_dir; +use crate::openclaw_config::get_openclaw_dir; use crate::opencode_config::get_opencode_dir; /// 返回指定应用所使用的提示词文件路径。 @@ -14,6 +15,7 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?, AppType::Gemini => get_gemini_dir(), AppType::OpenCode => get_opencode_dir(), + AppType::OpenClaw => get_openclaw_dir(), }; let filename = match app { @@ -21,6 +23,7 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::Codex => "AGENTS.md", AppType::Gemini => "GEMINI.md", AppType::OpenCode => "AGENTS.md", + AppType::OpenClaw => "AGENTS.md", // OpenClaw uses AGENTS.md for agent instructions }; Ok(base_dir.join(filename)) diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 61be1087e..a4bb3de76 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -136,6 +136,10 @@ impl ProviderType { // OpenCode doesn't support proxy, but return a default type for completeness ProviderType::Codex // Fallback to Codex-like type } + AppType::OpenClaw => { + // OpenClaw doesn't support proxy, but return a default type for completeness + ProviderType::Codex // Fallback to Codex-like type + } } } @@ -184,6 +188,10 @@ pub fn get_adapter(app_type: &AppType) -> Box { // OpenCode doesn't support proxy, fallback to Codex adapter Box::new(CodexAdapter::new()) } + AppType::OpenClaw => { + // OpenClaw doesn't support proxy, fallback to Codex adapter + Box::new(CodexAdapter::new()) + } } } diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index cf8dd7c8c..5118c2bbe 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -113,6 +113,8 @@ pub struct ProxyTakeoverStatus { pub claude: bool, pub codex: bool, pub gemini: bool, + pub opencode: bool, + pub openclaw: bool, } /// API 格式类型(预留,当前不需要格式转换) diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index a226e93ae..d354cccb1 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -126,6 +126,10 @@ impl ConfigService { // OpenCode uses additive mode, no live sync needed // OpenCode providers are managed directly in the config file } + AppType::OpenClaw => { + // OpenClaw uses additive mode, no live sync needed + // OpenClaw providers are managed directly in the config file + } } Ok(()) diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index 8d1211bbc..1080480b3 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -123,6 +123,11 @@ impl McpService { &server.server, )?; } + AppType::OpenClaw => { + // OpenClaw MCP support is still in development (Issue #4834) + // Skip for now + log::debug!("OpenClaw MCP support is still in development, skipping sync"); + } } Ok(()) } @@ -148,6 +153,10 @@ impl McpService { AppType::OpenCode => { mcp::remove_server_from_opencode(id)?; } + AppType::OpenClaw => { + // OpenClaw MCP support is still in development + log::debug!("OpenClaw MCP support is still in development, skipping remove"); + } } Ok(()) } diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 0875fdeb3..7a49e531c 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -10,6 +10,8 @@ pub mod skill; pub mod speedtest; pub mod stream_check; pub mod usage_stats; +pub mod webdav; +pub mod webdav_sync; pub use config::ConfigService; pub use mcp::McpService; diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 52437e6dd..da4e15fa6 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -191,6 +191,48 @@ 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 +382,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) + } } } @@ -348,6 +405,12 @@ pub fn read_live_settings(app_type: AppType) -> Result { /// Returns `Ok(true)` if a provider was actually imported, /// `Ok(false)` if skipped (providers already exist for this app). pub fn import_default_config(state: &AppState, app_type: AppType) -> Result { + // Additive mode apps (OpenCode, OpenClaw) should use their dedicated + // import_xxx_providers_from_live functions, not this generic default config import + if app_type.is_additive_mode() { + return Ok(false); + } + { let providers = state.db.get_all_providers(app_type.as_str())?; if !providers.is_empty() { @@ -415,23 +478,9 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result { - // OpenCode uses additive mode - import from live is not the same pattern - // For now, return an empty config structure - use crate::opencode_config::{get_opencode_config_path, read_opencode_config}; - - let config_path = get_opencode_config_path(); - if !config_path.exists() { - return Err(AppError::localized( - "opencode.live.missing", - "OpenCode 配置文件不存在", - "OpenCode configuration file is missing", - )); - } - - // For OpenCode, we return the full config - but note that OpenCode - // uses additive mode, so importing defaults works differently - read_opencode_config()? + // OpenCode and OpenClaw use additive mode and are handled by early return above + AppType::OpenCode | AppType::OpenClaw => { + unreachable!("additive mode apps are handled by early return") } }; @@ -609,3 +658,87 @@ 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 { + // Validate: skip entries with empty id or no models + if id.trim().is_empty() { + log::warn!("Skipping OpenClaw provider with empty id"); + continue; + } + if config.models.is_empty() { + log::warn!("Skipping OpenClaw provider '{id}': no models defined"); + continue; + } + + // 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; + + // Check if OpenClaw config directory exists + if !openclaw_config::get_openclaw_dir().exists() { + log::debug!("OpenClaw config directory doesn't exist, skipping removal of '{provider_id}'"); + return Ok(()); + } + + 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 c5f6abef7..5f523c722 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/services/proxy.rs b/src-tauri/src/services/proxy.rs index 0f429b030..5325f4af1 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -210,11 +210,16 @@ impl ProxyService { .await .map(|c| c.enabled) .unwrap_or(false); + // OpenCode and OpenClaw don't support proxy features, always return false + let opencode_enabled = false; + let openclaw_enabled = false; Ok(ProxyTakeoverStatus { claude: claude_enabled, codex: codex_enabled, gemini: gemini_enabled, + opencode: opencode_enabled, + openclaw: openclaw_enabled, }) } @@ -372,6 +377,10 @@ impl ProxyService { // OpenCode doesn't support proxy features return Err("OpenCode 不支持代理功能".to_string()); } + AppType::OpenClaw => { + // OpenClaw doesn't support proxy features + return Err("OpenClaw 不支持代理功能".to_string()); + } }; self.sync_live_config_to_provider(app_type, &live_config) @@ -588,6 +597,9 @@ impl ProxyService { AppType::OpenCode => { // OpenCode doesn't support proxy features, skip silently } + AppType::OpenClaw => { + // OpenClaw doesn't support proxy features, skip silently + } } Ok(()) @@ -770,6 +782,10 @@ impl ProxyService { // OpenCode doesn't support proxy features return Err("OpenCode 不支持代理功能".to_string()); } + AppType::OpenClaw => { + // OpenClaw doesn't support proxy features + return Err("OpenClaw 不支持代理功能".to_string()); + } }; let json_str = serde_json::to_string(&config) @@ -982,6 +998,10 @@ impl ProxyService { // OpenCode doesn't support proxy features return Err("OpenCode 不支持代理功能".to_string()); } + AppType::OpenClaw => { + // OpenClaw doesn't support proxy features + return Err("OpenClaw 不支持代理功能".to_string()); + } } Ok(()) @@ -1068,6 +1088,9 @@ impl ProxyService { AppType::OpenCode => { // OpenCode doesn't support proxy features, skip silently } + AppType::OpenClaw => { + // OpenClaw doesn't support proxy features, skip silently + } } Ok(()) @@ -1103,6 +1126,9 @@ impl ProxyService { AppType::OpenCode => { // OpenCode doesn't support proxy features, skip silently } + AppType::OpenClaw => { + // OpenClaw doesn't support proxy features, skip silently + } } Ok(()) @@ -1186,6 +1212,10 @@ impl ProxyService { // OpenCode doesn't support proxy features Err("OpenCode 不支持代理功能".to_string()) } + AppType::OpenClaw => { + // OpenClaw doesn't support proxy features + Err("OpenClaw 不支持代理功能".to_string()) + } } } @@ -1207,6 +1237,10 @@ impl ProxyService { // OpenCode doesn't support proxy takeover false } + AppType::OpenClaw => { + // OpenClaw doesn't support proxy takeover + false + } } } @@ -1250,6 +1284,10 @@ impl ProxyService { // OpenCode doesn't support proxy features Ok(()) } + AppType::OpenClaw => { + // OpenClaw doesn't support proxy features + Ok(()) + } } } diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index a10f9278f..580467848 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -284,6 +284,11 @@ impl SkillService { return Ok(custom.join("skills")); } } + AppType::OpenClaw => { + if let Some(custom) = crate::settings::get_openclaw_override_dir() { + return Ok(custom.join("skills")); + } + } } // 默认路径:回退到用户主目录下的标准位置 @@ -298,6 +303,7 @@ impl SkillService { AppType::Codex => home.join(".codex").join("skills"), AppType::Gemini => home.join(".gemini").join("skills"), AppType::OpenCode => home.join(".config").join("opencode").join("skills"), + AppType::OpenClaw => home.join(".openclaw").join("skills"), }) } diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index c7afaa55e..b5bf88887 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -240,6 +240,14 @@ impl StreamCheckService { "OpenCode does not support health check yet", )); } + AppType::OpenClaw => { + // OpenClaw doesn't support stream check yet + return Err(AppError::localized( + "openclaw_no_stream_check", + "OpenClaw 暂不支持健康检查", + "OpenClaw does not support health check yet", + )); + } }; let response_time = start.elapsed().as_millis() as u64; @@ -567,6 +575,11 @@ impl StreamCheckService { // Try to extract first model from the models object Self::extract_opencode_model(provider).unwrap_or_else(|| "gpt-4o".to_string()) } + AppType::OpenClaw => { + // OpenClaw uses models array in settings_config + // Try to extract first model from the models array + Self::extract_openclaw_model(provider).unwrap_or_else(|| "gpt-4o".to_string()) + } } } @@ -580,6 +593,21 @@ impl StreamCheckService { models.keys().next().map(|s| s.to_string()) } + fn extract_openclaw_model(provider: &Provider) -> Option { + // OpenClaw uses models array: [{ "id": "model-id", "name": "Model Name" }] + let models = provider + .settings_config + .get("models") + .and_then(|m| m.as_array())?; + + // Return the first model ID from the models array + models + .first() + .and_then(|m| m.get("id")) + .and_then(|id| id.as_str()) + .map(|s| s.to_string()) + } + fn extract_env_model(provider: &Provider, key: &str) -> Option { provider .settings_config diff --git a/src-tauri/src/services/webdav.rs b/src-tauri/src/services/webdav.rs new file mode 100644 index 000000000..c9d376e70 --- /dev/null +++ b/src-tauri/src/services/webdav.rs @@ -0,0 +1,507 @@ +//! WebDAV HTTP transport layer. +//! +//! Low-level HTTP primitives for WebDAV operations (PUT, GET, HEAD, MKCOL, PROPFIND). +//! The sync protocol logic lives in [`super::webdav_sync`]. + +use reqwest::{Method, RequestBuilder, StatusCode, Url}; +use std::time::Duration; + +use crate::error::AppError; +use crate::proxy::http_client; + +const DEFAULT_TIMEOUT_SECS: u64 = 30; +/// Timeout for large file transfers (PUT/GET of db.sql, skills.zip). +const TRANSFER_TIMEOUT_SECS: u64 = 300; + +/// Auth pair: `(username, Some(password))`. +pub type WebDavAuth = Option<(String, Option)>; + +// ─── WebDAV extension methods ──────────────────────────────── + +fn method_propfind() -> Method { + Method::from_bytes(b"PROPFIND").expect("PROPFIND is a valid HTTP method") +} + +fn method_mkcol() -> Method { + Method::from_bytes(b"MKCOL").expect("MKCOL is a valid HTTP method") +} + +// ─── URL utilities ─────────────────────────────────────────── + +/// Parse and validate a WebDAV base URL (must be http or https). +pub fn parse_base_url(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(AppError::localized( + "webdav.base_url.required", + "WebDAV 地址不能为空", + "WebDAV URL is required.", + )); + } + let url = Url::parse(trimmed).map_err(|e| { + AppError::localized( + "webdav.base_url.invalid", + format!("WebDAV 地址无效: {e}"), + format!("Invalid WebDAV URL: {e}"), + ) + })?; + match url.scheme() { + "http" | "https" => Ok(url), + _ => Err(AppError::localized( + "webdav.base_url.scheme_invalid", + "WebDAV 仅支持 http/https 地址", + "WebDAV URL must use http or https.", + )), + } +} + +/// Build a full URL from a base URL string and path segments. +/// +/// Each segment is individually percent-encoded by the `url` crate. +pub fn build_remote_url(base_url: &str, segments: &[String]) -> Result { + let mut url = parse_base_url(base_url)?; + { + let mut path = url.path_segments_mut().map_err(|_| { + AppError::localized( + "webdav.base_url.unusable", + "WebDAV 地址格式不支持追加路径", + "WebDAV URL format does not support appending path segments.", + ) + })?; + path.pop_if_empty(); + for seg in segments { + path.push(seg); + } + } + Ok(url.to_string()) +} + +/// Split a slash-delimited path into non-empty segments. +pub fn path_segments(raw: &str) -> impl Iterator { + raw.trim_matches('/').split('/').filter(|s| !s.is_empty()) +} + +// ─── Auth ──────────────────────────────────────────────────── + +/// Build auth from username/password. Returns `None` if username is blank. +pub fn auth_from_credentials(username: &str, password: &str) -> WebDavAuth { + let user = username.trim(); + if user.is_empty() { + return None; + } + Some((user.to_string(), Some(password.to_string()))) +} + +/// Apply Basic-Auth to a request builder if auth is present. +fn apply_auth(builder: RequestBuilder, auth: &WebDavAuth) -> RequestBuilder { + match auth { + Some((user, pass)) => builder.basic_auth(user, pass.as_deref()), + None => builder, + } +} + +fn webdav_transport_error( + key: &'static str, + op_zh: &str, + op_en: &str, + target_url: &str, + err: &reqwest::Error, +) -> AppError { + let (zh_reason, en_reason) = if err.is_timeout() { + ("请求超时", "request timed out") + } else if err.is_connect() { + ("连接失败", "connection failed") + } else if err.is_request() { + ("请求构造失败", "request build failed") + } else { + ("网络请求失败", "network request failed") + }; + + let safe_url = redact_url(target_url); + AppError::localized( + key, + format!("WebDAV {op_zh}失败({zh_reason}): {safe_url}"), + format!("WebDAV {op_en} failed ({en_reason}): {safe_url}"), + ) +} + +// ─── HTTP operations ───────────────────────────────────────── + +/// Test WebDAV connectivity via PROPFIND Depth=0 on the base URL. +pub async fn test_connection(base_url: &str, auth: &WebDavAuth) -> Result<(), AppError> { + let url = parse_base_url(base_url)?; + let client = http_client::get(); + + let resp = apply_auth( + client + .request(method_propfind(), url) + .header("Depth", "0") + .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)), + auth, + ) + .send() + .await + .map_err(|e| { + webdav_transport_error( + "webdav.connection_failed", + "连接", + "connection", + base_url, + &e, + ) + })?; + + if resp.status().is_success() || resp.status() == StatusCode::MULTI_STATUS { + return Ok(()); + } + Err(webdav_status_error("PROPFIND", resp.status(), base_url)) +} + +/// Ensure a chain of remote directories exists. +/// +/// Uses optimistic MKCOL: try creating first, fall back to PROPFIND verification +/// on ambiguous responses. This halves the round-trips vs PROPFIND-first approach. +pub async fn ensure_remote_directories( + base_url: &str, + segments: &[String], + auth: &WebDavAuth, +) -> Result<(), AppError> { + if segments.is_empty() { + return Ok(()); + } + let client = http_client::get(); + + for depth in 1..=segments.len() { + let prefix = &segments[..depth]; + let url = build_remote_url(base_url, prefix)?; + let dir_url = if url.ends_with('/') { + url + } else { + format!("{url}/") + }; + + let resp = apply_auth( + client + .request(method_mkcol(), &dir_url) + .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)), + auth, + ) + .send() + .await + .map_err(|e| { + webdav_transport_error( + "webdav.mkcol_failed", + "MKCOL 请求", + "MKCOL request", + &dir_url, + &e, + ) + })?; + + let status = resp.status(); + match status { + s if s == StatusCode::CREATED || s.is_success() => { + log::info!("[WebDAV] MKCOL ok: {}", redact_url(&dir_url)); + } + // 405 commonly means "already exists" on many WebDAV servers + StatusCode::METHOD_NOT_ALLOWED => {} + // Ambiguous — verify directory actually exists via PROPFIND + s if s == StatusCode::CONFLICT || s.is_redirection() => { + if !propfind_exists(&client, &dir_url, auth).await? { + return Err(webdav_status_error("MKCOL", status, &dir_url)); + } + } + _ => { + return Err(webdav_status_error("MKCOL", status, &dir_url)); + } + } + } + Ok(()) +} + +/// PUT bytes to a remote WebDAV URL. +pub async fn put_bytes( + url: &str, + auth: &WebDavAuth, + bytes: Vec, + content_type: &str, +) -> Result<(), AppError> { + let client = http_client::get(); + let resp = apply_auth( + client + .put(url) + .header("Content-Type", content_type) + .body(bytes) + .timeout(Duration::from_secs(TRANSFER_TIMEOUT_SECS)), + auth, + ) + .send() + .await + .map_err(|e| { + webdav_transport_error( + "webdav.put_failed", + "PUT 请求", + "PUT request", + url, + &e, + ) + })?; + + if resp.status().is_success() { + return Ok(()); + } + Err(webdav_status_error("PUT", resp.status(), url)) +} + +/// GET bytes from a remote WebDAV URL. Returns `None` on 404. +/// +/// On success returns `(body_bytes, optional_etag)`. +pub async fn get_bytes( + url: &str, + auth: &WebDavAuth, +) -> Result, Option)>, AppError> { + let client = http_client::get(); + let resp = apply_auth( + client + .get(url) + .timeout(Duration::from_secs(TRANSFER_TIMEOUT_SECS)), + auth, + ) + .send() + .await + .map_err(|e| { + webdav_transport_error( + "webdav.get_failed", + "GET 请求", + "GET request", + url, + &e, + ) + })?; + + if resp.status() == StatusCode::NOT_FOUND { + return Ok(None); + } + if !resp.status().is_success() { + return Err(webdav_status_error("GET", resp.status(), url)); + } + let etag = resp + .headers() + .get("etag") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + let bytes = resp + .bytes() + .await + .map_err(|e| { + AppError::localized( + "webdav.response_read_failed", + format!("读取 WebDAV 响应失败: {e}"), + format!("Failed to read WebDAV response: {e}"), + ) + })?; + Ok(Some((bytes.to_vec(), etag))) +} + +/// HEAD request to retrieve the ETag. Returns `None` on 404. +pub async fn head_etag(url: &str, auth: &WebDavAuth) -> Result, AppError> { + let client = http_client::get(); + let resp = apply_auth( + client + .head(url) + .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)), + auth, + ) + .send() + .await + .map_err(|e| { + webdav_transport_error( + "webdav.head_failed", + "HEAD 请求", + "HEAD request", + url, + &e, + ) + })?; + + if resp.status() == StatusCode::NOT_FOUND { + return Ok(None); + } + if !resp.status().is_success() { + return Err(webdav_status_error("HEAD", resp.status(), url)); + } + Ok(resp + .headers() + .get("etag") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string())) +} + +// ─── Internal helpers ──────────────────────────────────────── + +/// PROPFIND Depth=0 to check if a remote resource exists. +async fn propfind_exists( + client: &reqwest::Client, + url: &str, + auth: &WebDavAuth, +) -> Result { + let resp = apply_auth( + client + .request(method_propfind(), url) + .header("Depth", "0") + .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)), + auth, + ) + .send() + .await; + match resp { + Ok(r) => Ok(r.status().is_success() || r.status() == StatusCode::MULTI_STATUS), + Err(e) => { + log::warn!( + "[WebDAV] PROPFIND check failed for {}: {e}", + redact_url(url) + ); + Ok(false) + } + } +} + +// ─── Service detection & error helpers ─────────────────────── + +/// Check if a URL points to Jianguoyun (坚果云). +pub fn is_jianguoyun(url: &str) -> bool { + Url::parse(url) + .ok() + .and_then(|u| u.host_str().map(|h| h.to_lowercase())) + .map(|host| host.contains("jianguoyun.com") || host.contains("nutstore")) + .unwrap_or(false) +} + +/// Build an `AppError` with service-specific hints for WebDAV failures. +pub fn webdav_status_error(op: &str, status: StatusCode, url: &str) -> AppError { + let safe_url = redact_url(url); + let mut zh = format!("WebDAV {op} 失败: {status} ({safe_url})"); + let mut en = format!("WebDAV {op} failed: {status} ({safe_url})"); + let jgy = is_jianguoyun(url); + + if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) { + if jgy { + zh.push_str( + "。坚果云请使用「第三方应用密码」,并确认地址指向 /dav/ 下的目录。", + ); + en.push_str( + ". For Jianguoyun, use an app-specific password and ensure the URL points under /dav/.", + ); + } else { + zh.push_str("。请检查 WebDAV 用户名、密码及目录读写权限。"); + en.push_str(". Please check WebDAV username/password and directory permissions."); + } + } else if jgy && (status == StatusCode::NOT_FOUND || status.is_redirection()) { + zh.push_str("。坚果云常见原因:地址不在 /dav/ 可写目录下。"); + en.push_str(". Common Jianguoyun cause: URL is outside a writable /dav/ directory."); + } else if op == "MKCOL" && status == StatusCode::CONFLICT { + if jgy { + zh.push_str( + "。坚果云不允许自动创建顶层文件夹,请先在网页端手动创建后重试。", + ); + en.push_str( + ". Jianguoyun does not allow creating top-level folders automatically; create it manually first.", + ); + } else { + zh.push_str("。请确认上级目录存在。"); + en.push_str(". Please ensure the parent directory exists."); + } + } + + AppError::localized("webdav.http.status", zh, en) +} + +fn redact_url(raw: &str) -> String { + match Url::parse(raw) { + Ok(mut parsed) => { + let _ = parsed.set_username(""); + let _ = parsed.set_password(None); + + let mut out = format!("{}://", parsed.scheme()); + if let Some(host) = parsed.host_str() { + out.push_str(host); + } + if let Some(port) = parsed.port() { + out.push(':'); + out.push_str(&port.to_string()); + } + out.push_str(parsed.path()); + + let mut keys: Vec = parsed.query_pairs().map(|(k, _)| k.into_owned()).collect(); + keys.sort(); + keys.dedup(); + if !keys.is_empty() { + out.push_str("?[keys:"); + out.push_str(&keys.join(",")); + out.push(']'); + } + out + } + Err(_) => raw.split('?').next().unwrap_or(raw).to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_remote_url_encodes_path_segments() { + let url = build_remote_url( + "https://dav.example.com/remote.php/dav/files/demo/", + &[ + "cc switch-sync".to_string(), + "v2".to_string(), + "default profile".to_string(), + "manifest.json".to_string(), + ], + ) + .unwrap(); + assert_eq!( + url, + "https://dav.example.com/remote.php/dav/files/demo/cc%20switch-sync/v2/default%20profile/manifest.json" + ); + assert!(!url.contains("//cc"), "should not have double-slash"); + } + + #[test] + fn is_jianguoyun_detects_correctly() { + assert!(is_jianguoyun("https://dav.jianguoyun.com/dav")); + assert!(is_jianguoyun("https://dav.jianguoyun.com/dav/folder")); + assert!(!is_jianguoyun("https://nextcloud.example.com/dav")); + } + + #[test] + fn path_segments_splits_correctly() { + let segs: Vec<_> = path_segments("/a/b/c/").collect(); + assert_eq!(segs, vec!["a", "b", "c"]); + + let segs: Vec<_> = path_segments("single").collect(); + assert_eq!(segs, vec!["single"]); + + let segs: Vec<_> = path_segments("").collect(); + assert!(segs.is_empty()); + } + + #[test] + fn auth_from_credentials_trims_and_rejects_blank() { + assert!(auth_from_credentials(" ", "pass").is_none()); + let auth = auth_from_credentials(" user ", "pass"); + assert_eq!(auth, Some(("user".to_string(), Some("pass".to_string())))); + } + + #[test] + fn redact_url_hides_credentials_and_query_values() { + let redacted = redact_url("https://alice:secret@example.com:8443/dav?token=abc&foo=1"); + assert_eq!( + redacted, + "https://example.com:8443/dav?[keys:foo,token]" + ); + assert!(!redacted.contains("secret")); + } +} diff --git a/src-tauri/src/services/webdav_sync.rs b/src-tauri/src/services/webdav_sync.rs new file mode 100644 index 000000000..99d787d26 --- /dev/null +++ b/src-tauri/src/services/webdav_sync.rs @@ -0,0 +1,649 @@ +//! WebDAV v2 sync protocol layer. +//! +//! Implements manifest-based synchronization on top of the HTTP transport +//! primitives in [`super::webdav`]. Artifact set: `db.sql` + `skills.zip`. + +use std::collections::BTreeMap; +use std::fs; +use std::process::Command; + +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use tempfile::tempdir; + +use crate::error::AppError; +use crate::services::webdav::{ + auth_from_credentials, build_remote_url, ensure_remote_directories, get_bytes, head_etag, + path_segments, put_bytes, test_connection, WebDavAuth, +}; +use crate::settings::{update_webdav_sync_status, WebDavSyncSettings, WebDavSyncStatus}; + +mod archive; +use archive::{ + backup_current_skills, restore_skills_from_backup, restore_skills_zip, zip_skills_ssot, +}; + +// ─── Protocol constants ────────────────────────────────────── + +const PROTOCOL_FORMAT: &str = "cc-switch-webdav-sync"; +const PROTOCOL_VERSION: u32 = 2; +const REMOTE_DB_SQL: &str = "db.sql"; +const REMOTE_SKILLS_ZIP: &str = "skills.zip"; +const REMOTE_MANIFEST: &str = "manifest.json"; +const MAX_DEVICE_NAME_LEN: usize = 64; + +fn localized(key: &'static str, zh: impl Into, en: impl Into) -> AppError { + AppError::localized(key, zh, en) +} + +fn io_context_localized( + _key: &'static str, + zh: impl Into, + en: impl Into, + source: std::io::Error, +) -> AppError { + let zh_msg = zh.into(); + let en_msg = en.into(); + AppError::IoContext { + context: format!("{zh_msg} ({en_msg})"), + source, + } +} + +// ─── Types ─────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SyncManifest { + format: String, + version: u32, + device_name: String, + created_at: String, + artifacts: BTreeMap, + snapshot_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ArtifactMeta { + sha256: String, + size: u64, +} + +struct LocalSnapshot { + db_sql: Vec, + skills_zip: Vec, + manifest_bytes: Vec, + manifest_hash: String, +} + +// ─── Public API ────────────────────────────────────────────── + +/// Check WebDAV connectivity and ensure remote directory structure. +pub async fn check_connection(settings: &WebDavSyncSettings) -> Result<(), AppError> { + settings.validate()?; + let auth = auth_for(settings); + test_connection(&settings.base_url, &auth).await?; + let dir_segs = remote_dir_segments(settings); + ensure_remote_directories(&settings.base_url, &dir_segs, &auth).await?; + Ok(()) +} + +/// Upload local snapshot (db + skills) to remote. +pub async fn upload( + db: &crate::database::Database, + settings: &mut WebDavSyncSettings, +) -> Result { + settings.validate()?; + let auth = auth_for(settings); + let dir_segs = remote_dir_segments(settings); + ensure_remote_directories(&settings.base_url, &dir_segs, &auth).await?; + + let snapshot = build_local_snapshot(db, settings)?; + + // Upload order: artifacts first, manifest last (best-effort consistency) + let db_url = remote_file_url(settings, REMOTE_DB_SQL)?; + put_bytes(&db_url, &auth, snapshot.db_sql, "application/sql").await?; + + let skills_url = remote_file_url(settings, REMOTE_SKILLS_ZIP)?; + put_bytes(&skills_url, &auth, snapshot.skills_zip, "application/zip").await?; + + let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?; + put_bytes( + &manifest_url, + &auth, + snapshot.manifest_bytes, + "application/json", + ) + .await?; + + // Fetch etag (best-effort, don't fail the upload) + let etag = match head_etag(&manifest_url, &auth).await { + Ok(e) => e, + Err(e) => { + log::debug!("[WebDAV] Failed to fetch ETag after upload: {e}"); + None + } + }; + + let _persisted = persist_sync_success_best_effort( + settings, + snapshot.manifest_hash, + etag, + persist_sync_success, + ); + Ok(serde_json::json!({ "status": "uploaded" })) +} + +/// Download remote snapshot and apply to local database + skills. +pub async fn download( + db: &crate::database::Database, + settings: &mut WebDavSyncSettings, +) -> Result { + settings.validate()?; + let auth = auth_for(settings); + + let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?; + let (manifest_bytes, etag) = get_bytes(&manifest_url, &auth).await?.ok_or_else(|| { + localized( + "webdav.sync.remote_empty", + "远端没有可下载的同步数据", + "No downloadable sync data found on the remote.", + ) + })?; + + let manifest: SyncManifest = + serde_json::from_slice(&manifest_bytes).map_err(|e| AppError::Json { + path: REMOTE_MANIFEST.to_string(), + source: e, + })?; + + validate_manifest_compat(&manifest)?; + + // Download and verify artifacts + let db_sql = download_and_verify(settings, &auth, REMOTE_DB_SQL, &manifest.artifacts).await?; + let skills_zip = + download_and_verify(settings, &auth, REMOTE_SKILLS_ZIP, &manifest.artifacts).await?; + + // Apply snapshot + apply_snapshot(db, &db_sql, &skills_zip)?; + + let manifest_hash = sha256_hex(&manifest_bytes); + let _persisted = + persist_sync_success_best_effort(settings, manifest_hash, etag, persist_sync_success); + Ok(serde_json::json!({ "status": "downloaded" })) +} + +/// Fetch remote manifest info without downloading artifacts. +pub async fn fetch_remote_info(settings: &WebDavSyncSettings) -> Result, AppError> { + settings.validate()?; + let auth = auth_for(settings); + let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?; + + let Some((bytes, _)) = get_bytes(&manifest_url, &auth).await? else { + return Ok(None); + }; + + let manifest: SyncManifest = serde_json::from_slice(&bytes).map_err(|e| AppError::Json { + path: REMOTE_MANIFEST.to_string(), + source: e, + })?; + + let compatible = validate_manifest_compat(&manifest).is_ok(); + + let payload = serde_json::json!({ + "deviceName": manifest.device_name, + "createdAt": manifest.created_at, + "snapshotId": manifest.snapshot_id, + "version": manifest.version, + "compatible": compatible, + "artifacts": manifest.artifacts.keys().collect::>(), + }); + + Ok(Some(payload)) +} + +// ─── Sync status persistence (I3: deduplicated) ───────────── + +fn persist_sync_success( + settings: &mut WebDavSyncSettings, + manifest_hash: String, + etag: Option, +) -> Result<(), AppError> { + let status = WebDavSyncStatus { + last_sync_at: Some(Utc::now().timestamp()), + last_error: None, + last_local_manifest_hash: Some(manifest_hash.clone()), + last_remote_manifest_hash: Some(manifest_hash), + last_remote_etag: etag, + }; + settings.status = status.clone(); + update_webdav_sync_status(status) +} + +fn persist_sync_success_best_effort( + settings: &mut WebDavSyncSettings, + manifest_hash: String, + etag: Option, + persist_fn: F, +) -> bool +where + F: FnOnce(&mut WebDavSyncSettings, String, Option) -> Result<(), AppError>, +{ + match persist_fn(settings, manifest_hash, etag) { + Ok(()) => true, + Err(err) => { + log::warn!("[WebDAV] Persist sync status failed, keep operation success: {err}"); + false + } + } +} + +// ─── Snapshot building ─────────────────────────────────────── + +fn build_local_snapshot( + db: &crate::database::Database, + _settings: &WebDavSyncSettings, +) -> Result { + // Export database to SQL string + let sql_string = db.export_sql_string()?; + let db_sql = sql_string.into_bytes(); + + // Pack skills into deterministic ZIP + let tmp = tempdir().map_err(|e| { + io_context_localized( + "webdav.sync.snapshot_tmpdir_failed", + "创建 WebDAV 快照临时目录失败", + "Failed to create temporary directory for WebDAV snapshot", + e, + ) + })?; + let skills_zip_path = tmp.path().join(REMOTE_SKILLS_ZIP); + zip_skills_ssot(&skills_zip_path)?; + let skills_zip = fs::read(&skills_zip_path).map_err(|e| AppError::io(&skills_zip_path, e))?; + + // Build artifact map and compute hashes + let mut artifacts = BTreeMap::new(); + artifacts.insert( + REMOTE_DB_SQL.to_string(), + ArtifactMeta { + sha256: sha256_hex(&db_sql), + size: db_sql.len() as u64, + }, + ); + artifacts.insert( + REMOTE_SKILLS_ZIP.to_string(), + ArtifactMeta { + sha256: sha256_hex(&skills_zip), + size: skills_zip.len() as u64, + }, + ); + + let snapshot_id = compute_snapshot_id(&artifacts); + let manifest = SyncManifest { + format: PROTOCOL_FORMAT.to_string(), + version: PROTOCOL_VERSION, + device_name: detect_system_device_name().unwrap_or_else(|| "Unknown Device".to_string()), + created_at: Utc::now().to_rfc3339(), + artifacts, + snapshot_id, + }; + let manifest_bytes = + serde_json::to_vec_pretty(&manifest).map_err(|e| AppError::JsonSerialize { source: e })?; + let manifest_hash = sha256_hex(&manifest_bytes); + + Ok(LocalSnapshot { + db_sql, + skills_zip, + manifest_bytes, + manifest_hash, + }) +} + +/// Compute a deterministic snapshot identity from artifact hashes. +/// +/// BTreeMap iteration order is sorted by key, ensuring stability. +fn compute_snapshot_id(artifacts: &BTreeMap) -> String { + let parts: Vec = artifacts + .iter() + .map(|(name, meta)| format!("{}:{}", name, meta.sha256)) + .collect(); + sha256_hex(parts.join("|").as_bytes()) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +fn detect_system_device_name() -> Option { + let env_name = [ + "CC_SWITCH_DEVICE_NAME", + "COMPUTERNAME", + "HOSTNAME", + ] + .iter() + .filter_map(|key| std::env::var(key).ok()) + .find_map(|value| normalize_device_name(&value)); + + if env_name.is_some() { + return env_name; + } + + let output = Command::new("hostname").output().ok()?; + if !output.status.success() { + return None; + } + let hostname = String::from_utf8(output.stdout).ok()?; + normalize_device_name(&hostname) +} + +fn normalize_device_name(raw: &str) -> Option { + let compact = raw.chars().fold(String::with_capacity(raw.len()), |mut acc, ch| { + if ch.is_whitespace() { + acc.push(' '); + } else if !ch.is_control() { + acc.push(ch); + } + acc + }); + let normalized = compact.split_whitespace().collect::>().join(" "); + let trimmed = normalized.trim(); + if trimmed.is_empty() { + return None; + } + + let limited = trimmed.chars().take(MAX_DEVICE_NAME_LEN).collect::(); + if limited.is_empty() { + None + } else { + Some(limited) + } +} + +fn validate_manifest_compat(manifest: &SyncManifest) -> Result<(), AppError> { + if manifest.format != PROTOCOL_FORMAT { + return Err(localized( + "webdav.sync.manifest_format_incompatible", + format!("远端 manifest 格式不兼容: {}", manifest.format), + format!( + "Remote manifest format is incompatible: {}", + manifest.format + ), + )); + } + if manifest.version != PROTOCOL_VERSION { + return Err(localized( + "webdav.sync.manifest_version_incompatible", + format!( + "远端 manifest 协议版本不兼容: v{} (本地 v{PROTOCOL_VERSION})", + manifest.version + ), + format!( + "Remote manifest protocol version is incompatible: v{} (local v{PROTOCOL_VERSION})", + manifest.version + ), + )); + } + Ok(()) +} + +// ─── Download & verify ─────────────────────────────────────── + +async fn download_and_verify( + settings: &WebDavSyncSettings, + auth: &WebDavAuth, + artifact_name: &str, + artifacts: &BTreeMap, +) -> Result, AppError> { + let meta = artifacts.get(artifact_name).ok_or_else(|| { + localized( + "webdav.sync.manifest_missing_artifact", + format!("manifest 中缺少 artifact: {artifact_name}"), + format!("Manifest missing artifact: {artifact_name}"), + ) + })?; + let url = remote_file_url(settings, artifact_name)?; + let (bytes, _) = get_bytes(&url, auth).await?.ok_or_else(|| { + localized( + "webdav.sync.remote_missing_artifact", + format!("远端缺少 artifact 文件: {artifact_name}"), + format!("Remote artifact file missing: {artifact_name}"), + ) + })?; + + // Quick size check before expensive hash + if bytes.len() as u64 != meta.size { + return Err(localized( + "webdav.sync.artifact_size_mismatch", + format!( + "artifact {artifact_name} 大小不匹配 (expected: {}, got: {})", + meta.size, + bytes.len(), + ), + format!( + "Artifact {artifact_name} size mismatch (expected: {}, got: {})", + meta.size, + bytes.len(), + ), + )); + } + + let actual_hash = sha256_hex(&bytes); + if actual_hash != meta.sha256 { + return Err(localized( + "webdav.sync.artifact_hash_mismatch", + format!( + "artifact {artifact_name} SHA256 校验失败 (expected: {}..., got: {}...)", + meta.sha256.get(..8).unwrap_or(&meta.sha256), + actual_hash.get(..8).unwrap_or(&actual_hash), + ), + format!( + "Artifact {artifact_name} SHA256 verification failed (expected: {}..., got: {}...)", + meta.sha256.get(..8).unwrap_or(&meta.sha256), + actual_hash.get(..8).unwrap_or(&actual_hash), + ), + )); + } + Ok(bytes) +} + +fn apply_snapshot( + db: &crate::database::Database, + db_sql: &[u8], + skills_zip: &[u8], +) -> Result<(), AppError> { + let sql_str = std::str::from_utf8(db_sql).map_err(|e| { + localized( + "webdav.sync.sql_not_utf8", + format!("SQL 非 UTF-8: {e}"), + format!("SQL is not valid UTF-8: {e}"), + ) + })?; + let skills_backup = backup_current_skills()?; + + // 先替换 skills,再导入数据库;若导入失败则回滚 skills,避免“半恢复”。 + restore_skills_zip(skills_zip)?; + + if let Err(db_err) = db.import_sql_string(sql_str) { + if let Err(rollback_err) = restore_skills_from_backup(&skills_backup) { + return Err(localized( + "webdav.sync.db_import_and_rollback_failed", + format!("导入数据库失败: {db_err}; 同时回滚 Skills 失败: {rollback_err}"), + format!( + "Database import failed: {db_err}; skills rollback also failed: {rollback_err}" + ), + )); + } + return Err(db_err); + } + + Ok(()) +} + +// ─── Remote path helpers ───────────────────────────────────── + +fn remote_dir_segments(settings: &WebDavSyncSettings) -> Vec { + let mut segs = Vec::new(); + segs.extend(path_segments(&settings.remote_root).map(str::to_string)); + segs.push(format!("v{PROTOCOL_VERSION}")); + segs.extend(path_segments(&settings.profile).map(str::to_string)); + segs +} + +fn remote_file_url(settings: &WebDavSyncSettings, file_name: &str) -> Result { + let mut segs = remote_dir_segments(settings); + segs.extend(path_segments(file_name).map(str::to_string)); + build_remote_url(&settings.base_url, &segs) +} + +fn auth_for(settings: &WebDavSyncSettings) -> WebDavAuth { + auth_from_credentials(&settings.username, &settings.password) +} + +// ─── Tests ─────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn artifact(sha256: &str, size: u64) -> ArtifactMeta { + ArtifactMeta { + sha256: sha256.to_string(), + size, + } + } + + #[test] + fn snapshot_id_is_stable() { + let mut artifacts = BTreeMap::new(); + artifacts.insert("db.sql".to_string(), artifact("abc123", 100)); + artifacts.insert("skills.zip".to_string(), artifact("def456", 200)); + + let id1 = compute_snapshot_id(&artifacts); + let id2 = compute_snapshot_id(&artifacts); + assert_eq!(id1, id2); + } + + #[test] + fn snapshot_id_changes_with_artifacts() { + let mut a1 = BTreeMap::new(); + a1.insert("db.sql".to_string(), artifact("hash-a", 1)); + + let mut a2 = BTreeMap::new(); + a2.insert("db.sql".to_string(), artifact("hash-b", 1)); + + assert_ne!(compute_snapshot_id(&a1), compute_snapshot_id(&a2)); + } + + #[test] + fn remote_dir_segments_uses_v2() { + let settings = WebDavSyncSettings { + remote_root: "cc-switch-sync".to_string(), + profile: "default".to_string(), + ..WebDavSyncSettings::default() + }; + let segs = remote_dir_segments(&settings); + assert_eq!(segs, vec!["cc-switch-sync", "v2", "default"]); + } + + #[test] + fn sha256_hex_is_correct() { + let hash = sha256_hex(b"hello"); + assert_eq!( + hash, + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + ); + } + + #[test] + fn persist_best_effort_returns_true_on_success() { + let mut settings = WebDavSyncSettings::default(); + let ok = persist_sync_success_best_effort( + &mut settings, + "hash".to_string(), + Some("etag".to_string()), + |_settings, _hash, _etag| Ok(()), + ); + assert!(ok); + } + + #[test] + fn persist_best_effort_returns_false_on_error() { + let mut settings = WebDavSyncSettings::default(); + let ok = persist_sync_success_best_effort( + &mut settings, + "hash".to_string(), + None, + |_settings, _hash, _etag| Err(AppError::Config("boom".to_string())), + ); + assert!(!ok); + } + + fn manifest_with(format: &str, version: u32) -> SyncManifest { + let mut artifacts = BTreeMap::new(); + artifacts.insert("db.sql".to_string(), artifact("abc", 1)); + artifacts.insert("skills.zip".to_string(), artifact("def", 2)); + SyncManifest { + format: format.to_string(), + version, + device_name: "My MacBook".to_string(), + created_at: "2026-02-12T00:00:00Z".to_string(), + artifacts, + snapshot_id: "snap-1".to_string(), + } + } + + #[test] + fn validate_manifest_compat_accepts_supported_manifest() { + let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION); + assert!(validate_manifest_compat(&manifest).is_ok()); + } + + #[test] + fn validate_manifest_compat_rejects_wrong_format() { + let manifest = manifest_with("other-format", PROTOCOL_VERSION); + assert!(validate_manifest_compat(&manifest).is_err()); + } + + #[test] + fn validate_manifest_compat_rejects_wrong_version() { + let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION + 1); + assert!(validate_manifest_compat(&manifest).is_err()); + } + + #[test] + fn normalize_device_name_returns_none_for_blank_input() { + assert_eq!(normalize_device_name(" \n\t "), None); + } + + #[test] + fn normalize_device_name_collapses_whitespace_and_drops_control_chars() { + assert_eq!( + normalize_device_name(" Mac\tBook \n Pro\u{0007} "), + Some("Mac Book Pro".to_string()) + ); + } + + #[test] + fn normalize_device_name_truncates_to_max_len() { + let long = "a".repeat(80); + assert_eq!(normalize_device_name(&long).map(|s| s.len()), Some(64)); + } + + #[test] + fn manifest_serialization_uses_device_name_only() { + let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION); + let value = serde_json::to_value(&manifest).expect("serialize manifest"); + assert!( + value.get("deviceName").is_some(), + "manifest should contain deviceName" + ); + assert!( + value.get("deviceId").is_none(), + "manifest should not contain deviceId" + ); + } +} diff --git a/src-tauri/src/services/webdav_sync/archive.rs b/src-tauri/src/services/webdav_sync/archive.rs new file mode 100644 index 000000000..2058429e1 --- /dev/null +++ b/src-tauri/src/services/webdav_sync/archive.rs @@ -0,0 +1,346 @@ +use std::collections::HashSet; +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use tempfile::{tempdir, TempDir}; +use zip::write::SimpleFileOptions; +use zip::DateTime; + +use crate::error::AppError; +use crate::services::skill::SkillService; + +use super::{io_context_localized, localized, REMOTE_SKILLS_ZIP}; + +/// Maximum total bytes allowed during zip extraction (512 MB). +const MAX_EXTRACT_BYTES: u64 = 512 * 1024 * 1024; +/// Maximum number of entries allowed in a zip archive. +const MAX_EXTRACT_ENTRIES: usize = 10_000; + +pub(super) struct SkillsBackup { + _tmp: TempDir, + backup_dir: PathBuf, + ssot_path: PathBuf, + existed: bool, +} + +pub(super) fn zip_skills_ssot(dest_path: &Path) -> Result<(), AppError> { + let source = SkillService::get_ssot_dir().map_err(|e| { + localized( + "webdav.sync.skills_ssot_dir_failed", + format!("获取 Skills SSOT 目录失败: {e}"), + format!("Failed to resolve Skills SSOT directory: {e}"), + ) + })?; + if let Some(parent) = dest_path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + let file = fs::File::create(dest_path).map_err(|e| AppError::io(dest_path, e))?; + let mut writer = zip::ZipWriter::new(file); + let options = SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated) + .last_modified_time(DateTime::default()); + + if source.exists() { + let canonical_root = fs::canonicalize(&source).unwrap_or_else(|_| source.clone()); + let mut visited = HashSet::new(); + mark_visited_dir(&canonical_root, &mut visited)?; + zip_dir_recursive( + &canonical_root, + &canonical_root, + &mut writer, + options, + &mut visited, + )?; + } + + writer.finish().map_err(|e| { + localized( + "webdav.sync.skills_zip_write_failed", + format!("写入 skills.zip 失败: {e}"), + format!("Failed to write skills.zip: {e}"), + ) + })?; + Ok(()) +} + +pub(super) fn restore_skills_zip(raw: &[u8]) -> Result<(), AppError> { + let tmp = tempdir().map_err(|e| { + io_context_localized( + "webdav.sync.skills_extract_tmpdir_failed", + "创建 skills 解压临时目录失败", + "Failed to create temporary directory for skills extraction", + e, + ) + })?; + let zip_path = tmp.path().join(REMOTE_SKILLS_ZIP); + fs::write(&zip_path, raw).map_err(|e| AppError::io(&zip_path, e))?; + + let file = fs::File::open(&zip_path).map_err(|e| AppError::io(&zip_path, e))?; + let mut archive = zip::ZipArchive::new(file).map_err(|e| { + localized( + "webdav.sync.skills_zip_parse_failed", + format!("解析 skills.zip 失败: {e}"), + format!("Failed to parse skills.zip: {e}"), + ) + })?; + + let extracted = tmp.path().join("skills-extracted"); + fs::create_dir_all(&extracted).map_err(|e| AppError::io(&extracted, e))?; + + if archive.len() > MAX_EXTRACT_ENTRIES { + return Err(localized( + "webdav.sync.skills_zip_too_many_entries", + format!("skills.zip 条目数过多({}),上限 {MAX_EXTRACT_ENTRIES}", archive.len()), + format!("skills.zip has too many entries ({}), limit is {MAX_EXTRACT_ENTRIES}", archive.len()), + )); + } + + let mut total_bytes: u64 = 0; + for idx in 0..archive.len() { + let mut entry = archive.by_index(idx).map_err(|e| { + localized( + "webdav.sync.skills_zip_entry_read_failed", + format!("读取 ZIP 项失败: {e}"), + format!("Failed to read ZIP entry: {e}"), + ) + })?; + let Some(safe_name) = entry.enclosed_name() else { + continue; + }; + let out_path = extracted.join(safe_name); + if entry.is_dir() { + fs::create_dir_all(&out_path).map_err(|e| AppError::io(&out_path, e))?; + continue; + } + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + let mut out = fs::File::create(&out_path).map_err(|e| AppError::io(&out_path, e))?; + let written = std::io::copy(&mut entry, &mut out).map_err(|e| AppError::io(&out_path, e))?; + total_bytes += written; + if total_bytes > MAX_EXTRACT_BYTES { + return Err(localized( + "webdav.sync.skills_zip_too_large", + format!("skills.zip 解压后体积超过上限({} MB)", MAX_EXTRACT_BYTES / 1024 / 1024), + format!("skills.zip extracted size exceeds limit ({} MB)", MAX_EXTRACT_BYTES / 1024 / 1024), + )); + } + } + + let ssot = SkillService::get_ssot_dir().map_err(|e| { + localized( + "webdav.sync.skills_ssot_dir_failed", + format!("获取 Skills SSOT 目录失败: {e}"), + format!("Failed to resolve Skills SSOT directory: {e}"), + ) + })?; + let bak = ssot.with_extension("bak"); + + if ssot.exists() { + if bak.exists() { + let _ = fs::remove_dir_all(&bak); + } + fs::rename(&ssot, &bak).map_err(|e| AppError::io(&ssot, e))?; + } + + if let Err(e) = copy_dir_recursive(&extracted, &ssot) { + if bak.exists() { + let _ = fs::remove_dir_all(&ssot); + let _ = fs::rename(&bak, &ssot); + } + return Err(e); + } + + let _ = fs::remove_dir_all(&bak); + Ok(()) +} + +pub(super) fn backup_current_skills() -> Result { + let ssot = SkillService::get_ssot_dir().map_err(|e| { + localized( + "webdav.sync.skills_ssot_dir_failed", + format!("获取 Skills SSOT 目录失败: {e}"), + format!("Failed to resolve Skills SSOT directory: {e}"), + ) + })?; + let tmp = tempdir().map_err(|e| { + io_context_localized( + "webdav.sync.skills_backup_tmpdir_failed", + "创建 skills 备份临时目录失败", + "Failed to create temporary directory for skills backup", + e, + ) + })?; + let backup_dir = tmp.path().join("skills-backup"); + + let existed = ssot.exists(); + if existed { + copy_dir_recursive(&ssot, &backup_dir)?; + } + + Ok(SkillsBackup { + _tmp: tmp, + backup_dir, + ssot_path: ssot, + existed, + }) +} + +pub(super) fn restore_skills_from_backup(backup: &SkillsBackup) -> Result<(), AppError> { + if backup.ssot_path.exists() { + fs::remove_dir_all(&backup.ssot_path).map_err(|e| AppError::io(&backup.ssot_path, e))?; + } + + if backup.existed { + copy_dir_recursive(&backup.backup_dir, &backup.ssot_path)?; + } + + Ok(()) +} + +fn zip_dir_recursive( + root: &Path, + current: &Path, + writer: &mut zip::ZipWriter, + options: SimpleFileOptions, + visited: &mut HashSet, +) -> Result<(), AppError> { + let mut entries: Vec<_> = fs::read_dir(current) + .map_err(|e| AppError::io(current, e))? + .collect::, _>>() + .map_err(|e| AppError::io(current, e))?; + entries.sort_by_key(|e| e.file_name()); + + for entry in entries { + let path = entry.path(); + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + if name_str.starts_with('.') { + continue; + } + + let real_path = match fs::canonicalize(&path) { + Ok(p) if p.starts_with(root) => p, + Ok(_) => { + log::warn!( + "[WebDAV] Skipping symlink outside skills root: {}", + path.display() + ); + continue; + } + Err(_) => path.clone(), + }; + + let rel = real_path + .strip_prefix(root) + .or_else(|_| path.strip_prefix(root)) + .map_err(|e| { + localized( + "webdav.sync.zip_relative_path_failed", + format!("生成 ZIP 相对路径失败: {e}"), + format!("Failed to build relative ZIP path: {e}"), + ) + })?; + let rel_str = rel.to_string_lossy().replace('\\', "/"); + + if real_path.is_dir() { + if !mark_visited_dir(&real_path, visited)? { + log::warn!( + "[WebDAV] Skipping already visited directory: {}", + real_path.display() + ); + continue; + } + writer + .add_directory(format!("{rel_str}/"), options) + .map_err(|e| { + localized( + "webdav.sync.zip_add_directory_failed", + format!("写入 ZIP 目录失败: {e}"), + format!("Failed to write ZIP directory entry: {e}"), + ) + })?; + zip_dir_recursive(root, &real_path, writer, options, visited)?; + } else { + writer.start_file(&rel_str, options).map_err(|e| { + localized( + "webdav.sync.zip_start_file_failed", + format!("写入 ZIP 文件头失败: {e}"), + format!("Failed to start ZIP file entry: {e}"), + ) + })?; + let mut file = fs::File::open(&real_path).map_err(|e| AppError::io(&real_path, e))?; + let mut buf = Vec::new(); + file.read_to_end(&mut buf) + .map_err(|e| AppError::io(&real_path, e))?; + writer.write_all(&buf).map_err(|e| { + localized( + "webdav.sync.zip_write_file_failed", + format!("写入 ZIP 文件内容失败: {e}"), + format!("Failed to write ZIP file content: {e}"), + ) + })?; + } + } + Ok(()) +} + +fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), AppError> { + let mut visited = HashSet::new(); + copy_dir_recursive_inner(src, dest, &mut visited) +} + +fn copy_dir_recursive_inner( + src: &Path, + dest: &Path, + visited: &mut HashSet, +) -> Result<(), AppError> { + if !src.exists() { + return Ok(()); + } + if !mark_visited_dir(src, visited)? { + log::warn!( + "[WebDAV] Skipping already visited copy path: {}", + src.display() + ); + return Ok(()); + } + fs::create_dir_all(dest).map_err(|e| AppError::io(dest, e))?; + for entry in fs::read_dir(src).map_err(|e| AppError::io(src, e))? { + let entry = entry.map_err(|e| AppError::io(src, e))?; + let path = entry.path(); + let dest_path = dest.join(entry.file_name()); + if path.is_dir() { + copy_dir_recursive_inner(&path, &dest_path, visited)?; + } else { + fs::copy(&path, &dest_path).map_err(|e| AppError::io(&dest_path, e))?; + } + } + Ok(()) +} + +fn mark_visited_dir(path: &Path, visited: &mut HashSet) -> Result { + let canonical = fs::canonicalize(path).map_err(|e| AppError::io(path, e))?; + Ok(visited.insert(canonical)) +} + +#[cfg(test)] +mod tests { + use super::mark_visited_dir; + use std::collections::HashSet; + use tempfile::tempdir; + + #[test] + fn mark_visited_dir_tracks_canonical_duplicates() { + let temp = tempdir().expect("tempdir"); + let dir = temp.path().join("skills"); + std::fs::create_dir_all(&dir).expect("create dir"); + + let mut visited = HashSet::new(); + assert!(mark_visited_dir(&dir, &mut visited).expect("first visit")); + assert!(!mark_visited_dir(&dir, &mut visited).expect("second visit")); + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index f0e2ab8f5..1735cac9a 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use std::fs; +use std::io::Write; use std::path::PathBuf; use std::sync::{OnceLock, RwLock}; @@ -33,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 { @@ -42,6 +45,7 @@ impl Default for VisibleApps { codex: true, gemini: true, opencode: true, + openclaw: true, } } } @@ -54,10 +58,106 @@ impl VisibleApps { AppType::Codex => self.codex, AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, + AppType::OpenClaw => self.openclaw, } } } +/// WebDAV 同步状态(持久化同步进度信息) +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct WebDavSyncStatus { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_sync_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_remote_etag: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_local_manifest_hash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_remote_manifest_hash: Option, +} + +fn default_remote_root() -> String { + "cc-switch-sync".to_string() +} +fn default_profile() -> String { + "default".to_string() +} + +/// WebDAV v2 同步设置 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebDavSyncSettings { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub base_url: String, + #[serde(default)] + pub username: String, + #[serde(default)] + pub password: String, + #[serde(default = "default_remote_root")] + pub remote_root: String, + #[serde(default = "default_profile")] + pub profile: String, + #[serde(default)] + pub status: WebDavSyncStatus, +} + +impl Default for WebDavSyncSettings { + fn default() -> Self { + Self { + enabled: false, + base_url: String::new(), + username: String::new(), + password: String::new(), + remote_root: default_remote_root(), + profile: default_profile(), + status: WebDavSyncStatus::default(), + } + } +} + +impl WebDavSyncSettings { + pub fn validate(&self) -> Result<(), crate::error::AppError> { + if self.base_url.trim().is_empty() { + return Err(crate::error::AppError::localized( + "webdav.base_url.required", + "WebDAV 地址不能为空", + "WebDAV URL is required.", + )); + } + if self.username.trim().is_empty() { + return Err(crate::error::AppError::localized( + "webdav.username.required", + "WebDAV 用户名不能为空", + "WebDAV username is required.", + )); + } + Ok(()) + } + + pub fn normalize(&mut self) { + self.base_url = self.base_url.trim().to_string(); + self.username = self.username.trim().to_string(); + self.remote_root = self.remote_root.trim().to_string(); + self.profile = self.profile.trim().to_string(); + if self.remote_root.is_empty() { + self.remote_root = default_remote_root(); + } + if self.profile.is_empty() { + self.profile = default_profile(); + } + } + + /// Returns true if all credential fields are blank (no config to persist). + fn is_empty(&self) -> bool { + self.base_url.is_empty() && self.username.is_empty() && self.password.is_empty() + } +} + /// 应用设置结构 /// /// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。 @@ -98,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) @@ -112,12 +214,23 @@ 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 #[serde(default)] pub skill_sync_method: SyncMethod, + // ===== WebDAV 同步设置 ===== + #[serde(default, skip_serializing_if = "Option::is_none")] + pub webdav_sync: Option, + + // ===== WebDAV 备份设置(旧版,保留向后兼容)===== + #[serde(default, skip_serializing_if = "Option::is_none")] + pub webdav_backup: Option, + // ===== 终端设置 ===== /// 首选终端应用(可选,默认使用系统默认终端) /// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" @@ -150,11 +263,15 @@ 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, preferred_terminal: None, } } @@ -199,12 +316,26 @@ 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() .map(|s| s.trim()) .filter(|s| matches!(*s, "en" | "zh" | "ja")) .map(|s| s.to_string()); + + if let Some(sync) = &mut self.webdav_sync { + sync.normalize(); + if sync.is_empty() { + self.webdav_sync = None; + } + } } fn load_from_file() -> Self { @@ -245,7 +376,27 @@ fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> { let json = serde_json::to_string_pretty(&normalized) .map_err(|e| AppError::JsonSerialize { source: e })?; - fs::write(&path, json).map_err(|e| AppError::io(&path, e))?; + #[cfg(unix)] + { + use std::fs::OpenOptions; + use std::os::unix::fs::OpenOptionsExt; + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o600) + .open(&path) + .map_err(|e| AppError::io(&path, e))?; + file.write_all(json.as_bytes()) + .map_err(|e| AppError::io(&path, e))?; + } + + #[cfg(not(unix))] + { + fs::write(&path, json).map_err(|e| AppError::io(&path, e))?; + } + Ok(()) } @@ -283,6 +434,15 @@ pub fn get_settings() -> AppSettings { .clone() } +pub fn get_settings_for_frontend() -> AppSettings { + let mut settings = get_settings(); + if let Some(sync) = &mut settings.webdav_sync { + sync.password.clear(); + } + settings.webdav_backup = None; + settings +} + pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> { new_settings.normalize_paths(); save_settings_file(&new_settings)?; @@ -295,6 +455,22 @@ pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> { Ok(()) } +fn mutate_settings(mutator: F) -> Result<(), AppError> +where + F: FnOnce(&mut AppSettings), +{ + let mut guard = settings_store().write().unwrap_or_else(|e| { + log::warn!("设置锁已毒化,使用恢复值: {e}"); + e.into_inner() + }); + let mut next = guard.clone(); + mutator(&mut next); + next.normalize_paths(); + save_settings_file(&next)?; + *guard = next; + Ok(()) +} + /// 从文件重新加载设置到内存缓存 /// 用于导入配置等场景,确保内存缓存与文件同步 pub fn reload_settings() -> Result<(), AppError> { @@ -339,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 读取) @@ -352,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(), } } @@ -367,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) @@ -433,3 +619,26 @@ pub fn get_preferred_terminal() -> Option { .preferred_terminal .clone() } + +// ===== WebDAV 同步设置管理函数 ===== + +/// 获取 WebDAV 同步设置 +pub fn get_webdav_sync_settings() -> Option { + settings_store().read().ok()?.webdav_sync.clone() +} + +/// 保存 WebDAV 同步设置 +pub fn set_webdav_sync_settings(settings: Option) -> Result<(), AppError> { + mutate_settings(|current| { + current.webdav_sync = settings; + }) +} + +/// 仅更新 WebDAV 同步状态,避免覆写 credentials/root/profile 等字段 +pub fn update_webdav_sync_status(status: WebDavSyncStatus) -> Result<(), AppError> { + mutate_settings(|current| { + if let Some(sync) = current.webdav_sync.as_mut() { + sync.status = status; + } + }) +} diff --git a/src/App.tsx b/src/App.tsx index 4f0d47785..1eedef4da 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,10 @@ import { Download, FolderArchive, Search, + FolderOpen, + KeyRound, + Shield, + Cpu, } from "lucide-react"; import type { Provider, VisibleApps } from "@/types"; import type { EnvConflict } from "@/types/env"; @@ -28,6 +32,7 @@ import { } from "@/lib/api"; import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env"; import { useProviderActions } from "@/hooks/useProviderActions"; +import { openclawKeys } from "@/hooks/useOpenClaw"; import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useLastValidValue } from "@/hooks/useLastValidValue"; import { extractErrorMessage } from "@/utils/errorUtils"; @@ -56,6 +61,10 @@ import { McpIcon } from "@/components/BrandIcons"; import { Button } from "@/components/ui/button"; import { SessionManagerPage } from "@/components/sessions/SessionManagerPage"; import { useDisableCurrentOmo } from "@/lib/query/omo"; +import WorkspaceFilesPanel from "@/components/workspace/WorkspaceFilesPanel"; +import EnvPanel from "@/components/openclaw/EnvPanel"; +import ToolsPanel from "@/components/openclaw/ToolsPanel"; +import AgentsDefaultsPanel from "@/components/openclaw/AgentsDefaultsPanel"; type View = | "providers" @@ -66,14 +75,24 @@ type View = | "mcp" | "agents" | "universal" - | "sessions"; + | "sessions" + | "workspace" + | "openclawEnv" + | "openclawTools" + | "openclawAgents"; const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px const HEADER_HEIGHT = 64; // px const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT; const STORAGE_KEY = "cc-switch-last-app"; -const VALID_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"]; +const VALID_APPS: AppId[] = [ + "claude", + "codex", + "gemini", + "opencode", + "openclaw", +]; const getInitialApp = (): AppId => { const saved = localStorage.getItem(STORAGE_KEY) as AppId | null; @@ -94,6 +113,10 @@ const VALID_VIEWS: View[] = [ "agents", "universal", "sessions", + "workspace", + "openclawEnv", + "openclawTools", + "openclawAgents", ]; const getInitialView = (): View => { @@ -123,6 +146,7 @@ function App() { codex: true, gemini: true, opencode: true, + openclaw: true, }; const getFirstVisibleApp = (): AppId => { @@ -130,6 +154,7 @@ function App() { if (visibleApps.codex) return "codex"; if (visibleApps.gemini) return "gemini"; if (visibleApps.opencode) return "opencode"; + if (visibleApps.openclaw) return "openclaw"; return "claude"; // fallback }; @@ -196,6 +221,7 @@ function App() { switchProvider, deleteProvider, saveUsageScript, + setAsDefaultModel, } = useProviderActions(activeApp); const disableOmoMutation = useDisableCurrentOmo(); @@ -423,10 +449,19 @@ function App() { const { provider, action } = confirmAction; if (action === "remove") { + // Remove from live config only (for additive mode apps like OpenCode/OpenClaw) + // Does NOT delete from database - provider remains in the list await providersApi.removeFromLiveConfig(provider.id, activeApp); - await queryClient.invalidateQueries({ - queryKey: ["opencodeLiveProviderIds"], - }); + // Invalidate queries to refresh the isInConfig state + if (activeApp === "opencode") { + await queryClient.invalidateQueries({ + queryKey: ["opencodeLiveProviderIds"], + }); + } else if (activeApp === "openclaw") { + await queryClient.invalidateQueries({ + queryKey: openclawKeys.liveProviderIds, + }); + } toast.success( t("notifications.removeFromConfigSuccess", { defaultValue: "已从配置移除", @@ -586,7 +621,11 @@ function App() { return ( ); case "mcp": @@ -609,6 +648,14 @@ function App() { case "sessions": return ; + case "workspace": + return ; + case "openclawEnv": + return ; + case "openclawTools": + return ; + case "openclawAgents": + return ; default: return (
@@ -640,7 +687,7 @@ function App() { setConfirmAction({ provider, action: "delete" }) } onRemoveFromConfig={ - activeApp === "opencode" + activeApp === "opencode" || activeApp === "openclaw" ? (provider) => setConfirmAction({ provider, action: "remove" }) : undefined @@ -655,6 +702,9 @@ function App() { activeApp === "claude" ? handleOpenTerminal : undefined } onCreate={() => setIsAddOpen(true)} + onSetAsDefault={ + activeApp === "openclaw" ? setAsDefaultModel : undefined + } /> @@ -764,6 +814,11 @@ function App() { defaultValue: "统一供应商", })} {currentView === "sessions" && t("sessionManager.title")} + {currentView === "workspace" && t("workspace.title")} + {currentView === "openclawEnv" && t("openclaw.env.title")} + {currentView === "openclawTools" && t("openclaw.tools.title")} + {currentView === "openclawAgents" && + t("openclaw.agents.title")}
) : ( @@ -915,7 +970,7 @@ function App() { )} {currentView === "providers" && ( <> - {activeApp !== "opencode" && ( + {activeApp !== "opencode" && activeApp !== "openclaw" && ( <>
- - - - + {activeApp === "openclaw" ? ( + <> + + + + + + ) : ( + <> + + + + + + )}
+ +
+ + setEnabledApps({ ...enabledApps, opencode: checked }) + } + /> + +
diff --git a/src/components/mcp/UnifiedMcpPanel.tsx b/src/components/mcp/UnifiedMcpPanel.tsx index bab09acd5..57e0cd85f 100644 --- a/src/components/mcp/UnifiedMcpPanel.tsx +++ b/src/components/mcp/UnifiedMcpPanel.tsx @@ -56,7 +56,7 @@ const UnifiedMcpPanel = React.forwardRef< }, [serversMap]); const enabledCounts = useMemo(() => { - const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 }; + const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 }; serverEntries.forEach(([_, server]) => { for (const app of APP_IDS) { if (server.apps[app]) counts[app]++; diff --git a/src/components/openclaw/AgentsDefaultsPanel.tsx b/src/components/openclaw/AgentsDefaultsPanel.tsx new file mode 100644 index 000000000..03d5af3f9 --- /dev/null +++ b/src/components/openclaw/AgentsDefaultsPanel.tsx @@ -0,0 +1,228 @@ +import React, { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Save } from "lucide-react"; +import { toast } from "sonner"; +import { + useOpenClawAgentsDefaults, + useSaveOpenClawAgentsDefaults, +} from "@/hooks/useOpenClaw"; +import { extractErrorMessage } from "@/utils/errorUtils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { OpenClawAgentsDefaults } from "@/types"; + +const AgentsDefaultsPanel: React.FC = () => { + const { t } = useTranslation(); + const { data: agentsData, isLoading } = useOpenClawAgentsDefaults(); + const saveAgentsMutation = useSaveOpenClawAgentsDefaults(); + const [defaults, setDefaults] = useState(null); + const [primaryModel, setPrimaryModel] = useState(""); + const [fallbacks, setFallbacks] = useState(""); + + // Extra known fields from agents.defaults + const [workspace, setWorkspace] = useState(""); + const [timeout, setTimeout_] = useState(""); + const [contextTokens, setContextTokens] = useState(""); + const [maxConcurrent, setMaxConcurrent] = useState(""); + + useEffect(() => { + // agentsData is undefined while loading, null when config section is absent + if (agentsData === undefined) return; + setDefaults(agentsData); + + if (agentsData) { + setPrimaryModel(agentsData.model?.primary ?? ""); + setFallbacks((agentsData.model?.fallbacks ?? []).join(", ")); + + // Extract known extra fields + setWorkspace(String(agentsData.workspace ?? "")); + setTimeout_(String(agentsData.timeout ?? "")); + setContextTokens(String(agentsData.contextTokens ?? "")); + setMaxConcurrent(String(agentsData.maxConcurrent ?? "")); + } + }, [agentsData]); + + const handleSave = async () => { + try { + // Preserve all unknown fields from original data + const updated: OpenClawAgentsDefaults = { ...defaults }; + + // Model configuration + const fallbackList = fallbacks + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + + if (primaryModel.trim()) { + updated.model = { + primary: primaryModel.trim(), + ...(fallbackList.length > 0 ? { fallbacks: fallbackList } : {}), + }; + } + + // Optional fields + if (workspace.trim()) updated.workspace = workspace.trim(); + else delete updated.workspace; + + // Numeric fields: validate before saving to avoid NaN + const parseNum = (v: string) => { + const n = Number(v); + return !isNaN(n) && isFinite(n) ? n : undefined; + }; + + const timeoutNum = timeout.trim() ? parseNum(timeout) : undefined; + if (timeoutNum !== undefined) updated.timeout = timeoutNum; + else delete updated.timeout; + + const ctxNum = contextTokens.trim() ? parseNum(contextTokens) : undefined; + if (ctxNum !== undefined) updated.contextTokens = ctxNum; + else delete updated.contextTokens; + + const concNum = maxConcurrent.trim() + ? parseNum(maxConcurrent) + : undefined; + if (concNum !== undefined) updated.maxConcurrent = concNum; + else delete updated.maxConcurrent; + + await saveAgentsMutation.mutateAsync(updated); + toast.success(t("openclaw.agents.saveSuccess")); + } catch (error) { + const detail = extractErrorMessage(error); + toast.error(t("openclaw.agents.saveFailed"), { + description: detail || undefined, + }); + } + }; + + if (isLoading) { + return ( +
+
+ {t("common.loading")} +
+
+ ); + } + + return ( +
+

+ {t("openclaw.agents.description")} +

+ + {/* Model Configuration Card */} +
+

+ {t("openclaw.agents.modelSection")} +

+ +
+
+ + setPrimaryModel(e.target.value)} + placeholder="provider/model-id" + className="font-mono text-xs" + /> +

+ {t("openclaw.agents.primaryModelHint")} +

+
+ +
+ + setFallbacks(e.target.value)} + placeholder="provider/model-a, provider/model-b" + className="font-mono text-xs" + /> +

+ {t("openclaw.agents.fallbackModelsHint")} +

+
+
+
+ + {/* Runtime Parameters Card */} +
+

+ {t("openclaw.agents.runtimeSection")} +

+ +
+
+ + setWorkspace(e.target.value)} + placeholder="~/projects" + className="font-mono text-xs" + /> +
+ +
+ + setTimeout_(e.target.value)} + placeholder="300" + className="font-mono text-xs" + /> +
+ +
+ + setContextTokens(e.target.value)} + placeholder="200000" + className="font-mono text-xs" + /> +
+ +
+ + setMaxConcurrent(e.target.value)} + placeholder="4" + className="font-mono text-xs" + /> +
+
+
+ + {/* Save button */} +
+ +
+
+ ); +}; + +export default AgentsDefaultsPanel; diff --git a/src/components/openclaw/EnvPanel.tsx b/src/components/openclaw/EnvPanel.tsx new file mode 100644 index 000000000..544ad79cc --- /dev/null +++ b/src/components/openclaw/EnvPanel.tsx @@ -0,0 +1,182 @@ +import React, { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Plus, Trash2, Save, Eye, EyeOff } from "lucide-react"; +import { toast } from "sonner"; +import { useOpenClawEnv, useSaveOpenClawEnv } from "@/hooks/useOpenClaw"; +import { extractErrorMessage } from "@/utils/errorUtils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import type { OpenClawEnvConfig } from "@/types"; + +interface EnvEntry { + id: string; + key: string; + value: string; + isNew?: boolean; +} + +const EnvPanel: React.FC = () => { + const { t } = useTranslation(); + const { data: envData, isLoading } = useOpenClawEnv(); + const saveEnvMutation = useSaveOpenClawEnv(); + const [entries, setEntries] = useState([]); + const [visibleKeys, setVisibleKeys] = useState>(new Set()); + + useEffect(() => { + if (envData) { + const items: EnvEntry[] = Object.entries(envData).map(([key, value]) => ({ + id: crypto.randomUUID(), + key, + value: String(value ?? ""), + })); + setEntries(items.length > 0 ? items : []); + } + }, [envData]); + + const handleSave = async () => { + try { + const env: OpenClawEnvConfig = {}; + const seen = new Set(); + for (const entry of entries) { + const trimmedKey = entry.key.trim(); + if (trimmedKey) { + if (seen.has(trimmedKey)) { + toast.error(t("openclaw.env.duplicateKey", { key: trimmedKey })); + return; + } + seen.add(trimmedKey); + env[trimmedKey] = entry.value; + } + } + await saveEnvMutation.mutateAsync(env); + toast.success(t("openclaw.env.saveSuccess")); + } catch (error) { + const detail = extractErrorMessage(error); + toast.error(t("openclaw.env.saveFailed"), { + description: detail || undefined, + }); + } + }; + + const addEntry = () => { + setEntries((prev) => [ + ...prev, + { id: crypto.randomUUID(), key: "", value: "", isNew: true }, + ]); + }; + + const removeEntry = (index: number) => { + setEntries((prev) => prev.filter((_, i) => i !== index)); + }; + + const updateEntry = (index: number, field: "key" | "value", val: string) => { + setEntries((prev) => + prev.map((entry, i) => + i === index ? { ...entry, [field]: val } : entry, + ), + ); + }; + + const toggleVisibility = (key: string) => { + setVisibleKeys((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }; + + const isApiKey = (key: string) => /key|token|secret|password/i.test(key); + + if (isLoading) { + return ( +
+
+ {t("common.loading")} +
+
+ ); + } + + return ( +
+

+ {t("openclaw.env.description")} +

+ +
+ {entries.map((entry, index) => { + const sensitive = isApiKey(entry.key); + const visibilityId = entry.key || `__new_${index}`; + const visible = visibleKeys.has(visibilityId); + + return ( +
+
+ updateEntry(index, "key", e.target.value)} + placeholder={t("openclaw.env.keyPlaceholder")} + className="font-mono text-xs" + autoFocus={entry.isNew} + /> +
+
+ updateEntry(index, "value", e.target.value)} + placeholder={t("openclaw.env.valuePlaceholder")} + className="font-mono text-xs" + /> + {sensitive && ( + + )} +
+ +
+ ); + })} +
+ +
+ +
+ +
+
+ ); +}; + +export default EnvPanel; diff --git a/src/components/openclaw/ToolsPanel.tsx b/src/components/openclaw/ToolsPanel.tsx new file mode 100644 index 000000000..928a0eb3f --- /dev/null +++ b/src/components/openclaw/ToolsPanel.tsx @@ -0,0 +1,221 @@ +import React, { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Plus, Trash2, Save } from "lucide-react"; +import { toast } from "sonner"; +import { useOpenClawTools, useSaveOpenClawTools } from "@/hooks/useOpenClaw"; +import { extractErrorMessage } from "@/utils/errorUtils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { OpenClawToolsConfig } from "@/types"; + +interface ListItem { + id: string; + value: string; +} + +const PROFILE_OPTIONS = ["default", "strict", "permissive", "custom"]; + +const ToolsPanel: React.FC = () => { + const { t } = useTranslation(); + const { data: toolsData, isLoading } = useOpenClawTools(); + const saveToolsMutation = useSaveOpenClawTools(); + const [config, setConfig] = useState({}); + const [allowList, setAllowList] = useState([]); + const [denyList, setDenyList] = useState([]); + + useEffect(() => { + if (toolsData) { + setConfig(toolsData); + setAllowList( + (toolsData.allow ?? []).map((v) => ({ + id: crypto.randomUUID(), + value: v, + })), + ); + setDenyList( + (toolsData.deny ?? []).map((v) => ({ + id: crypto.randomUUID(), + value: v, + })), + ); + } + }, [toolsData]); + + const handleSave = async () => { + try { + const { profile, allow, deny, ...other } = config; + const newConfig: OpenClawToolsConfig = { + ...other, + profile: config.profile, + allow: allowList.map((item) => item.value).filter((s) => s.trim()), + deny: denyList.map((item) => item.value).filter((s) => s.trim()), + }; + await saveToolsMutation.mutateAsync(newConfig); + toast.success(t("openclaw.tools.saveSuccess")); + } catch (error) { + const detail = extractErrorMessage(error); + toast.error(t("openclaw.tools.saveFailed"), { + description: detail || undefined, + }); + } + }; + + const updateListItem = ( + setList: React.Dispatch>, + index: number, + value: string, + ) => { + setList((prev) => + prev.map((item, i) => (i === index ? { ...item, value } : item)), + ); + }; + + const removeListItem = ( + setList: React.Dispatch>, + index: number, + ) => { + setList((prev) => prev.filter((_, i) => i !== index)); + }; + + if (isLoading) { + return ( +
+
+ {t("common.loading")} +
+
+ ); + } + + return ( +
+

+ {t("openclaw.tools.description")} +

+ + {/* Profile selector */} +
+ + +
+ + {/* Allow list */} +
+ +
+ {allowList.map((item, index) => ( +
+ + updateListItem(setAllowList, index, e.target.value) + } + placeholder={t("openclaw.tools.patternPlaceholder")} + className="font-mono text-xs" + /> + +
+ ))} + +
+
+ + {/* Deny list */} +
+ +
+ {denyList.map((item, index) => ( +
+ + updateListItem(setDenyList, index, e.target.value) + } + placeholder={t("openclaw.tools.patternPlaceholder")} + className="font-mono text-xs" + /> + +
+ ))} + +
+
+ + {/* Save button */} +
+ +
+
+ ); +}; + +export default ToolsPanel; diff --git a/src/components/prompts/PromptFormModal.tsx b/src/components/prompts/PromptFormModal.tsx index 970265d06..84e18d255 100644 --- a/src/components/prompts/PromptFormModal.tsx +++ b/src/components/prompts/PromptFormModal.tsx @@ -30,13 +30,13 @@ const PromptFormModal: React.FC = ({ }) => { const { t } = useTranslation(); const appName = t(`apps.${appId}`); - const filenameMap: Record = { + const filenameMap: Record, string> = { claude: "CLAUDE.md", codex: "AGENTS.md", gemini: "GEMINI.md", opencode: "AGENTS.md", }; - const filename = filenameMap[appId]; + const filename = filenameMap[appId as Exclude]; const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [content, setContent] = useState(""); diff --git a/src/components/prompts/PromptFormPanel.tsx b/src/components/prompts/PromptFormPanel.tsx index 83a423209..c4481fa4c 100644 --- a/src/components/prompts/PromptFormPanel.tsx +++ b/src/components/prompts/PromptFormPanel.tsx @@ -29,6 +29,7 @@ const PromptFormPanel: React.FC = ({ codex: "AGENTS.md", gemini: "GEMINI.md", opencode: "AGENTS.md", + openclaw: "AGENTS.md", }; const filename = filenameMap[appId]; const [name, setName] = useState(""); diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index 32c17f5dd..a40befdc4 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -17,6 +17,7 @@ import { UniversalProviderPanel } from "@/components/universal"; import { providerPresets } from "@/config/claudeProviderPresets"; import { codexProviderPresets } from "@/config/codexProviderPresets"; import { geminiProviderPresets } from "@/config/geminiProviderPresets"; +import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets"; import type { UniversalProviderPreset } from "@/config/universalProviderPresets"; interface AddProviderDialogProps { @@ -24,7 +25,10 @@ interface AddProviderDialogProps { onOpenChange: (open: boolean) => void; appId: AppId; onSubmit: ( - provider: Omit & { providerKey?: string }, + provider: Omit & { + providerKey?: string; + suggestedDefaults?: OpenClawSuggestedDefaults; + }, ) => Promise | void; } @@ -35,7 +39,8 @@ export function AddProviderDialog({ onSubmit, }: AddProviderDialogProps) { const { t } = useTranslation(); - const showUniversalTab = appId !== "opencode"; + // OpenCode and OpenClaw don't support universal providers + const showUniversalTab = appId !== "opencode" && appId !== "openclaw"; const [activeTab, setActiveTab] = useState<"app-specific" | "universal">( "app-specific", ); @@ -82,7 +87,11 @@ export function AddProviderDialog({ unknown >; - const providerData: Omit & { providerKey?: string } = { + // 构造基础提交数据 + const providerData: Omit & { + providerKey?: string; + suggestedDefaults?: OpenClawSuggestedDefaults; + } = { name: values.name.trim(), notes: values.notes?.trim() || undefined, websiteUrl: values.websiteUrl?.trim() || undefined, @@ -93,7 +102,11 @@ export function AddProviderDialog({ ...(values.meta ? { meta: values.meta } : {}), }; - if (appId === "opencode" && values.providerKey) { + // OpenCode/OpenClaw: pass providerKey for ID generation + if ( + (appId === "opencode" || appId === "openclaw") && + values.providerKey + ) { providerData.providerKey = values.providerKey; } @@ -185,6 +198,11 @@ export function AddProviderDialog({ if (options?.baseURL) { addUrl(options.baseURL); } + } else if (appId === "openclaw") { + // OpenClaw uses baseUrl directly + if (parsedConfig.baseUrl) { + addUrl(parsedConfig.baseUrl as string); + } } const urls = Array.from(urlSet); @@ -206,6 +224,11 @@ export function AddProviderDialog({ } } + // OpenClaw: pass suggestedDefaults for model registration + if (appId === "openclaw" && values.suggestedDefaults) { + providerData.suggestedDefaults = values.suggestedDefaults; + } + await onSubmit(providerData); onOpenChange(false); }, @@ -286,6 +309,7 @@ export function AddProviderDialog({ ) : ( + // OpenCode/OpenClaw: directly show form without tabs void; + // OpenClaw: default model + isDefaultModel?: boolean; + onSetAsDefault?: () => void; } export function ProviderActions({ @@ -58,14 +62,20 @@ export function ProviderActions({ isAutoFailoverEnabled = false, isInFailoverQueue = false, onToggleFailover, + // OpenClaw: default model + isDefaultModel = false, + onSetAsDefault, }: ProviderActionsProps) { const { t } = useTranslation(); const iconButtonClass = "h-8 w-8 p-1"; - const isOpenCodeMode = appId === "opencode" && !isOmo; + // 累加模式应用(OpenCode 非 OMO 和 OpenClaw) + const isAdditiveMode = + (appId === "opencode" && !isOmo) || appId === "openclaw"; + // 故障转移模式下的按钮逻辑(累加模式和 OMO 应用不支持故障转移) const isFailoverMode = - !isOpenCodeMode && !isOmo && isAutoFailoverEnabled && onToggleFailover; + !isAdditiveMode && !isOmo && isAutoFailoverEnabled && onToggleFailover; const handleMainButtonClick = () => { if (isOmo) { @@ -74,7 +84,8 @@ export function ProviderActions({ } else { onSwitch(); } - } else if (isOpenCodeMode) { + } else if (isAdditiveMode) { + // 累加模式:切换配置状态(添加/移除) if (isInConfig) { if (onRemoveFromConfig) { onRemoveFromConfig(); @@ -112,13 +123,16 @@ export function ProviderActions({ }; } - if (isOpenCodeMode) { + // 累加模式(OpenCode 非 OMO / OpenClaw) + if (isAdditiveMode) { if (isInConfig) { return { - disabled: false, + disabled: isDefaultModel === true, variant: "secondary" as const, - className: + className: cn( "bg-orange-100 text-orange-600 hover:bg-orange-200 dark:bg-orange-900/50 dark:text-orange-400 dark:hover:bg-orange-900/70", + isDefaultModel && "opacity-40 cursor-not-allowed", + ), icon: , text: t("provider.removeFromConfig", { defaultValue: "移除" }), }; @@ -180,12 +194,32 @@ export function ProviderActions({ const canDelete = isOmo ? !(isLastOmo && isCurrent) - : isOpenCodeMode + : isAdditiveMode ? true : !isCurrent; return (
+ {appId === "openclaw" && isInConfig && onSetAsDefault && ( + + )} +
diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index 7b1ab9eae..58111cba1 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -20,6 +20,11 @@ import type { Provider } from "@/types"; import type { AppId } from "@/lib/api"; import { providersApi } from "@/lib/api/providers"; import { useDragSort } from "@/hooks/useDragSort"; +import { + useOpenClawLiveProviderIds, + useOpenClawDefaultModel, +} from "@/hooks/useOpenClaw"; +// import { useStreamCheck } from "@/hooks/useStreamCheck"; // 测试功能已隐藏 import { ProviderCard } from "@/components/providers/ProviderCard"; import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState"; import { @@ -51,6 +56,7 @@ interface ProviderListProps { isProxyRunning?: boolean; // 代理服务运行状态 isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管) activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框) + onSetAsDefault?: (provider: Provider) => void; // OpenClaw: set as default model } export function ProviderList({ @@ -71,6 +77,7 @@ export function ProviderList({ isProxyRunning = false, isProxyTakeover = false, activeProviderId, + onSetAsDefault, }: ProviderListProps) { const { t } = useTranslation(); const { sortedProviders, sensors, handleDragEnd } = useDragSort( @@ -84,14 +91,39 @@ export function ProviderList({ enabled: appId === "opencode", }); - const isProviderInConfig = useCallback( - (providerId: string): boolean => { - if (appId !== "opencode") return true; // 非 OpenCode 应用始终返回 true - return opencodeLiveIds?.includes(providerId) ?? false; - }, - [appId, opencodeLiveIds], + // OpenClaw: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig + const { data: openclawLiveIds } = useOpenClawLiveProviderIds( + appId === "openclaw", ); + // 判断供应商是否已添加到配置(累加模式应用:OpenCode/OpenClaw) + const isProviderInConfig = useCallback( + (providerId: string): boolean => { + if (appId === "opencode") { + return opencodeLiveIds?.includes(providerId) ?? false; + } + if (appId === "openclaw") { + return openclawLiveIds?.includes(providerId) ?? false; + } + return true; // 其他应用始终返回 true + }, + [appId, opencodeLiveIds, openclawLiveIds], + ); + + // OpenClaw: query default model to determine which provider is default + const { data: openclawDefaultModel } = useOpenClawDefaultModel( + appId === "openclaw", + ); + + const isProviderDefaultModel = useCallback( + (providerId: string): boolean => { + if (appId !== "openclaw" || !openclawDefaultModel?.primary) return false; + return openclawDefaultModel.primary.startsWith(providerId + "/"); + }, + [appId, openclawDefaultModel], + ); + + // 故障转移相关 const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId); const { data: failoverQueue } = useFailoverQueue(appId); const addToQueue = useAddToFailoverQueue(); @@ -240,6 +272,11 @@ export function ProviderList({ handleToggleFailover(provider.id, enabled) } activeProviderId={activeProviderId} + // OpenClaw: default model + isDefaultModel={isProviderDefaultModel(provider.id)} + onSetAsDefault={ + onSetAsDefault ? () => onSetAsDefault(provider) : undefined + } /> ); })} @@ -352,6 +389,9 @@ interface SortableProviderCardProps { isInFailoverQueue: boolean; onToggleFailover: (enabled: boolean) => void; activeProviderId?: string; + // OpenClaw: default model + isDefaultModel?: boolean; + onSetAsDefault?: () => void; } function SortableProviderCard({ @@ -379,6 +419,8 @@ function SortableProviderCard({ isInFailoverQueue, onToggleFailover, activeProviderId, + isDefaultModel, + onSetAsDefault, }: SortableProviderCardProps) { const { setNodeRef, @@ -428,6 +470,9 @@ function SortableProviderCard({ isInFailoverQueue={isInFailoverQueue} onToggleFailover={onToggleFailover} activeProviderId={activeProviderId} + // OpenClaw: default model + isDefaultModel={isDefaultModel} + onSetAsDefault={onSetAsDefault} /> ); diff --git a/src/components/providers/forms/EndpointSpeedTest.tsx b/src/components/providers/forms/EndpointSpeedTest.tsx index 1a0b39f99..2ede2f019 100644 --- a/src/components/providers/forms/EndpointSpeedTest.tsx +++ b/src/components/providers/forms/EndpointSpeedTest.tsx @@ -14,6 +14,7 @@ const ENDPOINT_TIMEOUT_SECS: Record = { claude: 8, gemini: 8, opencode: 8, + openclaw: 8, }; interface TestResult { diff --git a/src/components/providers/forms/OpenClawFormFields.tsx b/src/components/providers/forms/OpenClawFormFields.tsx new file mode 100644 index 000000000..bb4a26770 --- /dev/null +++ b/src/components/providers/forms/OpenClawFormFields.tsx @@ -0,0 +1,472 @@ +import { useTranslation } from "react-i18next"; +import { useState, useRef, useCallback } from "react"; +import { FormLabel } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react"; +import { ApiKeySection } from "./shared"; +import { openclawApiProtocols } from "@/config/openclawProviderPresets"; +import type { ProviderCategory, OpenClawModel } from "@/types"; + +interface OpenClawFormFieldsProps { + // Base URL + baseUrl: string; + onBaseUrlChange: (value: string) => void; + + // API Key + apiKey: string; + onApiKeyChange: (value: string) => void; + category?: ProviderCategory; + shouldShowApiKeyLink: boolean; + websiteUrl: string; + isPartner?: boolean; + partnerPromotionKey?: string; + + // API Protocol + api: string; + onApiChange: (value: string) => void; + + // Models + models: OpenClawModel[]; + onModelsChange: (models: OpenClawModel[]) => void; +} + +export function OpenClawFormFields({ + baseUrl, + onBaseUrlChange, + apiKey, + onApiKeyChange, + category, + shouldShowApiKeyLink, + websiteUrl, + isPartner, + partnerPromotionKey, + api, + onApiChange, + models, + onModelsChange, +}: OpenClawFormFieldsProps) { + const { t } = useTranslation(); + const [expandedModels, setExpandedModels] = useState>( + {}, + ); + + // Stable key tracking for models list + const modelKeysRef = useRef([]); + const getModelKeys = useCallback(() => { + // Grow keys array if models were added externally + while (modelKeysRef.current.length < models.length) { + modelKeysRef.current.push(crypto.randomUUID()); + } + // Shrink if models were removed externally + if (modelKeysRef.current.length > models.length) { + modelKeysRef.current.length = models.length; + } + return modelKeysRef.current; + }, [models.length]); + const modelKeys = getModelKeys(); + + // Toggle advanced section for a model + const toggleModelAdvanced = (index: number) => { + setExpandedModels((prev) => ({ ...prev, [index]: !prev[index] })); + }; + + // Add a new model entry + const handleAddModel = () => { + modelKeysRef.current.push(crypto.randomUUID()); + onModelsChange([ + ...models, + { + id: "", + name: "", + contextWindow: undefined, + maxTokens: undefined, + cost: undefined, + }, + ]); + }; + + // Remove a model entry + const handleRemoveModel = (index: number) => { + modelKeysRef.current.splice(index, 1); + const newModels = [...models]; + newModels.splice(index, 1); + onModelsChange(newModels); + // Clean up expanded state + setExpandedModels((prev) => { + const updated = { ...prev }; + delete updated[index]; + return updated; + }); + }; + + // Update model field + const handleModelChange = ( + index: number, + field: keyof OpenClawModel, + value: unknown, + ) => { + const newModels = [...models]; + newModels[index] = { ...newModels[index], [field]: value }; + onModelsChange(newModels); + }; + + // Update model cost + const handleCostChange = ( + index: number, + costField: "input" | "output" | "cacheRead" | "cacheWrite", + value: string, + ) => { + const newModels = [...models]; + const numValue = parseFloat(value); + const currentCost = newModels[index].cost || { input: 0, output: 0 }; + newModels[index] = { + ...newModels[index], + cost: { + ...currentCost, + [costField]: isNaN(numValue) ? undefined : numValue, + }, + }; + onModelsChange(newModels); + }; + + return ( + <> + {/* API Protocol Selector */} +
+ + {t("openclaw.apiProtocol", { + defaultValue: "API 协议", + })} + + +

+ {t("openclaw.apiProtocolHint", { + defaultValue: + "选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。", + })} +

+
+ + {/* Base URL */} +
+ + {t("openclaw.baseUrl", { defaultValue: "API 端点" })} + + onBaseUrlChange(e.target.value)} + placeholder="https://api.example.com/v1" + /> +

+ {t("openclaw.baseUrlHint", { + defaultValue: "供应商的 API 端点地址。", + })} +

+
+ + {/* API Key */} + + + {/* Models Editor */} +
+
+ + {t("openclaw.models", { defaultValue: "模型列表" })} + + +
+ + {models.length === 0 ? ( +

+ {t("openclaw.noModels", { + defaultValue: "暂无模型配置。点击添加模型来配置可用模型。", + })} +

+ ) : ( +
+ {models.map((model, index) => ( +
+ {/* Model ID and Name row */} +
+
+ + + handleModelChange(index, "id", e.target.value) + } + placeholder={t("openclaw.modelIdPlaceholder", { + defaultValue: "claude-3-sonnet", + })} + /> +
+
+ + + handleModelChange(index, "name", e.target.value) + } + placeholder={t("openclaw.modelNamePlaceholder", { + defaultValue: "Claude 3 Sonnet", + })} + /> +
+ +
+ + {/* Advanced Options (Collapsible) */} + toggleModelAdvanced(index)} + > + + + + + {/* Context Window, Max Tokens and Reasoning row */} +
+
+ + + handleModelChange( + index, + "contextWindow", + e.target.value + ? parseInt(e.target.value) + : undefined, + ) + } + placeholder="200000" + /> +
+
+ + + handleModelChange( + index, + "maxTokens", + e.target.value + ? parseInt(e.target.value) + : undefined, + ) + } + placeholder="32000" + /> +
+
+ +
+ + handleModelChange(index, "reasoning", checked) + } + /> + + {model.reasoning + ? t("openclaw.reasoningOn", { + defaultValue: "启用", + }) + : t("openclaw.reasoningOff", { + defaultValue: "关闭", + })} + +
+
+
+ + {/* Cost row */} +
+
+ + + handleCostChange(index, "input", e.target.value) + } + placeholder="3" + /> +
+
+ + + handleCostChange(index, "output", e.target.value) + } + placeholder="15" + /> +
+
+
+ + {/* Cache Cost row */} +
+
+ + + handleCostChange(index, "cacheRead", e.target.value) + } + placeholder="0.3" + /> +
+
+ + + handleCostChange( + index, + "cacheWrite", + e.target.value, + ) + } + placeholder="3.75" + /> +
+
+
+

+ {t("openclaw.cacheCostHint", { + defaultValue: + "缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。", + })} +

+ + +
+ ))} +
+ )} + +

+ {t("openclaw.modelsHint", { + defaultValue: + "配置该供应商支持的模型。模型 ID 用于 API 调用,显示名称用于界面展示。", + })} +

+
+ + ); +} diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 381a7d68c..36451b04f 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -16,6 +16,7 @@ import type { ClaudeApiFormat, OpenCodeModel, OpenCodeProviderConfig, + OpenClawModel, } from "@/types"; import { providerPresets, @@ -34,7 +35,13 @@ import { OPENCODE_PRESET_MODEL_VARIANTS, type OpenCodeProviderPreset, } from "@/config/opencodeProviderPresets"; +import { + openclawProviderPresets, + type OpenClawProviderPreset, + type OpenClawSuggestedDefaults, +} from "@/config/openclawProviderPresets"; import { OpenCodeFormFields } from "./OpenCodeFormFields"; +import { OpenClawFormFields } from "./OpenClawFormFields"; import type { UniversalProviderPreset } from "@/config/universalProviderPresets"; import { applyTemplateValues } from "@/utils/providerConfigUtils"; import { mergeProviderMeta } from "@/utils/providerMetaUtils"; @@ -172,13 +179,25 @@ function toOpencodeExtraOptions( return extra; } +const OPENCLAW_DEFAULT_CONFIG = JSON.stringify( + { + baseUrl: "", + apiKey: "", + api: "openai-completions", + models: [], + }, + null, + 2, +); + type PresetEntry = { id: string; preset: | ProviderPreset | CodexProviderPreset | GeminiProviderPreset - | OpenCodeProviderPreset; + | OpenCodeProviderPreset + | OpenClawProviderPreset; }; interface ProviderFormProps { @@ -259,6 +278,7 @@ export function ProviderForm({ category?: ProviderCategory; isPartner?: boolean; partnerPromotionKey?: string; + suggestedDefaults?: OpenClawSuggestedDefaults; } | null>(null); const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false); const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] = @@ -337,7 +357,9 @@ export function ProviderForm({ ? GEMINI_DEFAULT_CONFIG : appId === "opencode" ? OPENCODE_DEFAULT_CONFIG - : CLAUDE_DEFAULT_CONFIG, + : appId === "openclaw" + ? OPENCLAW_DEFAULT_CONFIG + : CLAUDE_DEFAULT_CONFIG, icon: initialData?.icon ?? "", iconColor: initialData?.iconColor ?? "", }), @@ -464,6 +486,11 @@ export function ProviderForm({ id: `opencode-${index}`, preset, })); + } else if (appId === "openclaw") { + return openclawProviderPresets.map((preset, index) => ({ + id: `openclaw-${index}`, + preset, + })); } return providerPresets.map((preset, index) => ({ id: `claude-${index}`, @@ -846,6 +873,24 @@ export function ProviderForm({ return providerId || ""; }); + // OpenClaw: query existing providers for duplicate key checking + const { data: openclawProvidersData } = useProvidersQuery("openclaw"); + const existingOpenclawKeys = useMemo(() => { + if (!openclawProvidersData?.providers) return []; + // Exclude current provider ID when in edit mode + return Object.keys(openclawProvidersData.providers).filter( + (k) => k !== providerId, + ); + }, [openclawProvidersData?.providers, providerId]); + + // OpenClaw Provider Key state + const [openclawProviderKey, setOpenclawProviderKey] = useState(() => { + if (appId !== "openclaw") return ""; + // In edit mode, use the existing provider ID as the key + return providerId || ""; + }); + + // OpenCode 配置状态 const [opencodeNpm, setOpencodeNpm] = useState(() => { if (appId !== "opencode") return OPENCODE_DEFAULT_NPM; return initialOpencodeConfig?.npm || OPENCODE_DEFAULT_NPM; @@ -987,6 +1032,128 @@ export function ProviderForm({ setUseOmoCommonConfig(useCommonConfig); }, []); + // OpenClaw 配置状态 + const [openclawBaseUrl, setOpenclawBaseUrl] = useState(() => { + if (appId !== "openclaw") return ""; + try { + const config = JSON.parse( + initialData?.settingsConfig + ? JSON.stringify(initialData.settingsConfig) + : OPENCLAW_DEFAULT_CONFIG, + ); + return config.baseUrl || ""; + } catch { + return ""; + } + }); + + const [openclawApiKey, setOpenclawApiKey] = useState(() => { + if (appId !== "openclaw") return ""; + try { + const config = JSON.parse( + initialData?.settingsConfig + ? JSON.stringify(initialData.settingsConfig) + : OPENCLAW_DEFAULT_CONFIG, + ); + return config.apiKey || ""; + } catch { + return ""; + } + }); + + const [openclawApi, setOpenclawApi] = useState(() => { + if (appId !== "openclaw") return "openai-completions"; + try { + const config = JSON.parse( + initialData?.settingsConfig + ? JSON.stringify(initialData.settingsConfig) + : OPENCLAW_DEFAULT_CONFIG, + ); + return config.api || "openai-completions"; + } catch { + return "openai-completions"; + } + }); + + const [openclawModels, setOpenclawModels] = useState(() => { + if (appId !== "openclaw") return []; + try { + const config = JSON.parse( + initialData?.settingsConfig + ? JSON.stringify(initialData.settingsConfig) + : OPENCLAW_DEFAULT_CONFIG, + ); + return config.models || []; + } catch { + return []; + } + }); + + // OpenClaw handlers - sync state to form + const handleOpenclawBaseUrlChange = useCallback( + (baseUrl: string) => { + setOpenclawBaseUrl(baseUrl); + try { + const config = JSON.parse( + form.getValues("settingsConfig") || OPENCLAW_DEFAULT_CONFIG, + ); + config.baseUrl = baseUrl.trim().replace(/\/+$/, ""); + form.setValue("settingsConfig", JSON.stringify(config, null, 2)); + } catch { + // ignore + } + }, + [form], + ); + + const handleOpenclawApiKeyChange = useCallback( + (apiKey: string) => { + setOpenclawApiKey(apiKey); + try { + const config = JSON.parse( + form.getValues("settingsConfig") || OPENCLAW_DEFAULT_CONFIG, + ); + config.apiKey = apiKey; + form.setValue("settingsConfig", JSON.stringify(config, null, 2)); + } catch { + // ignore + } + }, + [form], + ); + + const handleOpenclawApiChange = useCallback( + (api: string) => { + setOpenclawApi(api); + try { + const config = JSON.parse( + form.getValues("settingsConfig") || OPENCLAW_DEFAULT_CONFIG, + ); + config.api = api; + form.setValue("settingsConfig", JSON.stringify(config, null, 2)); + } catch { + // ignore + } + }, + [form], + ); + + const handleOpenclawModelsChange = useCallback( + (models: OpenClawModel[]) => { + setOpenclawModels(models); + try { + const config = JSON.parse( + form.getValues("settingsConfig") || OPENCLAW_DEFAULT_CONFIG, + ); + config.models = models; + form.setValue("settingsConfig", JSON.stringify(config, null, 2)); + } catch { + // ignore + } + }, + [form], + ); + const updateOpencodeSettings = useCallback( (updater: (config: Record) => void) => { try { @@ -1114,6 +1281,24 @@ export function ProviderForm({ } } + // OpenClaw: validate provider key + if (appId === "openclaw") { + const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/; + if (!openclawProviderKey.trim()) { + toast.error(t("openclaw.providerKeyRequired")); + return; + } + if (!keyPattern.test(openclawProviderKey)) { + toast.error(t("openclaw.providerKeyInvalid")); + return; + } + if (!isEditMode && existingOpenclawKeys.includes(openclawProviderKey)) { + toast.error(t("openclaw.providerKeyDuplicate")); + return; + } + } + + // 非官方供应商必填校验:端点和 API Key if (category !== "official") { if (appId === "claude") { if (!baseUrl.trim()) { @@ -1247,6 +1432,8 @@ export function ProviderForm({ } else { payload.providerKey = opencodeProviderKey; } + } else if (appId === "openclaw") { + payload.providerKey = openclawProviderKey; } if (category === "omo" && !payload.presetCategory) { @@ -1261,6 +1448,10 @@ export function ProviderForm({ if (activePreset.isPartner) { payload.isPartner = activePreset.isPartner; } + // OpenClaw: 传递预设的 suggestedDefaults 到提交数据 + if (activePreset.suggestedDefaults) { + payload.suggestedDefaults = activePreset.suggestedDefaults; + } } if (!isEditMode && draftCustomEndpoints.length > 0) { @@ -1399,6 +1590,21 @@ export function ProviderForm({ formWebsiteUrl: form.watch("websiteUrl") || "", }); + // 使用 API Key 链接 hook (OpenClaw) + const { + shouldShowApiKeyLink: shouldShowOpenclawApiKeyLink, + websiteUrl: openclawWebsiteUrl, + isPartner: isOpenclawPartner, + partnerPromotionKey: openclawPartnerPromotionKey, + } = useApiKeyLink({ + appId: "openclaw", + category, + selectedPresetId, + presetEntries, + formWebsiteUrl: form.watch("websiteUrl") || "", + }); + + // 使用端点测速候选 hook const speedTestEndpoints = useSpeedTestEndpoints({ appId, selectedPresetId, @@ -1430,6 +1636,14 @@ export function ProviderForm({ setOpencodeExtraOptions({}); resetOmoDraftState(); } + // OpenClaw 自定义模式:重置为空配置 + if (appId === "openclaw") { + setOpenclawProviderKey(""); + setOpenclawBaseUrl(""); + setOpenclawApiKey(""); + setOpenclawApi("openai-completions"); + setOpenclawModels([]); + } return; } @@ -1513,6 +1727,40 @@ export function ProviderForm({ return; } + // OpenClaw preset handling + if (appId === "openclaw") { + const preset = entry.preset as OpenClawProviderPreset; + const config = preset.settingsConfig; + + // Update activePreset with suggestedDefaults for OpenClaw + setActivePreset({ + id: value, + category: preset.category, + isPartner: preset.isPartner, + partnerPromotionKey: preset.partnerPromotionKey, + suggestedDefaults: preset.suggestedDefaults, + }); + + // Clear provider key (user must enter their own unique key) + setOpenclawProviderKey(""); + + // Update OpenClaw-specific states + setOpenclawBaseUrl(config.baseUrl || ""); + setOpenclawApiKey(config.apiKey || ""); + setOpenclawApi(config.api || "openai-completions"); + setOpenclawModels(config.models || []); + + // Update form fields + form.reset({ + name: preset.name, + websiteUrl: preset.websiteUrl ?? "", + settingsConfig: JSON.stringify(config, null, 2), + icon: preset.icon ?? "", + iconColor: preset.iconColor ?? "", + }); + return; + } + const preset = entry.preset as ProviderPreset; const config = applyTemplateValues( preset.settingsConfig, @@ -1617,6 +1865,54 @@ export function ProviderForm({

)}
+ ) : appId === "openclaw" ? ( +
+ + + setOpenclawProviderKey( + e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""), + ) + } + placeholder={t("openclaw.providerKeyPlaceholder")} + disabled={isEditMode} + className={ + (existingOpenclawKeys.includes(openclawProviderKey) && + !isEditMode) || + (openclawProviderKey.trim() !== "" && + !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(openclawProviderKey)) + ? "border-destructive" + : "" + } + /> + {existingOpenclawKeys.includes(openclawProviderKey) && + !isEditMode && ( +

+ {t("openclaw.providerKeyDuplicate")} +

+ )} + {openclawProviderKey.trim() !== "" && + !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(openclawProviderKey) && ( +

+ {t("openclaw.providerKeyInvalid")} +

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

+ {t("openclaw.providerKeyHint")} +

+ )} +
) : undefined } /> @@ -1752,6 +2048,26 @@ export function ProviderForm({ /> )} + {/* OpenClaw 专属字段 */} + {appId === "openclaw" && ( + + )} + + {/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */} {appId === "codex" ? ( <> {settingsConfigErrorField} + ) : appId === "openclaw" ? ( + <> +
+ + form.setValue("settingsConfig", config)} + placeholder={`{ + "baseUrl": "https://api.example.com/v1", + "apiKey": "your-api-key-here", + "api": "openai-completions", + "models": [] +}`} + rows={14} + showValidation={true} + language="json" + /> +
+ ( + + + + )} + /> + ) : ( <> +
+ +
diff --git a/src/components/settings/WebdavSyncSection.tsx b/src/components/settings/WebdavSyncSection.tsx new file mode 100644 index 000000000..1d4c48ab7 --- /dev/null +++ b/src/components/settings/WebdavSyncSection.tsx @@ -0,0 +1,775 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { + Link2, + UploadCloud, + DownloadCloud, + Loader2, + Save, + Check, + Info, + AlertTriangle, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { settingsApi } from "@/lib/api"; +import type { RemoteSnapshotInfo, WebDavSyncSettings } from "@/types"; + +// ─── WebDAV service presets ───────────────────────────────── + +interface WebDavPreset { + id: string; + label: string; + baseUrl: string; + hint: string; + matchPattern?: string; // substring match on URL +} + +const WEBDAV_PRESETS: WebDavPreset[] = [ + { + id: "jianguoyun", + label: "settings.webdavSync.presets.jianguoyun", + baseUrl: "https://dav.jianguoyun.com/dav/", + hint: "settings.webdavSync.presets.jianguoyunHint", + matchPattern: "jianguoyun.com", + }, + { + id: "nextcloud", + label: "settings.webdavSync.presets.nextcloud", + baseUrl: "https://your-server/remote.php/dav/files/USERNAME/", + hint: "settings.webdavSync.presets.nextcloudHint", + matchPattern: "remote.php/dav", + }, + { + id: "synology", + label: "settings.webdavSync.presets.synology", + baseUrl: "http://your-nas-ip:5005/", + hint: "settings.webdavSync.presets.synologyHint", + matchPattern: ":5005", + }, + { + id: "custom", + label: "settings.webdavSync.presets.custom", + baseUrl: "", + hint: "", + }, +]; + +/** Match a URL to one of the preset providers, or "custom". */ +function detectPreset(url: string): string { + if (!url) return "custom"; + for (const preset of WEBDAV_PRESETS) { + if (preset.matchPattern && url.includes(preset.matchPattern)) { + return preset.id; + } + } + return "custom"; +} + +/** Format an RFC 3339 date string for display; falls back to raw string. */ +function formatDate(rfc3339: string): string { + const d = new Date(rfc3339); + return Number.isNaN(d.getTime()) ? rfc3339 : d.toLocaleString(); +} + +// ─── Types ────────────────────────────────────────────────── + +type ActionState = + | "idle" + | "testing" + | "saving" + | "uploading" + | "downloading" + | "fetching_remote"; + +type DialogType = "upload" | "download" | null; + +interface WebdavSyncSectionProps { + config?: WebDavSyncSettings; +} + +// ─── ActionButton ─────────────────────────────────────────── + +/** Reusable button with loading spinner. */ +function ActionButton({ + actionState, + targetState, + alsoActiveFor, + icon: Icon, + activeLabel, + idleLabel, + disabled, + ...props +}: { + actionState: ActionState; + targetState: ActionState; + alsoActiveFor?: ActionState[]; + icon: LucideIcon; + activeLabel: ReactNode; + idleLabel: ReactNode; +} & Omit, "children">) { + const isActive = + actionState === targetState || + (alsoActiveFor?.includes(actionState) ?? false); + return ( + + ); +} + +// ─── Main component ───────────────────────────────────────── + +export function WebdavSyncSection({ config }: WebdavSyncSectionProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [actionState, setActionState] = useState("idle"); + const [dirty, setDirty] = useState(false); + const [passwordTouched, setPasswordTouched] = useState(false); + const [justSaved, setJustSaved] = useState(false); + const justSavedTimerRef = useRef | null>(null); + + // Local form state — credentials are only persisted on explicit "Save". + const [form, setForm] = useState(() => ({ + baseUrl: config?.baseUrl ?? "", + username: config?.username ?? "", + password: config?.password ?? "", + remoteRoot: config?.remoteRoot ?? "cc-switch-sync", + profile: config?.profile ?? "default", + })); + + // Preset selector — derived from initial URL, updated on user selection + const [presetId, setPresetId] = useState(() => + detectPreset(config?.baseUrl ?? ""), + ); + + const activePreset = WEBDAV_PRESETS.find((p) => p.id === presetId); + + // Confirmation dialog state + const [dialogType, setDialogType] = useState(null); + const [remoteInfo, setRemoteInfo] = useState(null); + + const closeDialog = useCallback(() => { + setDialogType(null); + setRemoteInfo(null); + }, []); + + // Cleanup justSaved timer on unmount + useEffect(() => { + return () => { + if (justSavedTimerRef.current) clearTimeout(justSavedTimerRef.current); + }; + }, []); + + // Sync form when config is loaded/updated from backend, but not while user is editing + useEffect(() => { + if (!config || dirty) return; + setForm({ + baseUrl: config.baseUrl ?? "", + username: config.username ?? "", + password: config.password ?? "", + remoteRoot: config.remoteRoot ?? "cc-switch-sync", + profile: config.profile ?? "default", + }); + setPasswordTouched(false); + setPresetId(detectPreset(config.baseUrl ?? "")); + }, [config, dirty]); + + const updateField = useCallback((field: keyof typeof form, value: string) => { + setForm((prev) => ({ ...prev, [field]: value })); + if (field === "password") { + setPasswordTouched(true); + } + setDirty(true); + setJustSaved(false); + if (justSavedTimerRef.current) { + clearTimeout(justSavedTimerRef.current); + justSavedTimerRef.current = null; + } + }, []); + + const handlePresetChange = useCallback((id: string) => { + setPresetId(id); + const preset = WEBDAV_PRESETS.find((p) => p.id === id); + if (preset?.baseUrl) { + setForm((prev) => ({ ...prev, baseUrl: preset.baseUrl })); + setDirty(true); + setJustSaved(false); + if (justSavedTimerRef.current) { + clearTimeout(justSavedTimerRef.current); + justSavedTimerRef.current = null; + } + } + }, []); + + // When user edits the URL, check if it still matches the current preset on blur + const handleBaseUrlBlur = useCallback(() => { + if (presetId === "custom") return; + const detected = detectPreset(form.baseUrl); + if (detected !== presetId) { + setPresetId("custom"); + } + }, [form.baseUrl, presetId]); + + const buildSettings = useCallback((): WebDavSyncSettings | null => { + const baseUrl = form.baseUrl.trim(); + if (!baseUrl) return null; + return { + enabled: true, + baseUrl, + username: form.username.trim(), + password: form.password, + remoteRoot: form.remoteRoot.trim() || "cc-switch-sync", + profile: form.profile.trim() || "default", + }; + }, [form]); + + // ─── Handlers ─────────────────────────────────────────── + + const handleTest = useCallback(async () => { + const settings = buildSettings(); + if (!settings) { + toast.error(t("settings.webdavSync.missingUrl")); + return; + } + setActionState("testing"); + try { + await settingsApi.webdavTestConnection(settings, !passwordTouched); + toast.success(t("settings.webdavSync.testSuccess")); + } catch (error) { + toast.error( + t("settings.webdavSync.testFailed", { + error: (error as Error)?.message ?? String(error), + }), + ); + } finally { + setActionState("idle"); + } + }, [buildSettings, passwordTouched, t]); + + const handleSave = useCallback(async () => { + const settings = buildSettings(); + if (!settings) { + toast.error(t("settings.webdavSync.missingUrl")); + return; + } + setActionState("saving"); + try { + await settingsApi.webdavSyncSaveSettings(settings, passwordTouched); + setDirty(false); + setPasswordTouched(false); + // Show "saved" indicator for 2 seconds + setJustSaved(true); + if (justSavedTimerRef.current) clearTimeout(justSavedTimerRef.current); + justSavedTimerRef.current = setTimeout(() => { + setJustSaved(false); + justSavedTimerRef.current = null; + }, 2000); + await queryClient.invalidateQueries(); + } catch (error) { + toast.error( + t("settings.webdavSync.saveFailed", { + error: (error as Error)?.message ?? String(error), + }), + ); + setActionState("idle"); + return; + } + + // Auto-test connection after save + setActionState("testing"); + try { + await settingsApi.webdavTestConnection(settings, true); + toast.success(t("settings.webdavSync.saveAndTestSuccess")); + } catch (error) { + toast.warning( + t("settings.webdavSync.saveAndTestFailed", { + error: (error as Error)?.message ?? String(error), + }), + ); + } finally { + setActionState("idle"); + } + }, [buildSettings, passwordTouched, queryClient, t]); + + /** Fetch remote info, then open upload confirmation dialog. */ + const handleUploadClick = useCallback(async () => { + if (dirty) { + toast.error(t("settings.webdavSync.unsavedChanges")); + return; + } + setActionState("fetching_remote"); + try { + const info = await settingsApi.webdavSyncFetchRemoteInfo(); + if ("empty" in info) { + setRemoteInfo(null); + } else { + setRemoteInfo(info); + } + setDialogType("upload"); + } catch { + setRemoteInfo(null); + toast.error(t("settings.webdavSync.fetchRemoteFailed")); + setActionState("idle"); + return; + } + setActionState("idle"); + }, [dirty, t]); + + /** Actually perform the upload after user confirms. */ + const handleUploadConfirm = useCallback(async () => { + if (dirty) { + toast.error(t("settings.webdavSync.unsavedChanges")); + return; + } + closeDialog(); + setActionState("uploading"); + try { + await settingsApi.webdavSyncUpload(); + toast.success(t("settings.webdavSync.uploadSuccess")); + await queryClient.invalidateQueries(); + } catch (error) { + toast.error( + t("settings.webdavSync.uploadFailed", { + error: (error as Error)?.message ?? String(error), + }), + ); + } finally { + setActionState("idle"); + } + }, [closeDialog, dirty, queryClient, t]); + + /** Fetch remote info, then open download confirmation dialog. */ + const handleDownloadClick = useCallback(async () => { + if (dirty) { + toast.error(t("settings.webdavSync.unsavedChanges")); + return; + } + setActionState("fetching_remote"); + try { + const info = await settingsApi.webdavSyncFetchRemoteInfo(); + if ("empty" in info) { + toast.info(t("settings.webdavSync.noRemoteData")); + return; + } + if (!info.compatible) { + toast.error( + t("settings.webdavSync.incompatibleVersion", { + version: info.version, + }), + ); + return; + } + setRemoteInfo(info); + setDialogType("download"); + } catch (error) { + toast.error( + t("settings.webdavSync.downloadFailed", { + error: (error as Error)?.message ?? String(error), + }), + ); + } finally { + setActionState("idle"); + } + }, [dirty, t]); + + /** Actually perform the download after user confirms. */ + const handleDownloadConfirm = useCallback(async () => { + if (dirty) { + toast.error(t("settings.webdavSync.unsavedChanges")); + return; + } + closeDialog(); + setActionState("downloading"); + try { + await settingsApi.webdavSyncDownload(); + toast.success(t("settings.webdavSync.downloadSuccess")); + await queryClient.invalidateQueries(); + } catch (error) { + toast.error( + t("settings.webdavSync.downloadFailed", { + error: (error as Error)?.message ?? String(error), + }), + ); + } finally { + setActionState("idle"); + } + }, [closeDialog, dirty, queryClient, t]); + + // ─── Derived state ────────────────────────────────────── + + const isLoading = actionState !== "idle"; + const hasSavedConfig = Boolean( + config?.baseUrl?.trim() && config?.username?.trim(), + ); + + const lastSyncAt = config?.status?.lastSyncAt; + const lastSyncDisplay = lastSyncAt + ? new Date(lastSyncAt * 1000).toLocaleString() + : null; + + // ─── Render ───────────────────────────────────────────── + + return ( +
+
+

+ {t("settings.webdavSync.title")} +

+

+ {t("settings.webdavSync.description")} +

+
+ +
+ {/* Config fields */} +
+ {/* Service preset selector */} +
+ + +
+ + {/* Server URL */} +
+ + updateField("baseUrl", e.target.value)} + onBlur={handleBaseUrlBlur} + placeholder={t("settings.webdavSync.baseUrlPlaceholder")} + className="text-xs flex-1" + disabled={isLoading} + /> +
+ + {/* Username */} +
+ + updateField("username", e.target.value)} + placeholder={t("settings.webdavSync.usernamePlaceholder")} + className="text-xs flex-1" + disabled={isLoading} + /> +
+ + {/* Password */} +
+ + updateField("password", e.target.value)} + placeholder={t("settings.webdavSync.passwordPlaceholder")} + className="text-xs flex-1" + autoComplete="off" + disabled={isLoading} + /> +
+ + {/* Preset hint */} + {activePreset?.hint && ( +
+ + {t(activePreset.hint)} +
+ )} + + {/* Remote Root */} +
+ + updateField("remoteRoot", e.target.value)} + placeholder="cc-switch-sync" + className="text-xs flex-1" + disabled={isLoading} + /> +
+ + {/* Profile */} +
+ + updateField("profile", e.target.value)} + placeholder="default" + className="text-xs flex-1" + disabled={isLoading} + /> +
+
+ + {/* Last sync time */} + {lastSyncDisplay && ( +

+ {t("settings.webdavSync.lastSync", { time: lastSyncDisplay })} +

+ )} + + {/* Config buttons + save status */} +
+ + + + {/* Save status indicator */} + {dirty && ( + + + {t("settings.webdavSync.unsaved")} + + )} + {!dirty && justSaved && ( + + + {t("settings.webdavSync.saved")} + + )} +
+ + {/* Sync buttons */} +
+ + +
+ {!hasSavedConfig && ( +

+ {t("settings.webdavSync.saveBeforeSync")} +

+ )} +
+ + {/* ─── Upload confirmation dialog ──────────────────── */} + { + if (!open) closeDialog(); + }} + > + + + + + {t("settings.webdavSync.confirmUpload.title")} + + +
+

{t("settings.webdavSync.confirmUpload.content")}

+
    +
  • {t("settings.webdavSync.confirmUpload.dbItem")}
  • +
  • {t("settings.webdavSync.confirmUpload.skillsItem")}
  • +
+

+ {t("settings.webdavSync.confirmUpload.targetPath")} + {": "} + + /{form.remoteRoot.trim() || "cc-switch-sync"}/v2/ + {form.profile.trim() || "default"} + +

+ {remoteInfo && ( +
+

+ {t("settings.webdavSync.confirmUpload.existingData")} +

+
+
+ {t("settings.webdavSync.confirmUpload.deviceName")} +
+
+ + {remoteInfo.deviceName} + +
+
+ {t("settings.webdavSync.confirmUpload.createdAt")} +
+
{formatDate(remoteInfo.createdAt)}
+
+
+ )} + {remoteInfo && ( +

+ {t("settings.webdavSync.confirmUpload.warning")} +

+ )} +
+
+
+ + + + +
+
+ + {/* ─── Download confirmation dialog ────────────────── */} + { + if (!open) closeDialog(); + }} + > + + + + + {t("settings.webdavSync.confirmDownload.title")} + + +
+ {remoteInfo && ( +
+
+ {t("settings.webdavSync.confirmDownload.deviceName")} +
+
+ + {remoteInfo.deviceName} + +
+
+ {t("settings.webdavSync.confirmDownload.createdAt")} +
+
{formatDate(remoteInfo.createdAt)}
+
+ {t("settings.webdavSync.confirmDownload.artifacts")} +
+
{remoteInfo.artifacts.join(", ")}
+
+ )} +

+ {t("settings.webdavSync.confirmDownload.warning")} +

+
+
+
+ + + + +
+
+
+ ); +} diff --git a/src/components/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx index 7230401fd..17e23a86b 100644 --- a/src/components/skills/UnifiedSkillsPanel.tsx +++ b/src/components/skills/UnifiedSkillsPanel.tsx @@ -53,7 +53,7 @@ const UnifiedSkillsPanel = React.forwardRef< const installFromZipMutation = useInstallSkillsFromZip(); const enabledCounts = useMemo(() => { - const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 }; + const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 }; if (!skills) return counts; skills.forEach((skill) => { for (const app of APP_IDS) { diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 000000000..5c28cbcc3 --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +1,9 @@ +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; + +const Collapsible = CollapsiblePrimitive.Root; + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/src/components/workspace/WorkspaceFileEditor.tsx b/src/components/workspace/WorkspaceFileEditor.tsx new file mode 100644 index 000000000..b81fd0b4c --- /dev/null +++ b/src/components/workspace/WorkspaceFileEditor.tsx @@ -0,0 +1,95 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import MarkdownEditor from "@/components/MarkdownEditor"; +import { FullScreenPanel } from "@/components/common/FullScreenPanel"; +import { workspaceApi } from "@/lib/api/workspace"; + +interface WorkspaceFileEditorProps { + filename: string; + isOpen: boolean; + onClose: () => void; +} + +const WorkspaceFileEditor: React.FC = ({ + filename, + isOpen, + onClose, +}) => { + const { t } = useTranslation(); + const [content, setContent] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [isDarkMode, setIsDarkMode] = useState(false); + + useEffect(() => { + setIsDarkMode(document.documentElement.classList.contains("dark")); + const observer = new MutationObserver(() => { + setIsDarkMode(document.documentElement.classList.contains("dark")); + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!isOpen || !filename) return; + + setLoading(true); + workspaceApi + .readFile(filename) + .then((data) => { + setContent(data ?? ""); + }) + .catch((err) => { + console.error("Failed to read workspace file:", err); + toast.error(t("workspace.loadFailed")); + }) + .finally(() => setLoading(false)); + }, [isOpen, filename, t]); + + const handleSave = useCallback(async () => { + setSaving(true); + try { + await workspaceApi.writeFile(filename, content); + toast.success(t("workspace.saveSuccess")); + } catch (err) { + console.error("Failed to save workspace file:", err); + toast.error(t("workspace.saveFailed")); + } finally { + setSaving(false); + } + }, [filename, content, t]); + + return ( + + {saving ? t("common.saving") : t("common.save")} + + } + > + {loading ? ( +
+ {t("prompts.loading")} +
+ ) : ( + + )} +
+ ); +}; + +export default WorkspaceFileEditor; diff --git a/src/components/workspace/WorkspaceFilesPanel.tsx b/src/components/workspace/WorkspaceFilesPanel.tsx new file mode 100644 index 000000000..2d25405af --- /dev/null +++ b/src/components/workspace/WorkspaceFilesPanel.tsx @@ -0,0 +1,129 @@ +import React, { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { + FileCode, + Heart, + User, + IdCard, + Wrench, + Brain, + Activity, + Rocket, + Power, + CheckCircle2, + Circle, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { workspaceApi } from "@/lib/api/workspace"; +import WorkspaceFileEditor from "./WorkspaceFileEditor"; + +interface WorkspaceFile { + filename: string; + icon: LucideIcon; + descKey: string; +} + +const WORKSPACE_FILES: WorkspaceFile[] = [ + { filename: "AGENTS.md", icon: FileCode, descKey: "workspace.files.agents" }, + { filename: "SOUL.md", icon: Heart, descKey: "workspace.files.soul" }, + { filename: "USER.md", icon: User, descKey: "workspace.files.user" }, + { + filename: "IDENTITY.md", + icon: IdCard, + descKey: "workspace.files.identity", + }, + { filename: "TOOLS.md", icon: Wrench, descKey: "workspace.files.tools" }, + { filename: "MEMORY.md", icon: Brain, descKey: "workspace.files.memory" }, + { + filename: "HEARTBEAT.md", + icon: Activity, + descKey: "workspace.files.heartbeat", + }, + { + filename: "BOOTSTRAP.md", + icon: Rocket, + descKey: "workspace.files.bootstrap", + }, + { filename: "BOOT.md", icon: Power, descKey: "workspace.files.boot" }, +]; + +const WorkspaceFilesPanel: React.FC = () => { + const { t } = useTranslation(); + const [editingFile, setEditingFile] = useState(null); + const [fileExists, setFileExists] = useState>({}); + + const checkFileExistence = async () => { + const results: Record = {}; + await Promise.all( + WORKSPACE_FILES.map(async (f) => { + try { + const content = await workspaceApi.readFile(f.filename); + results[f.filename] = content !== null; + } catch { + results[f.filename] = false; + } + }), + ); + setFileExists(results); + }; + + useEffect(() => { + void checkFileExistence(); + }, []); + + const handleEditorClose = () => { + setEditingFile(null); + // Re-check file existence after closing editor (file may have been created) + void checkFileExistence(); + }; + + return ( +
+

+ ~/.openclaw/workspace/ +

+ +
+ {WORKSPACE_FILES.map((file) => { + const Icon = file.icon; + const exists = fileExists[file.filename]; + + return ( + + ); + })} +
+ + +
+ ); +}; + +export default WorkspaceFilesPanel; diff --git a/src/config/appConfig.tsx b/src/config/appConfig.tsx index 6ab2b5046..95b7d2edb 100644 --- a/src/config/appConfig.tsx +++ b/src/config/appConfig.tsx @@ -1,6 +1,11 @@ import React from "react"; import type { AppId } from "@/lib/api/types"; -import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons"; +import { + ClaudeIcon, + CodexIcon, + GeminiIcon, + OpenClawIcon, +} from "@/components/BrandIcons"; import { ProviderIcon } from "@/components/ProviderIcon"; export interface AppConfig { @@ -10,7 +15,13 @@ export interface AppConfig { badgeClass: string; } -export const APP_IDS: AppId[] = ["claude", "codex", "gemini", "opencode"]; +export const APP_IDS: AppId[] = [ + "claude", + "codex", + "gemini", + "opencode", + "openclaw", +]; export const APP_ICON_MAP: Record = { claude: { @@ -52,4 +63,12 @@ export const APP_ICON_MAP: Record = { badgeClass: "bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-500/20 border-0 gap-1.5", }, + openclaw: { + label: "OpenClaw", + icon: , + activeClass: + "bg-rose-500/10 ring-1 ring-rose-500/20 hover:bg-rose-500/20 text-rose-600 dark:text-rose-400", + badgeClass: + "bg-rose-500/10 text-rose-700 dark:text-rose-300 hover:bg-rose-500/20 border-0 gap-1.5", + }, }; diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts new file mode 100644 index 000000000..6eb615797 --- /dev/null +++ b/src/config/openclawProviderPresets.ts @@ -0,0 +1,1084 @@ +/** + * OpenClaw provider presets configuration + * OpenClaw uses models.providers structure with custom provider configs + */ +import type { + ProviderCategory, + OpenClawProviderConfig, + OpenClawDefaultModel, +} from "../types"; +import type { PresetTheme, TemplateValueConfig } from "./claudeProviderPresets"; + +/** Suggested default model configuration for a preset */ +export interface OpenClawSuggestedDefaults { + /** Default model config to apply (agents.defaults.model) */ + model?: OpenClawDefaultModel; + /** Model catalog entries to add (agents.defaults.models) */ + modelCatalog?: Record; +} + +export interface OpenClawProviderPreset { + name: string; + websiteUrl: string; + apiKeyUrl?: string; + /** OpenClaw settings_config structure */ + settingsConfig: OpenClawProviderConfig; + isOfficial?: boolean; + isPartner?: boolean; + partnerPromotionKey?: string; + category?: ProviderCategory; + /** Template variable definitions */ + templateValues?: Record; + /** Visual theme config */ + theme?: PresetTheme; + /** Icon name */ + icon?: string; + /** Icon color */ + iconColor?: string; + /** Mark as custom template (for UI distinction) */ + isCustomTemplate?: boolean; + /** Suggested default model configuration */ + suggestedDefaults?: OpenClawSuggestedDefaults; +} + +/** + * OpenClaw API protocol options + * @see https://github.com/openclaw/openclaw/blob/main/docs/gateway/configuration.md + */ +export const openclawApiProtocols = [ + { value: "openai-completions", label: "OpenAI Completions" }, + { value: "openai-responses", label: "OpenAI Responses" }, + { value: "anthropic-messages", label: "Anthropic Messages" }, + { value: "google-generative-ai", label: "Google Generative AI" }, + { value: "bedrock-converse-stream", label: "AWS Bedrock" }, +] as const; + +/** + * OpenClaw provider presets list + */ +export const openclawProviderPresets: OpenClawProviderPreset[] = [ + // ========== Chinese Officials ========== + { + name: "DeepSeek", + websiteUrl: "https://platform.deepseek.com", + apiKeyUrl: "https://platform.deepseek.com/api_keys", + settingsConfig: { + baseUrl: "https://api.deepseek.com/v1", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "deepseek-chat", + name: "DeepSeek V3.2", + contextWindow: 64000, + cost: { input: 0.0005, output: 0.002 }, + }, + { + id: "deepseek-reasoner", + name: "DeepSeek R1", + contextWindow: 64000, + cost: { input: 0.0005, output: 0.002 }, + }, + ], + }, + category: "cn_official", + icon: "deepseek", + iconColor: "#1E88E5", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "sk-...", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { + primary: "deepseek/deepseek-chat", + fallbacks: ["deepseek/deepseek-reasoner"], + }, + modelCatalog: { + "deepseek/deepseek-chat": { alias: "DeepSeek" }, + "deepseek/deepseek-reasoner": { alias: "R1" }, + }, + }, + }, + { + name: "Zhipu GLM", + websiteUrl: "https://open.bigmodel.cn", + apiKeyUrl: "https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII", + settingsConfig: { + baseUrl: "https://open.bigmodel.cn/api/paas/v4", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "glm-4.7", + name: "GLM-4.7", + contextWindow: 128000, + cost: { input: 0.001, output: 0.001 }, + }, + ], + }, + category: "cn_official", + isPartner: true, + partnerPromotionKey: "zhipu", + icon: "zhipu", + iconColor: "#0F62FE", + templateValues: { + baseUrl: { + label: "Base URL", + placeholder: "https://open.bigmodel.cn/api/paas/v4", + defaultValue: "https://open.bigmodel.cn/api/paas/v4", + editorValue: "", + }, + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { primary: "zhipu/glm-4.7" }, + modelCatalog: { "zhipu/glm-4.7": { alias: "GLM" } }, + }, + }, + { + name: "Zhipu GLM en", + websiteUrl: "https://z.ai", + apiKeyUrl: "https://z.ai/subscribe?ic=8JVLJQFSKB", + settingsConfig: { + baseUrl: "https://api.z.ai/v1", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "glm-4.7", + name: "GLM-4.7", + contextWindow: 128000, + cost: { input: 0.001, output: 0.001 }, + }, + ], + }, + category: "cn_official", + isPartner: true, + partnerPromotionKey: "zhipu", + icon: "zhipu", + iconColor: "#0F62FE", + templateValues: { + baseUrl: { + label: "Base URL", + placeholder: "https://api.z.ai/v1", + defaultValue: "https://api.z.ai/v1", + editorValue: "", + }, + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { primary: "zhipu-en/glm-4.7" }, + modelCatalog: { "zhipu-en/glm-4.7": { alias: "GLM" } }, + }, + }, + { + name: "Qwen Coder", + websiteUrl: "https://bailian.console.aliyun.com", + apiKeyUrl: "https://bailian.console.aliyun.com/#/api-key", + settingsConfig: { + baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "qwen3-max", + name: "Qwen3 Max", + contextWindow: 32000, + cost: { input: 0.002, output: 0.006 }, + }, + ], + }, + category: "cn_official", + icon: "qwen", + iconColor: "#FF6A00", + templateValues: { + baseUrl: { + label: "Base URL", + placeholder: "https://dashscope.aliyuncs.com/compatible-mode/v1", + defaultValue: "https://dashscope.aliyuncs.com/compatible-mode/v1", + editorValue: "", + }, + apiKey: { + label: "API Key", + placeholder: "sk-...", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { primary: "qwen/qwen3-max" }, + modelCatalog: { "qwen/qwen3-max": { alias: "Qwen" } }, + }, + }, + { + name: "Kimi k2.5", + websiteUrl: "https://platform.moonshot.cn/console", + apiKeyUrl: "https://platform.moonshot.cn/console/api-keys", + settingsConfig: { + baseUrl: "https://api.moonshot.cn/v1", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "kimi-k2.5", + name: "Kimi K2.5", + contextWindow: 131072, + cost: { input: 0.002, output: 0.006 }, + }, + ], + }, + category: "cn_official", + icon: "kimi", + iconColor: "#6366F1", + templateValues: { + baseUrl: { + label: "Base URL", + placeholder: "https://api.moonshot.cn/v1", + defaultValue: "https://api.moonshot.cn/v1", + editorValue: "", + }, + apiKey: { + label: "API Key", + placeholder: "sk-...", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { primary: "kimi/kimi-k2.5" }, + modelCatalog: { "kimi/kimi-k2.5": { alias: "Kimi" } }, + }, + }, + { + name: "Kimi For Coding", + websiteUrl: "https://www.kimi.com/coding/docs/", + apiKeyUrl: "https://platform.moonshot.cn/console/api-keys", + settingsConfig: { + baseUrl: "https://api.kimi.com/v1", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "kimi-for-coding", + name: "Kimi For Coding", + contextWindow: 131072, + cost: { input: 0.002, output: 0.006 }, + }, + ], + }, + category: "cn_official", + icon: "kimi", + iconColor: "#6366F1", + templateValues: { + baseUrl: { + label: "Base URL", + placeholder: "https://api.kimi.com/v1", + defaultValue: "https://api.kimi.com/v1", + editorValue: "", + }, + apiKey: { + label: "API Key", + placeholder: "sk-...", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { primary: "kimi-coding/kimi-for-coding" }, + modelCatalog: { "kimi-coding/kimi-for-coding": { alias: "Kimi" } }, + }, + }, + { + name: "MiniMax", + websiteUrl: "https://platform.minimaxi.com", + apiKeyUrl: "https://platform.minimaxi.com/subscribe/coding-plan", + settingsConfig: { + baseUrl: "https://api.minimaxi.com/v1", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "MiniMax-M2.1", + name: "MiniMax M2.1", + contextWindow: 200000, + cost: { input: 0.001, output: 0.004 }, + }, + ], + }, + category: "cn_official", + isPartner: true, + partnerPromotionKey: "minimax_cn", + theme: { + backgroundColor: "#f64551", + textColor: "#FFFFFF", + }, + icon: "minimax", + iconColor: "#FF6B6B", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { primary: "minimax/MiniMax-M2.1" }, + modelCatalog: { "minimax/MiniMax-M2.1": { alias: "MiniMax" } }, + }, + }, + { + name: "MiniMax en", + websiteUrl: "https://platform.minimax.io", + apiKeyUrl: "https://platform.minimax.io/subscribe/coding-plan", + settingsConfig: { + baseUrl: "https://api.minimax.io/v1", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "MiniMax-M2.1", + name: "MiniMax M2.1", + contextWindow: 200000, + cost: { input: 0.001, output: 0.004 }, + }, + ], + }, + category: "cn_official", + isPartner: true, + partnerPromotionKey: "minimax_en", + theme: { + backgroundColor: "#f64551", + textColor: "#FFFFFF", + }, + icon: "minimax", + iconColor: "#FF6B6B", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { primary: "minimax-en/MiniMax-M2.1" }, + modelCatalog: { "minimax-en/MiniMax-M2.1": { alias: "MiniMax" } }, + }, + }, + { + name: "KAT-Coder", + websiteUrl: "https://console.streamlake.ai", + apiKeyUrl: "https://console.streamlake.ai/console/api-key", + settingsConfig: { + baseUrl: + "https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/openai", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "KAT-Coder-Pro", + name: "KAT-Coder Pro", + contextWindow: 128000, + cost: { input: 0.002, output: 0.006 }, + }, + ], + }, + category: "cn_official", + icon: "catcoder", + templateValues: { + baseUrl: { + label: "Base URL", + placeholder: + "https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/openai", + defaultValue: + "https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/openai", + editorValue: "", + }, + ENDPOINT_ID: { + label: "Endpoint ID", + placeholder: "", + editorValue: "", + }, + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { primary: "katcoder/KAT-Coder-Pro" }, + modelCatalog: { "katcoder/KAT-Coder-Pro": { alias: "KAT-Coder" } }, + }, + }, + { + name: "Longcat", + websiteUrl: "https://longcat.chat/platform", + apiKeyUrl: "https://longcat.chat/platform/api_keys", + settingsConfig: { + baseUrl: "https://api.longcat.chat/v1", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "LongCat-Flash-Chat", + name: "LongCat Flash Chat", + contextWindow: 128000, + cost: { input: 0.001, output: 0.004 }, + }, + ], + }, + category: "cn_official", + icon: "longcat", + iconColor: "#29E154", + templateValues: { + baseUrl: { + label: "Base URL", + placeholder: "https://api.longcat.chat/v1", + defaultValue: "https://api.longcat.chat/v1", + editorValue: "", + }, + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { primary: "longcat/LongCat-Flash-Chat" }, + modelCatalog: { "longcat/LongCat-Flash-Chat": { alias: "LongCat" } }, + }, + }, + { + name: "DouBaoSeed", + websiteUrl: "https://www.volcengine.com/product/doubao", + apiKeyUrl: "https://www.volcengine.com/product/doubao", + settingsConfig: { + baseUrl: "https://ark.cn-beijing.volces.com/api/v3", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "doubao-seed-code-preview-latest", + name: "DouBao Seed Code Preview", + contextWindow: 128000, + cost: { input: 0.002, output: 0.006 }, + }, + ], + }, + category: "cn_official", + icon: "doubao", + iconColor: "#3370FF", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { primary: "doubaoseed/doubao-seed-code-preview-latest" }, + modelCatalog: { + "doubaoseed/doubao-seed-code-preview-latest": { alias: "DouBao" }, + }, + }, + }, + { + name: "BaiLing", + websiteUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/get_started", + settingsConfig: { + baseUrl: "https://api.tbox.cn/v1", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "Ling-1T", + name: "Ling 1T", + contextWindow: 128000, + cost: { input: 0.001, output: 0.004 }, + }, + ], + }, + category: "cn_official", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { primary: "bailing/Ling-1T" }, + modelCatalog: { "bailing/Ling-1T": { alias: "BaiLing" } }, + }, + }, + { + name: "Xiaomi MiMo", + websiteUrl: "https://platform.xiaomimimo.com", + apiKeyUrl: "https://platform.xiaomimimo.com/#/console/api-keys", + settingsConfig: { + baseUrl: "https://api.xiaomimimo.com/v1", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "mimo-v2-flash", + name: "MiMo V2 Flash", + contextWindow: 128000, + cost: { input: 0.001, output: 0.004 }, + }, + ], + }, + category: "cn_official", + icon: "xiaomimimo", + iconColor: "#000000", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { primary: "xiaomimimo/mimo-v2-flash" }, + modelCatalog: { "xiaomimimo/mimo-v2-flash": { alias: "MiMo" } }, + }, + }, + + // ========== Aggregators ========== + { + name: "AiHubMix", + websiteUrl: "https://aihubmix.com", + apiKeyUrl: "https://aihubmix.com", + settingsConfig: { + baseUrl: "https://aihubmix.com", + apiKey: "", + api: "anthropic-messages", + models: [ + { + id: "claude-sonnet-4-5-20250929", + name: "Claude Sonnet 4.5", + contextWindow: 200000, + cost: { input: 3, output: 15 }, + }, + { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + contextWindow: 200000, + cost: { input: 5, output: 25 }, + }, + ], + }, + category: "aggregator", + icon: "aihubmix", + iconColor: "#006FFB", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { + primary: "aihubmix/claude-sonnet-4-5-20250929", + fallbacks: ["aihubmix/claude-opus-4-6"], + }, + modelCatalog: { + "aihubmix/claude-sonnet-4-5-20250929": { alias: "Sonnet" }, + "aihubmix/claude-opus-4-6": { alias: "Opus" }, + }, + }, + }, + { + name: "DMXAPI", + websiteUrl: "https://www.dmxapi.cn", + apiKeyUrl: "https://www.dmxapi.cn", + settingsConfig: { + baseUrl: "https://www.dmxapi.cn", + apiKey: "", + api: "anthropic-messages", + models: [ + { + id: "claude-sonnet-4-5-20250929", + name: "Claude Sonnet 4.5", + contextWindow: 200000, + cost: { input: 3, output: 15 }, + }, + { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + contextWindow: 200000, + cost: { input: 5, output: 25 }, + }, + ], + }, + category: "aggregator", + isPartner: true, + partnerPromotionKey: "dmxapi", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { + primary: "dmxapi/claude-sonnet-4-5-20250929", + fallbacks: ["dmxapi/claude-opus-4-6"], + }, + modelCatalog: { + "dmxapi/claude-sonnet-4-5-20250929": { alias: "Sonnet" }, + "dmxapi/claude-opus-4-6": { alias: "Opus" }, + }, + }, + }, + { + name: "OpenRouter", + websiteUrl: "https://openrouter.ai", + apiKeyUrl: "https://openrouter.ai/keys", + settingsConfig: { + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "anthropic/claude-sonnet-4.5", + name: "Claude Sonnet 4.5", + contextWindow: 200000, + cost: { input: 3, output: 15 }, + }, + { + id: "anthropic/claude-opus-4.6", + name: "Claude Opus 4.6", + contextWindow: 200000, + cost: { input: 5, output: 25 }, + }, + ], + }, + category: "aggregator", + icon: "openrouter", + iconColor: "#6566F1", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "sk-or-...", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { + primary: "openrouter/anthropic/claude-sonnet-4.5", + fallbacks: ["openrouter/anthropic/claude-opus-4.6"], + }, + modelCatalog: { + "openrouter/anthropic/claude-sonnet-4.5": { alias: "Sonnet" }, + "openrouter/anthropic/claude-opus-4.6": { alias: "Opus" }, + }, + }, + }, + { + name: "ModelScope", + websiteUrl: "https://modelscope.cn", + apiKeyUrl: "https://modelscope.cn/my/myaccesstoken", + settingsConfig: { + baseUrl: "https://api-inference.modelscope.cn/v1", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "ZhipuAI/GLM-4.7", + name: "GLM-4.7", + contextWindow: 128000, + cost: { input: 0.001, output: 0.001 }, + }, + ], + }, + category: "aggregator", + icon: "modelscope", + iconColor: "#624AFF", + templateValues: { + baseUrl: { + label: "Base URL", + placeholder: "https://api-inference.modelscope.cn/v1", + defaultValue: "https://api-inference.modelscope.cn/v1", + editorValue: "", + }, + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { primary: "modelscope/ZhipuAI/GLM-4.7" }, + modelCatalog: { "modelscope/ZhipuAI/GLM-4.7": { alias: "GLM" } }, + }, + }, + { + name: "SiliconFlow", + websiteUrl: "https://siliconflow.cn", + apiKeyUrl: "https://cloud.siliconflow.cn/me/account/ak", + settingsConfig: { + baseUrl: "https://api.siliconflow.cn/v1", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "Pro/MiniMaxAI/MiniMax-M2.1", + name: "MiniMax M2.1", + contextWindow: 200000, + cost: { input: 0.001, output: 0.004 }, + }, + ], + }, + category: "aggregator", + icon: "siliconflow", + iconColor: "#6E29F6", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "sk-...", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { primary: "siliconflow/Pro/MiniMaxAI/MiniMax-M2.1" }, + modelCatalog: { + "siliconflow/Pro/MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax" }, + }, + }, + }, + { + name: "SiliconFlow en", + websiteUrl: "https://siliconflow.com", + apiKeyUrl: "https://cloud.siliconflow.com/account/ak", + settingsConfig: { + baseUrl: "https://api.siliconflow.com/v1", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "MiniMaxAI/MiniMax-M2.1", + name: "MiniMax M2.1", + contextWindow: 200000, + cost: { input: 0.001, output: 0.004 }, + }, + ], + }, + category: "aggregator", + icon: "siliconflow", + iconColor: "#000000", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "sk-...", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { primary: "siliconflow-en/MiniMaxAI/MiniMax-M2.1" }, + modelCatalog: { + "siliconflow-en/MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax" }, + }, + }, + }, + { + name: "Nvidia", + websiteUrl: "https://build.nvidia.com", + apiKeyUrl: "https://build.nvidia.com/settings/api-keys", + settingsConfig: { + baseUrl: "https://integrate.api.nvidia.com/v1", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "moonshotai/kimi-k2.5", + name: "Kimi K2.5", + contextWindow: 131072, + cost: { input: 0.002, output: 0.006 }, + }, + ], + }, + category: "aggregator", + icon: "nvidia", + iconColor: "#000000", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "nvapi-...", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { primary: "nvidia/moonshotai/kimi-k2.5" }, + modelCatalog: { "nvidia/moonshotai/kimi-k2.5": { alias: "Kimi" } }, + }, + }, + + // ========== Third Party Partners ========== + { + name: "PackyCode", + websiteUrl: "https://www.packyapi.com", + apiKeyUrl: "https://www.packyapi.com/register?aff=cc-switch", + settingsConfig: { + baseUrl: "https://www.packyapi.com", + apiKey: "", + api: "anthropic-messages", + models: [ + { + id: "claude-sonnet-4-5-20250929", + name: "Claude Sonnet 4.5", + contextWindow: 200000, + cost: { input: 3, output: 15 }, + }, + { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + contextWindow: 200000, + cost: { input: 5, output: 25 }, + }, + ], + }, + category: "third_party", + isPartner: true, + partnerPromotionKey: "packycode", + icon: "packycode", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { + primary: "packycode/claude-sonnet-4-5-20250929", + fallbacks: ["packycode/claude-opus-4-6"], + }, + modelCatalog: { + "packycode/claude-sonnet-4-5-20250929": { alias: "Sonnet" }, + "packycode/claude-opus-4-6": { alias: "Opus" }, + }, + }, + }, + { + name: "Cubence", + websiteUrl: "https://cubence.com", + apiKeyUrl: "https://cubence.com/signup?code=CCSWITCH&source=ccs", + settingsConfig: { + baseUrl: "https://api.cubence.com", + apiKey: "", + api: "anthropic-messages", + models: [ + { + id: "claude-sonnet-4-5-20250929", + name: "Claude Sonnet 4.5", + contextWindow: 200000, + cost: { input: 3, output: 15 }, + }, + { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + contextWindow: 200000, + cost: { input: 5, output: 25 }, + }, + ], + }, + category: "third_party", + isPartner: true, + partnerPromotionKey: "cubence", + icon: "cubence", + iconColor: "#000000", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { + primary: "cubence/claude-sonnet-4-5-20250929", + fallbacks: ["cubence/claude-opus-4-6"], + }, + modelCatalog: { + "cubence/claude-sonnet-4-5-20250929": { alias: "Sonnet" }, + "cubence/claude-opus-4-6": { alias: "Opus" }, + }, + }, + }, + { + name: "AIGoCode", + websiteUrl: "https://aigocode.com", + apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH", + settingsConfig: { + baseUrl: "https://api.aigocode.com", + apiKey: "", + api: "anthropic-messages", + models: [ + { + id: "claude-sonnet-4-5-20250929", + name: "Claude Sonnet 4.5", + contextWindow: 200000, + cost: { input: 3, output: 15 }, + }, + { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + contextWindow: 200000, + cost: { input: 5, output: 25 }, + }, + ], + }, + category: "third_party", + isPartner: true, + partnerPromotionKey: "aigocode", + icon: "aigocode", + iconColor: "#5B7FFF", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { + primary: "aigocode/claude-sonnet-4-5-20250929", + fallbacks: ["aigocode/claude-opus-4-6"], + }, + modelCatalog: { + "aigocode/claude-sonnet-4-5-20250929": { alias: "Sonnet" }, + "aigocode/claude-opus-4-6": { alias: "Opus" }, + }, + }, + }, + { + name: "RightCode", + websiteUrl: "https://www.right.codes", + apiKeyUrl: "https://www.right.codes/register?aff=CCSWITCH", + settingsConfig: { + baseUrl: "https://www.right.codes/claude", + apiKey: "", + api: "anthropic-messages", + models: [ + { + id: "claude-sonnet-4-5-20250929", + name: "Claude Sonnet 4.5", + contextWindow: 200000, + cost: { input: 3, output: 15 }, + }, + { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + contextWindow: 200000, + cost: { input: 5, output: 25 }, + }, + ], + }, + category: "third_party", + isPartner: true, + partnerPromotionKey: "rightcode", + icon: "rc", + iconColor: "#E96B2C", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { + primary: "rightcode/claude-sonnet-4-5-20250929", + fallbacks: ["rightcode/claude-opus-4-6"], + }, + modelCatalog: { + "rightcode/claude-sonnet-4-5-20250929": { alias: "Sonnet" }, + "rightcode/claude-opus-4-6": { alias: "Opus" }, + }, + }, + }, + { + name: "AICodeMirror", + websiteUrl: "https://www.aicodemirror.com", + apiKeyUrl: "https://www.aicodemirror.com/register?invitecode=9915W3", + settingsConfig: { + baseUrl: "https://api.aicodemirror.com/api/claudecode", + apiKey: "", + api: "anthropic-messages", + models: [ + { + id: "claude-sonnet-4-5-20250929", + name: "Claude Sonnet 4.5", + contextWindow: 200000, + cost: { input: 3, output: 15 }, + }, + { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + contextWindow: 200000, + cost: { input: 5, output: 25 }, + }, + ], + }, + category: "third_party", + isPartner: true, + partnerPromotionKey: "aicodemirror", + icon: "aicodemirror", + iconColor: "#000000", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { + primary: "aicodemirror/claude-sonnet-4-5-20250929", + fallbacks: ["aicodemirror/claude-opus-4-6"], + }, + modelCatalog: { + "aicodemirror/claude-sonnet-4-5-20250929": { alias: "Sonnet" }, + "aicodemirror/claude-opus-4-6": { alias: "Opus" }, + }, + }, + }, + + // ========== Custom Template ========== + { + name: "OpenAI Compatible", + websiteUrl: "", + settingsConfig: { + baseUrl: "", + apiKey: "", + api: "openai-completions", + models: [], + }, + category: "custom", + isCustomTemplate: true, + icon: "generic", + iconColor: "#6B7280", + templateValues: { + baseUrl: { + label: "Base URL", + placeholder: "https://api.example.com/v1", + editorValue: "", + }, + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + }, +]; diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index c01c1448d..51bfa5503 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -844,7 +844,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, models: { "claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" }, - "claude-opus-4-5-20251101": { name: "Claude Opus 4.5" }, + "claude-opus-4-6": { name: "Claude Opus 4.6" }, }, }, category: "aggregator", @@ -871,7 +871,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, models: { "claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" }, - "claude-opus-4-5-20251101": { name: "Claude Opus 4.5" }, + "claude-opus-4-6": { name: "Claude Opus 4.6" }, }, }, category: "aggregator", @@ -898,7 +898,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, models: { "anthropic/claude-sonnet-4.5": { name: "Claude Sonnet 4.5" }, - "anthropic/claude-opus-4.5": { name: "Claude Opus 4.5" }, + "anthropic/claude-opus-4.6": { name: "Claude Opus 4.6" }, }, }, category: "aggregator", @@ -952,7 +952,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, models: { "claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" }, - "claude-opus-4-5-20251101": { name: "Claude Opus 4.5" }, + "claude-opus-4-6": { name: "Claude Opus 4.6" }, }, }, category: "third_party", @@ -980,7 +980,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, models: { "claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" }, - "claude-opus-4-5-20251101": { name: "Claude Opus 4.5" }, + "claude-opus-4-6": { name: "Claude Opus 4.6" }, }, }, category: "third_party", @@ -1009,7 +1009,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, models: { "claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" }, - "claude-opus-4-5-20251101": { name: "Claude Opus 4.5" }, + "claude-opus-4-6": { name: "Claude Opus 4.6" }, }, }, category: "third_party", @@ -1070,7 +1070,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, models: { "claude-sonnet-4.5": { name: "Claude Sonnet 4.5" }, - "claude-opus-4.5": { name: "Claude Opus 4.5" }, + "claude-opus-4.6": { name: "Claude Opus 4.6" }, }, }, category: "third_party", diff --git a/src/hooks/useOpenClaw.ts b/src/hooks/useOpenClaw.ts new file mode 100644 index 000000000..41d7b006b --- /dev/null +++ b/src/hooks/useOpenClaw.ts @@ -0,0 +1,131 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { openclawApi } from "@/lib/api/openclaw"; +import { providersApi } from "@/lib/api/providers"; +import type { + OpenClawEnvConfig, + OpenClawToolsConfig, + OpenClawAgentsDefaults, +} from "@/types"; + +/** + * Centralized query keys for all OpenClaw-related queries. + * Import this from any file that needs to invalidate OpenClaw caches. + */ +export const openclawKeys = { + all: ["openclaw"] as const, + liveProviderIds: ["openclaw", "liveProviderIds"] as const, + defaultModel: ["openclaw", "defaultModel"] as const, + env: ["openclaw", "env"] as const, + tools: ["openclaw", "tools"] as const, + agentsDefaults: ["openclaw", "agentsDefaults"] as const, +}; + +// ============================================================ +// Query hooks +// ============================================================ + +/** + * Query live provider IDs from openclaw.json config. + * Used by ProviderList to show "In Config" badge. + */ +export function useOpenClawLiveProviderIds(enabled: boolean) { + return useQuery({ + queryKey: openclawKeys.liveProviderIds, + queryFn: () => providersApi.getOpenClawLiveProviderIds(), + enabled, + }); +} + +/** + * Query the default model from agents.defaults.model. + * Used by ProviderList to show which provider is the default. + */ +export function useOpenClawDefaultModel(enabled: boolean) { + return useQuery({ + queryKey: openclawKeys.defaultModel, + queryFn: () => openclawApi.getDefaultModel(), + enabled, + }); +} + +/** + * Query env section of openclaw.json. + */ +export function useOpenClawEnv() { + return useQuery({ + queryKey: openclawKeys.env, + queryFn: () => openclawApi.getEnv(), + staleTime: 30_000, + }); +} + +/** + * Query tools section of openclaw.json. + */ +export function useOpenClawTools() { + return useQuery({ + queryKey: openclawKeys.tools, + queryFn: () => openclawApi.getTools(), + staleTime: 30_000, + }); +} + +/** + * Query agents.defaults section of openclaw.json. + */ +export function useOpenClawAgentsDefaults() { + return useQuery({ + queryKey: openclawKeys.agentsDefaults, + queryFn: () => openclawApi.getAgentsDefaults(), + staleTime: 30_000, + }); +} + +// ============================================================ +// Mutation hooks +// ============================================================ + +/** + * Save env config. Invalidates env query on success. + * Toast notifications are handled by the component. + */ +export function useSaveOpenClawEnv() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (env: OpenClawEnvConfig) => openclawApi.setEnv(env), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: openclawKeys.env }); + }, + }); +} + +/** + * Save tools config. Invalidates tools query on success. + * Toast notifications are handled by the component. + */ +export function useSaveOpenClawTools() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (tools: OpenClawToolsConfig) => openclawApi.setTools(tools), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: openclawKeys.tools }); + }, + }); +} + +/** + * Save agents.defaults config. Invalidates both agentsDefaults and defaultModel + * queries on success (since changing agents.defaults may affect the default model). + * Toast notifications are handled by the component. + */ +export function useSaveOpenClawAgentsDefaults() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (defaults: OpenClawAgentsDefaults) => + openclawApi.setAgentsDefaults(defaults), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: openclawKeys.agentsDefaults }); + queryClient.invalidateQueries({ queryKey: openclawKeys.defaultModel }); + }, + }); +} diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index ede55f8a2..42a5c4120 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -2,8 +2,14 @@ import { useCallback } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; -import { providersApi, settingsApi, type AppId } from "@/lib/api"; -import type { Provider, UsageScript } from "@/types"; +import { providersApi, settingsApi, openclawApi, type AppId } from "@/lib/api"; +import type { + Provider, + UsageScript, + OpenClawProviderConfig, + OpenClawDefaultModel, +} from "@/types"; +import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets"; import { useAddProviderMutation, useUpdateProviderMutation, @@ -11,6 +17,7 @@ import { useSwitchProviderMutation, } from "@/lib/query"; import { extractErrorMessage } from "@/utils/errorUtils"; +import { openclawKeys } from "@/hooks/useOpenClaw"; /** * Hook for managing provider actions (add, update, delete, switch) @@ -54,10 +61,55 @@ export function useProviderActions(activeApp: AppId) { // 添加供应商 const addProvider = useCallback( - async (provider: Omit & { providerKey?: string }) => { + async ( + provider: Omit & { + providerKey?: string; + suggestedDefaults?: OpenClawSuggestedDefaults; + }, + ) => { await addProviderMutation.mutateAsync(provider); + + // OpenClaw: register models to allowlist after adding provider + if (activeApp === "openclaw" && provider.suggestedDefaults) { + const { model, modelCatalog } = provider.suggestedDefaults; + let modelsRegistered = false; + + try { + // 1. Merge model catalog (allowlist) + if (modelCatalog && Object.keys(modelCatalog).length > 0) { + const existingCatalog = (await openclawApi.getModelCatalog()) || {}; + const mergedCatalog = { ...existingCatalog, ...modelCatalog }; + await openclawApi.setModelCatalog(mergedCatalog); + modelsRegistered = true; + } + + // 2. Set default model (only if not already set) + if (model) { + const existingDefault = await openclawApi.getDefaultModel(); + if (!existingDefault?.primary) { + await openclawApi.setDefaultModel(model); + } + } + + // Show success toast if models were registered + if (modelsRegistered) { + toast.success( + t("notifications.openclawModelsRegistered", { + defaultValue: "模型已注册到 /model 列表", + }), + { closeButton: true }, + ); + } + } catch (error) { + // Log warning but don't block main flow - provider config is already saved + console.warn( + "[OpenClaw] Failed to register models to allowlist:", + error, + ); + } + } }, - [addProviderMutation], + [addProviderMutation, activeApp, t], ); // 更新供应商 @@ -104,13 +156,15 @@ export function useProviderActions(activeApp: AppId) { ); } else { // 普通供应商:显示切换成功 - // OpenCode: show "added to config" message instead of "switched" - const messageKey = - activeApp === "opencode" - ? "notifications.addToConfigSuccess" - : "notifications.switchSuccess"; - const defaultMessage = - activeApp === "opencode" ? "已添加到配置" : "切换成功!"; + // OpenCode/OpenClaw: show "added to config" message instead of "switched" + const isMultiProviderApp = + activeApp === "opencode" || activeApp === "openclaw"; + const messageKey = isMultiProviderApp + ? "notifications.addToConfigSuccess" + : "notifications.switchSuccess"; + const defaultMessage = isMultiProviderApp + ? "已添加到配置" + : "切换成功!"; toast.success(t(messageKey, { defaultValue: defaultMessage }), { closeButton: true, @@ -170,12 +224,54 @@ export function useProviderActions(activeApp: AppId) { [activeApp, queryClient, t], ); + // Set provider as default model (OpenClaw only) + const setAsDefaultModel = useCallback( + async (provider: Provider) => { + const config = provider.settingsConfig as OpenClawProviderConfig; + if (!config.models || config.models.length === 0) { + toast.error( + t("notifications.openclawNoModels", { + defaultValue: "该供应商没有配置模型", + }), + ); + return; + } + + const model: OpenClawDefaultModel = { + primary: `${provider.id}/${config.models[0].id}`, + fallbacks: config.models.slice(1).map((m) => `${provider.id}/${m.id}`), + }; + + try { + await openclawApi.setDefaultModel(model); + await queryClient.invalidateQueries({ + queryKey: openclawKeys.defaultModel, + }); + toast.success( + t("notifications.openclawDefaultModelSet", { + defaultValue: "已设为默认模型", + }), + { closeButton: true }, + ); + } catch (error) { + const detail = + extractErrorMessage(error) || + t("notifications.openclawDefaultModelSetFailed", { + defaultValue: "设置默认模型失败", + }); + toast.error(detail); + } + }, + [queryClient, t], + ); + return { addProvider, updateProvider, switchProvider, deleteProvider, saveUsageScript, + setAsDefaultModel, isLoading: addProviderMutation.isPending || updateProviderMutation.isPending || diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 55d73d0e1..402da1682 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -135,9 +135,10 @@ export function useSettings(): UseSettingsResult { const sanitizedOpencodeDir = sanitizeDir( mergedSettings.opencodeConfigDir, ); + const { webdavSync: _ignoredWebdavSync, ...restSettings } = mergedSettings; const payload: Settings = { - ...mergedSettings, + ...restSettings, claudeConfigDir: sanitizedClaudeDir, codexConfigDir: sanitizedCodexDir, geminiConfigDir: sanitizedGeminiDir, @@ -251,9 +252,10 @@ export function useSettings(): UseSettingsResult { const previousCodexDir = sanitizeDir(data?.codexConfigDir); const previousGeminiDir = sanitizeDir(data?.geminiConfigDir); const previousOpencodeDir = sanitizeDir(data?.opencodeConfigDir); + const { webdavSync: _ignoredWebdavSync, ...restSettings } = mergedSettings; const payload: Settings = { - ...mergedSettings, + ...restSettings, claudeConfigDir: sanitizedClaudeDir, codexConfigDir: sanitizedCodexDir, geminiConfigDir: sanitizedGeminiDir, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6de6e1a38..69acd81da 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -88,6 +88,8 @@ "addOpenCodeProvider": "Add OpenCode Provider", "addToConfig": "Add", "removeFromConfig": "Remove", + "setAsDefault": "Enable", + "isDefault": "Default", "inConfig": "Added", "addProviderHint": "Fill in the information to quickly switch providers in the list.", "editClaudeProvider": "Edit Claude Code Provider", @@ -162,7 +164,11 @@ "settingsSaved": "Settings saved", "settingsSaveFailed": "Failed to save settings: {{error}}", "openAIChatFormatHint": "This provider uses OpenAI Chat format and requires the proxy service to be enabled", - "openLinkFailed": "Failed to open link" + "openLinkFailed": "Failed to open link", + "openclawModelsRegistered": "Models have been registered to /model list", + "openclawDefaultModelSet": "Set as default model", + "openclawDefaultModelSetFailed": "Failed to set default model", + "openclawNoModels": "No models configured" }, "confirm": { "deleteProvider": "Delete Provider", @@ -266,6 +272,77 @@ "selectFileFailed": "Please choose a valid SQL backup file", "configCorrupted": "SQL file may be corrupted or invalid", "backupId": "Backup ID", + "webdavSync": { + "title": "WebDAV Cloud Sync", + "description": "Sync database and skill configurations across devices via WebDAV.", + "baseUrl": "WebDAV Server URL", + "baseUrlPlaceholder": "https://dav.example.com/dav/", + "username": "WebDAV Account", + "usernamePlaceholder": "Email or username", + "password": "WebDAV Password", + "passwordPlaceholder": "App password", + "remoteRoot": "Remote Root Directory", + "profile": "Sync Profile Name", + "test": "Test Connection", + "testing": "Testing...", + "testSuccess": "Connection successful", + "testFailed": "Connection failed: {{error}}", + "save": "Save Config", + "saving": "Saving...", + "saveFailed": "Failed to save config: {{error}}", + "upload": "Upload to Cloud", + "uploading": "Uploading...", + "uploadSuccess": "Uploaded to WebDAV", + "uploadFailed": "Upload failed: {{error}}", + "download": "Download from Cloud", + "downloading": "Downloading...", + "downloadSuccess": "Downloaded and restored from WebDAV", + "downloadFailed": "Download failed: {{error}}", + "lastSync": "Last sync: {{time}}", + "missingUrl": "Please enter the WebDAV server URL", + "presets": { + "label": "Provider", + "jianguoyun": "Jianguoyun", + "jianguoyunHint": "Generate an \"App Password\" in Jianguoyun security settings. Do not use your login password.", + "nextcloud": "Nextcloud", + "nextcloudHint": "Replace your-server with your Nextcloud domain and USERNAME with your username.", + "synology": "Synology NAS", + "synologyHint": "Install and enable the WebDAV Server package in Synology Package Center first.", + "custom": "Custom" + }, + "remoteRootDefault": "Default: cc-switch-sync", + "profileDefault": "Default: default", + "saveAndTestSuccess": "Config saved, connection OK", + "saveAndTestFailed": "Config saved, but connection test failed: {{error}}", + "noRemoteData": "No sync data found on the remote server", + "incompatibleVersion": "Remote data version incompatible (v{{version}}), current supports v2", + "unsaved": "Unsaved", + "saved": "Saved", + "unsavedChanges": "Please save config first", + "saveBeforeSync": "Save configuration first to enable upload/download.", + "fetchingRemote": "Fetching remote info...", + "fetchRemoteFailed": "Failed to fetch remote info. Please check configuration and network.", + "confirmDownload": { + "title": "Restore from Cloud", + "deviceName": "Uploaded by", + "createdAt": "Uploaded at", + "artifacts": "Contents", + "warning": "This will overwrite all local data and skill configurations", + "confirm": "Confirm Restore" + }, + "confirmUpload": { + "title": "Upload to Cloud", + "content": "The following will be synced to the WebDAV server:", + "dbItem": "Database (all provider configs and data)", + "skillsItem": "Skills (all custom skills)", + "targetPath": "Target path", + "existingData": "Existing cloud data", + "deviceName": "Uploaded by", + "createdAt": "Uploaded at", + "warning": "This will overwrite existing sync data on the remote server", + "confirm": "Confirm Upload" + } + }, "autoReload": "Data refreshed", "languageOptionChinese": "中文", "languageOptionEnglish": "English", @@ -407,7 +484,8 @@ "claude": "Claude", "codex": "Codex", "gemini": "Gemini", - "opencode": "OpenCode" + "opencode": "OpenCode", + "openclaw": "OpenClaw" }, "sessionManager": { "title": "Session Manager", @@ -660,6 +738,38 @@ "modelOptionKeyPlaceholder": "provider", "modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}" }, + "openclaw": { + "providerKey": "Provider Key", + "providerKeyPlaceholder": "my-provider", + "providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.", + "providerKeyRequired": "Provider key is required", + "providerKeyDuplicate": "This key is already in use", + "providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.", + "apiProtocol": "API Protocol", + "selectProtocol": "Select API Protocol", + "apiProtocolHint": "Select the protocol type compatible with the provider's API. Most providers use OpenAI Completions format.", + "baseUrl": "API Endpoint", + "baseUrlHint": "The provider's API endpoint address.", + "models": "Models", + "addModel": "Add Model", + "noModels": "No models configured. Click Add Model to configure available models.", + "modelId": "Model ID", + "modelIdPlaceholder": "claude-3-sonnet", + "modelName": "Display Name", + "modelNamePlaceholder": "Claude 3 Sonnet", + "contextWindow": "Context Window", + "maxTokens": "Max Output Tokens", + "reasoning": "Reasoning Mode", + "reasoningOn": "Enabled", + "reasoningOff": "Disabled", + "inputCost": "Input Cost ($/M tokens)", + "outputCost": "Output Cost ($/M tokens)", + "advancedOptions": "Advanced Options", + "cacheReadCost": "Cache Read Cost ($/M tokens)", + "cacheWriteCost": "Cache Write Cost ($/M tokens)", + "cacheCostHint": "Cache costs are used to calculate Prompt Caching costs. Leave empty if not using caching.", + "modelsHint": "Configure the models supported by this provider. Model ID is used for API calls, Display Name for the interface." + }, "providerPreset": { "label": "Provider Preset", "custom": "Custom Configuration", @@ -855,7 +965,8 @@ "claude": "Claude", "codex": "Codex", "gemini": "Gemini", - "opencode": "OpenCode" + "opencode": "OpenCode", + "openclaw": "OpenClaw" } }, "userLevelPath": "User-level MCP path", @@ -1028,6 +1139,74 @@ "deleteMessage": "Are you sure you want to delete prompt \"{{name}}\"?" } }, + "workspace": { + "title": "Workspace Files", + "manage": "Workspace", + "files": { + "agents": "Agent instructions and rules", + "soul": "Agent personality and communication style", + "user": "User profile and preferences", + "identity": "Agent name and avatar", + "tools": "Local tool documentation", + "memory": "Long-term memory and decisions", + "heartbeat": "Heartbeat run checklist", + "bootstrap": "First-run bootstrap ritual", + "boot": "Gateway reboot checklist" + }, + "editing": "Edit {{filename}}", + "saveSuccess": "Saved successfully", + "saveFailed": "Failed to save", + "loadFailed": "Failed to load" + }, + "openclaw": { + "env": { + "title": "Environment Variables", + "description": "Manage environment variables in openclaw.json (API keys, custom variables, etc.)", + "keyPlaceholder": "Variable name", + "valuePlaceholder": "Value", + "add": "Add Variable", + "saveSuccess": "Environment variables saved", + "saveFailed": "Failed to save environment variables", + "loadFailed": "Failed to load environment variables", + "duplicateKey": "Duplicate variable name detected: {{key}}" + }, + "tools": { + "title": "Tool Permissions", + "description": "Manage tool permissions in openclaw.json (allow/deny lists)", + "profile": "Permission Profile", + "profiles": { + "default": "Default", + "strict": "Strict", + "permissive": "Permissive", + "custom": "Custom" + }, + "allowList": "Allow List", + "denyList": "Deny List", + "patternPlaceholder": "Tool name or pattern", + "addAllow": "Add Allow", + "addDeny": "Add Deny", + "saveSuccess": "Tool permissions saved", + "saveFailed": "Failed to save tool permissions", + "loadFailed": "Failed to load tool permissions" + }, + "agents": { + "title": "Agents Config", + "description": "Manage agents.defaults in openclaw.json (default model, runtime parameters, etc.)", + "modelSection": "Model Configuration", + "primaryModel": "Primary Model", + "primaryModelHint": "Format: provider/model-id", + "fallbackModels": "Fallback Models", + "fallbackModelsHint": "Comma-separated, ordered by priority", + "runtimeSection": "Runtime Parameters", + "workspace": "Workspace Path", + "timeout": "Timeout (seconds)", + "contextTokens": "Context Tokens", + "maxConcurrent": "Max Concurrent", + "saveSuccess": "Agents config saved", + "saveFailed": "Failed to save agents config", + "loadFailed": "Failed to load agents config" + } + }, "env": { "warning": { "title": "Environment Variable Conflicts Detected", @@ -1170,7 +1349,8 @@ "claude": "Claude", "codex": "Codex", "gemini": "Gemini", - "opencode": "OpenCode" + "opencode": "OpenCode", + "openclaw": "OpenClaw" }, "installFromZip": { "button": "Install from ZIP", @@ -1671,5 +1851,34 @@ "unspecifiedHigh": "Uncategorized high-effort task category for tasks that don't fit other categories with large workload. Defaults to Claude Opus 4.6 max variant.", "writing": "Writing category for documentation, prose and technical writing. Defaults to Gemini 3 Flash fast generation model." } + }, + "openclawConfig": { + "defaultModel": { + "title": "Default Model", + "description": "Configure the default primary model and fallback models for OpenClaw", + "primary": "Primary Model", + "primaryPlaceholder": "e.g., deepseek/deepseek-chat", + "fallbacks": "Fallback Models", + "fallbacksPlaceholder": "e.g., openrouter/anthropic/claude-sonnet-4.5", + "addFallback": "Add Fallback Model", + "saved": "Default model configuration saved", + "saveFailed": "Failed to save default model" + }, + "modelCatalog": { + "title": "Model Catalog", + "description": "Configure model aliases and allowlist", + "modelId": "Model ID", + "modelIdPlaceholder": "e.g., deepseek/deepseek-chat", + "alias": "Alias", + "aliasPlaceholder": "e.g., DeepSeek", + "addEntry": "Add Model", + "removeEntry": "Remove", + "saved": "Model catalog saved", + "saveFailed": "Failed to save model catalog", + "empty": "No model catalog entries", + "emptyHint": "Add models to the catalog to configure aliases" + }, + "suggestedDefaults": "Apply Suggested Defaults", + "suggestedDefaultsHint": "Use this preset's recommended default model configuration" } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 87047b29b..4b37c5010 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -88,6 +88,8 @@ "addOpenCodeProvider": "OpenCode プロバイダーを追加", "addToConfig": "追加", "removeFromConfig": "削除", + "setAsDefault": "有効化", + "isDefault": "デフォルト", "inConfig": "追加済み", "addProviderHint": "一覧にすばやく切り替えられるよう、ここに情報を入力してください。", "editClaudeProvider": "Claude Code プロバイダーを編集", @@ -162,7 +164,11 @@ "settingsSaved": "設定を保存しました", "settingsSaveFailed": "設定の保存に失敗しました: {{error}}", "openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です", - "openLinkFailed": "リンクを開けませんでした" + "openLinkFailed": "リンクを開けませんでした", + "openclawModelsRegistered": "モデルが /model リストに登録されました", + "openclawDefaultModelSet": "デフォルトモデルに設定しました", + "openclawDefaultModelSetFailed": "デフォルトモデルの設定に失敗しました", + "openclawNoModels": "モデルが設定されていません" }, "confirm": { "deleteProvider": "プロバイダーを削除", @@ -266,6 +272,77 @@ "selectFileFailed": "有効な SQL バックアップファイルを選択してください", "configCorrupted": "SQL ファイルが壊れているか形式が無効な可能性があります", "backupId": "バックアップ ID", + "webdavSync": { + "title": "WebDAV クラウド同期", + "description": "WebDAV を使ってデバイス間でデータベースとスキル設定を同期します。", + "baseUrl": "WebDAV サーバー URL", + "baseUrlPlaceholder": "https://example.com/remote.php/dav/files/user", + "username": "ユーザー名", + "usernamePlaceholder": "メールアドレスまたはユーザー名", + "password": "パスワード", + "passwordPlaceholder": "アプリパスワード", + "remoteRoot": "リモートルートディレクトリ", + "profile": "同期プロファイル名", + "test": "接続テスト", + "testing": "テスト中...", + "testSuccess": "接続成功", + "testFailed": "接続失敗:{{error}}", + "save": "設定を保存", + "saving": "保存中...", + "saveFailed": "設定の保存に失敗しました:{{error}}", + "upload": "クラウドにアップロード", + "uploading": "アップロード中...", + "uploadSuccess": "WebDAV にアップロードしました", + "uploadFailed": "アップロードに失敗しました:{{error}}", + "download": "クラウドからダウンロード", + "downloading": "ダウンロード中...", + "downloadSuccess": "WebDAV からダウンロード・復元しました", + "downloadFailed": "ダウンロードに失敗しました:{{error}}", + "lastSync": "前回の同期:{{time}}", + "missingUrl": "WebDAV サーバー URL を入力してください", + "presets": { + "label": "サービス", + "jianguoyun": "坚果云", + "jianguoyunHint": "坚果云の「セキュリティ設定」で「サードパーティアプリパスワード」を生成してください。ログインパスワードは使用しないでください。", + "nextcloud": "Nextcloud", + "nextcloudHint": "your-server を Nextcloud サーバーのアドレスに、USERNAME をユーザー名に置き換えてください。", + "synology": "Synology NAS", + "synologyHint": "Synology の「パッケージセンター」で WebDAV Server パッケージをインストール・有効化してください。", + "custom": "カスタム" + }, + "remoteRootDefault": "デフォルト: cc-switch-sync", + "profileDefault": "デフォルト: default", + "saveAndTestSuccess": "設定を保存しました。接続正常です", + "saveAndTestFailed": "設定を保存しましたが、接続テストに失敗しました:{{error}}", + "noRemoteData": "クラウドに同期データが見つかりません", + "incompatibleVersion": "リモートデータのバージョンに互換性がありません(v{{version}})。現在 v2 をサポートしています", + "unsaved": "未保存", + "saved": "保存済み", + "unsavedChanges": "先に設定を保存してください", + "saveBeforeSync": "アップロード/ダウンロードを有効にするには、先に設定を保存してください。", + "fetchingRemote": "リモート情報を取得中...", + "fetchRemoteFailed": "リモート情報の取得に失敗しました。設定とネットワークを確認してください。", + "confirmDownload": { + "title": "クラウドから復元", + "deviceName": "アップロード元", + "createdAt": "アップロード日時", + "artifacts": "内容", + "warning": "ローカルのすべてのデータとスキル設定が上書きされます", + "confirm": "復元を実行" + }, + "confirmUpload": { + "title": "クラウドにアップロード", + "content": "以下の内容を WebDAV サーバーに同期します:", + "dbItem": "データベース(すべてのプロバイダー設定とデータ)", + "skillsItem": "スキル(すべてのカスタムスキル)", + "targetPath": "保存先パス", + "existingData": "クラウドの既存データ", + "deviceName": "アップロード元", + "createdAt": "アップロード日時", + "warning": "リモートの既存同期データが上書きされます", + "confirm": "アップロードを実行" + } + }, "autoReload": "データを更新しました", "languageOptionChinese": "中文", "languageOptionEnglish": "English", @@ -407,7 +484,8 @@ "claude": "Claude", "codex": "Codex", "gemini": "Gemini", - "opencode": "OpenCode" + "opencode": "OpenCode", + "openclaw": "OpenClaw" }, "sessionManager": { "title": "セッション管理", @@ -660,6 +738,38 @@ "modelOptionKeyPlaceholder": "provider", "modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}" }, + "openclaw": { + "providerKey": "プロバイダーキー", + "providerKeyPlaceholder": "my-provider", + "providerKeyHint": "設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。", + "providerKeyRequired": "プロバイダーキーを入力してください", + "providerKeyDuplicate": "このキーは既に使用されています", + "providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。", + "apiProtocol": "API プロトコル", + "selectProtocol": "API プロトコルを選択", + "apiProtocolHint": "プロバイダーの API と互換性のあるプロトコルタイプを選択してください。ほとんどのプロバイダーは OpenAI Completions 形式を使用します。", + "baseUrl": "API エンドポイント", + "baseUrlHint": "プロバイダーの API エンドポイントアドレス。", + "models": "モデル一覧", + "addModel": "モデルを追加", + "noModels": "モデルが設定されていません。「モデルを追加」をクリックして利用可能なモデルを設定してください。", + "modelId": "モデル ID", + "modelIdPlaceholder": "claude-3-sonnet", + "modelName": "表示名", + "modelNamePlaceholder": "Claude 3 Sonnet", + "contextWindow": "コンテキストウィンドウ", + "maxTokens": "最大出力トークン数", + "reasoning": "推論モード", + "reasoningOn": "有効", + "reasoningOff": "無効", + "inputCost": "入力コスト ($/M トークン)", + "outputCost": "出力コスト ($/M トークン)", + "advancedOptions": "詳細オプション", + "cacheReadCost": "キャッシュ読取コスト ($/M トークン)", + "cacheWriteCost": "キャッシュ書込コスト ($/M トークン)", + "cacheCostHint": "キャッシュコストは Prompt Caching のコスト計算に使用されます。キャッシュを使用しない場合は空欄のままにしてください。", + "modelsHint": "このプロバイダーがサポートするモデルを設定します。モデル ID は API 呼び出しに、表示名はインターフェースに使用されます。" + }, "providerPreset": { "label": "プロバイダータイプ", "custom": "カスタム設定", @@ -855,7 +965,8 @@ "claude": "Claude", "codex": "Codex", "gemini": "Gemini", - "opencode": "OpenCode" + "opencode": "OpenCode", + "openclaw": "OpenClaw" } }, "userLevelPath": "ユーザーレベルの MCP パス", @@ -1028,6 +1139,74 @@ "deleteMessage": "プロンプト「{{name}}」を削除してもよろしいですか?" } }, + "workspace": { + "title": "ワークスペースファイル", + "manage": "ワークスペース", + "files": { + "agents": "エージェントの操作指示とルール", + "soul": "エージェントの人格とコミュニケーションスタイル", + "user": "ユーザープロファイルと設定", + "identity": "エージェントの名前とアバター", + "tools": "ローカルツールドキュメント", + "memory": "長期記憶と意思決定記録", + "heartbeat": "ハートビート実行チェックリスト", + "bootstrap": "初回実行ブートストラップ", + "boot": "ゲートウェイ再起動チェックリスト" + }, + "editing": "{{filename}} を編集", + "saveSuccess": "保存しました", + "saveFailed": "保存に失敗しました", + "loadFailed": "読み込みに失敗しました" + }, + "openclaw": { + "env": { + "title": "環境変数", + "description": "openclaw.json の環境変数設定を管理(APIキー、カスタム変数など)", + "keyPlaceholder": "変数名", + "valuePlaceholder": "値", + "add": "変数を追加", + "saveSuccess": "環境変数を保存しました", + "saveFailed": "環境変数の保存に失敗しました", + "loadFailed": "環境変数の読み込みに失敗しました", + "duplicateKey": "重複する変数名が検出されました: {{key}}" + }, + "tools": { + "title": "ツール権限", + "description": "openclaw.json のツール権限設定を管理(許可/拒否リスト)", + "profile": "権限プロファイル", + "profiles": { + "default": "デフォルト", + "strict": "厳格", + "permissive": "寛容", + "custom": "カスタム" + }, + "allowList": "許可リスト", + "denyList": "拒否リスト", + "patternPlaceholder": "ツール名またはパターン", + "addAllow": "許可を追加", + "addDeny": "拒否を追加", + "saveSuccess": "ツール権限を保存しました", + "saveFailed": "ツール権限の保存に失敗しました", + "loadFailed": "ツール権限の読み込みに失敗しました" + }, + "agents": { + "title": "Agents 設定", + "description": "openclaw.json の agents.defaults 設定を管理(デフォルトモデル、ランタイムパラメータなど)", + "modelSection": "モデル設定", + "primaryModel": "プライマリモデル", + "primaryModelHint": "形式: provider/model-id", + "fallbackModels": "フォールバックモデル", + "fallbackModelsHint": "カンマ区切り、優先度順", + "runtimeSection": "ランタイムパラメータ", + "workspace": "ワークスペースパス", + "timeout": "タイムアウト(秒)", + "contextTokens": "コンテキストトークン数", + "maxConcurrent": "最大同時実行数", + "saveSuccess": "Agents 設定を保存しました", + "saveFailed": "Agents 設定の保存に失敗しました", + "loadFailed": "Agents 設定の読み込みに失敗しました" + } + }, "env": { "warning": { "title": "競合する環境変数を検出しました", @@ -1168,7 +1347,8 @@ "claude": "Claude", "codex": "Codex", "gemini": "Gemini", - "opencode": "OpenCode" + "opencode": "OpenCode", + "openclaw": "OpenClaw" }, "installFromZip": { "button": "ZIP からインストール", @@ -1652,5 +1832,34 @@ "unspecifiedHigh": "未分類の高作業量タスクカテゴリ。他のカテゴリに該当せず作業量が大きいタスクに適用。デフォルトで Claude Opus 4.6 の max バリアントを使用。", "writing": "ライティングカテゴリ。ドキュメント、散文、技術文書に特化。デフォルトで Gemini 3 Flash 高速生成モデルを使用。" } + }, + "openclawConfig": { + "defaultModel": { + "title": "デフォルトモデル", + "description": "OpenClaw のデフォルトのプライマリモデルとフォールバックモデルを設定します", + "primary": "プライマリモデル", + "primaryPlaceholder": "例: deepseek/deepseek-chat", + "fallbacks": "フォールバックモデル", + "fallbacksPlaceholder": "例: openrouter/anthropic/claude-sonnet-4.5", + "addFallback": "フォールバックモデルを追加", + "saved": "デフォルトモデル設定を保存しました", + "saveFailed": "デフォルトモデルの保存に失敗しました" + }, + "modelCatalog": { + "title": "モデルカタログ", + "description": "モデルのエイリアスと許可リストを設定します", + "modelId": "モデル ID", + "modelIdPlaceholder": "例: deepseek/deepseek-chat", + "alias": "エイリアス", + "aliasPlaceholder": "例: DeepSeek", + "addEntry": "モデルを追加", + "removeEntry": "削除", + "saved": "モデルカタログを保存しました", + "saveFailed": "モデルカタログの保存に失敗しました", + "empty": "モデルカタログエントリがありません", + "emptyHint": "エイリアスを設定するにはカタログにモデルを追加してください" + }, + "suggestedDefaults": "推奨デフォルト設定を適用", + "suggestedDefaultsHint": "このプリセットの推奨デフォルトモデル設定を使用します" } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 731b1c558..f18795aba 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -88,6 +88,8 @@ "addOpenCodeProvider": "添加 OpenCode 供应商", "addToConfig": "添加", "removeFromConfig": "移除", + "setAsDefault": "启用", + "isDefault": "默认", "inConfig": "已添加", "addProviderHint": "填写信息后即可在列表中快速切换供应商。", "editClaudeProvider": "编辑 Claude Code 供应商", @@ -162,7 +164,11 @@ "settingsSaved": "设置已保存", "settingsSaveFailed": "保存设置失败:{{error}}", "openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用", - "openLinkFailed": "链接打开失败" + "openLinkFailed": "链接打开失败", + "openclawModelsRegistered": "模型已注册到 /model 列表", + "openclawDefaultModelSet": "已设为默认模型", + "openclawDefaultModelSetFailed": "设置默认模型失败", + "openclawNoModels": "该供应商没有配置模型" }, "confirm": { "deleteProvider": "删除供应商", @@ -266,6 +272,77 @@ "selectFileFailed": "请选择有效的 SQL 备份文件", "configCorrupted": "SQL 文件可能已损坏或格式不正确", "backupId": "备份ID", + "webdavSync": { + "title": "WebDAV 云同步", + "description": "通过 WebDAV 在多设备间同步数据库和技能配置。", + "baseUrl": "WebDAV 服务器地址", + "baseUrlPlaceholder": "https://dav.jianguoyun.com/dav/", + "username": "WebDAV 账户", + "usernamePlaceholder": "邮箱账号", + "password": "WebDAV 密码", + "passwordPlaceholder": "应用密码(坚果云请使用「第三方应用密码」)", + "remoteRoot": "远程根目录", + "profile": "同步配置名", + "test": "测试连接", + "testing": "测试中...", + "testSuccess": "连接成功", + "testFailed": "连接失败:{{error}}", + "save": "保存配置", + "saving": "保存中...", + "saveFailed": "保存配置失败:{{error}}", + "upload": "上传到云端", + "uploading": "上传中...", + "uploadSuccess": "已上传到 WebDAV", + "uploadFailed": "上传失败:{{error}}", + "download": "从云端下载", + "downloading": "下载中...", + "downloadSuccess": "已从 WebDAV 下载并恢复", + "downloadFailed": "下载失败:{{error}}", + "lastSync": "上次同步:{{time}}", + "missingUrl": "请填写 WebDAV 服务器地址", + "presets": { + "label": "服务商", + "jianguoyun": "坚果云", + "jianguoyunHint": "请在坚果云「安全选项」中生成「第三方应用密码」,不要使用登录密码。", + "nextcloud": "Nextcloud", + "nextcloudHint": "请将 your-server 替换为你的 Nextcloud 服务器地址,USERNAME 替换为你的用户名。", + "synology": "群晖 NAS", + "synologyHint": "请先在群晖「套件中心」安装并启用 WebDAV Server 套件。", + "custom": "自定义" + }, + "remoteRootDefault": "默认: cc-switch-sync", + "profileDefault": "默认: default", + "saveAndTestSuccess": "配置已保存,连接正常", + "saveAndTestFailed": "配置已保存,但连接测试失败:{{error}}", + "noRemoteData": "云端没有找到同步数据", + "incompatibleVersion": "远端数据版本不兼容(v{{version}}),当前支持 v2", + "unsaved": "未保存", + "saved": "已保存", + "unsavedChanges": "请先保存配置", + "saveBeforeSync": "请先保存配置,再使用上传/下载。", + "fetchingRemote": "获取远端信息...", + "fetchRemoteFailed": "获取远端信息失败,请检查配置和网络后重试。", + "confirmDownload": { + "title": "从云端恢复", + "deviceName": "上传设备", + "createdAt": "上传时间", + "artifacts": "包含内容", + "warning": "恢复将覆盖本地所有数据和技能配置", + "confirm": "确认恢复" + }, + "confirmUpload": { + "title": "上传到云端", + "content": "将同步以下内容到 WebDAV 服务器:", + "dbItem": "数据库(所有 Provider 配置和数据)", + "skillsItem": "技能包(所有自定义技能)", + "targetPath": "目标路径", + "existingData": "云端已有数据", + "deviceName": "上传设备", + "createdAt": "上传时间", + "warning": "将覆盖云端已有的同步数据", + "confirm": "确认上传" + } + }, "autoReload": "数据已刷新", "languageOptionChinese": "中文", "languageOptionEnglish": "English", @@ -407,7 +484,8 @@ "claude": "Claude", "codex": "Codex", "gemini": "Gemini", - "opencode": "OpenCode" + "opencode": "OpenCode", + "openclaw": "OpenClaw" }, "sessionManager": { "title": "会话管理", @@ -660,6 +738,38 @@ "modelOptionKeyPlaceholder": "provider", "modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}" }, + "openclaw": { + "providerKey": "供应商标识", + "providerKeyPlaceholder": "my-provider", + "providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符", + "providerKeyRequired": "请填写供应商标识", + "providerKeyDuplicate": "此标识已被使用,请更换", + "providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符", + "apiProtocol": "API 协议", + "selectProtocol": "选择 API 协议", + "apiProtocolHint": "选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。", + "baseUrl": "API 端点", + "baseUrlHint": "供应商的 API 端点地址。", + "models": "模型列表", + "addModel": "添加模型", + "noModels": "暂无模型配置。点击添加模型来配置可用模型。", + "modelId": "模型 ID", + "modelIdPlaceholder": "claude-3-sonnet", + "modelName": "显示名称", + "modelNamePlaceholder": "Claude 3 Sonnet", + "contextWindow": "上下文窗口", + "maxTokens": "最大输出 Tokens", + "reasoning": "推理模式", + "reasoningOn": "启用", + "reasoningOff": "关闭", + "inputCost": "输入价格 ($/M tokens)", + "outputCost": "输出价格 ($/M tokens)", + "advancedOptions": "高级选项", + "cacheReadCost": "缓存读取价格 ($/M tokens)", + "cacheWriteCost": "缓存写入价格 ($/M tokens)", + "cacheCostHint": "缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。", + "modelsHint": "配置该供应商支持的模型。模型 ID 用于 API 调用,显示名称用于界面展示。" + }, "providerPreset": { "label": "预设供应商", "custom": "自定义配置", @@ -855,7 +965,8 @@ "claude": "Claude", "codex": "Codex", "gemini": "Gemini", - "opencode": "OpenCode" + "opencode": "OpenCode", + "openclaw": "OpenClaw" } }, "userLevelPath": "用户级 MCP 配置路径", @@ -1028,6 +1139,74 @@ "deleteMessage": "确定要删除提示词 \"{{name}}\" 吗?" } }, + "workspace": { + "title": "Workspace 文件管理", + "manage": "Workspace", + "files": { + "agents": "Agent 操作指令和规则", + "soul": "Agent 人格和沟通风格", + "user": "用户档案和偏好", + "identity": "Agent 名称和头像", + "tools": "本地工具文档", + "memory": "长期记忆和决策记录", + "heartbeat": "心跳运行清单", + "bootstrap": "首次运行仪式", + "boot": "网关重启清单" + }, + "editing": "编辑 {{filename}}", + "saveSuccess": "保存成功", + "saveFailed": "保存失败", + "loadFailed": "读取失败" + }, + "openclaw": { + "env": { + "title": "环境变量", + "description": "管理 openclaw.json 中的环境变量配置(API Key、自定义变量等)", + "keyPlaceholder": "变量名", + "valuePlaceholder": "值", + "add": "添加变量", + "saveSuccess": "环境变量已保存", + "saveFailed": "保存环境变量失败", + "loadFailed": "读取环境变量失败", + "duplicateKey": "检测到重复的变量名: {{key}}" + }, + "tools": { + "title": "工具权限", + "description": "管理 openclaw.json 中的工具权限配置(允许/拒绝列表)", + "profile": "权限模式", + "profiles": { + "default": "默认", + "strict": "严格", + "permissive": "宽松", + "custom": "自定义" + }, + "allowList": "允许列表", + "denyList": "拒绝列表", + "patternPlaceholder": "工具名称或模式", + "addAllow": "添加允许", + "addDeny": "添加拒绝", + "saveSuccess": "工具权限已保存", + "saveFailed": "保存工具权限失败", + "loadFailed": "读取工具权限失败" + }, + "agents": { + "title": "Agents 配置", + "description": "管理 openclaw.json 中的 agents.defaults 配置(默认模型、运行参数等)", + "modelSection": "模型配置", + "primaryModel": "主模型", + "primaryModelHint": "格式:provider/model-id", + "fallbackModels": "回退模型", + "fallbackModelsHint": "逗号分隔,按优先级排列", + "runtimeSection": "运行参数", + "workspace": "工作区路径", + "timeout": "超时时间(秒)", + "contextTokens": "上下文 Token 数", + "maxConcurrent": "最大并发数", + "saveSuccess": "Agents 配置已保存", + "saveFailed": "保存 Agents 配置失败", + "loadFailed": "读取 Agents 配置失败" + } + }, "env": { "warning": { "title": "检测到系统环境变量冲突", @@ -1170,7 +1349,8 @@ "claude": "Claude", "codex": "Codex", "gemini": "Gemini", - "opencode": "OpenCode" + "opencode": "OpenCode", + "openclaw": "OpenClaw" }, "installFromZip": { "button": "从 ZIP 安装", @@ -1671,5 +1851,34 @@ "unspecifiedHigh": "未归类高工作量任务类别,适用于不适合其他类别且工作量较大的任务,默认使用 Claude Opus 4.6 的最大变体。", "writing": "写作类别,专注于文档、散文和技术写作,默认使用 Gemini 3 Flash 快速生成模型。" } + }, + "openclawConfig": { + "defaultModel": { + "title": "默认模型", + "description": "配置 OpenClaw 的默认主模型和回退模型", + "primary": "主模型", + "primaryPlaceholder": "例如: deepseek/deepseek-chat", + "fallbacks": "回退模型", + "fallbacksPlaceholder": "例如: openrouter/anthropic/claude-sonnet-4.5", + "addFallback": "添加回退模型", + "saved": "默认模型配置已保存", + "saveFailed": "保存默认模型失败" + }, + "modelCatalog": { + "title": "模型目录", + "description": "配置可用模型的别名和允许列表", + "modelId": "模型 ID", + "modelIdPlaceholder": "例如: deepseek/deepseek-chat", + "alias": "别名", + "aliasPlaceholder": "例如: DeepSeek", + "addEntry": "添加模型", + "removeEntry": "移除", + "saved": "模型目录已保存", + "saveFailed": "保存模型目录失败", + "empty": "暂无模型目录配置", + "emptyHint": "添加模型到目录以配置别名" + }, + "suggestedDefaults": "应用建议默认配置", + "suggestedDefaultsHint": "使用此预设推荐的默认模型配置" } } diff --git a/src/icons/extracted/claw.svg b/src/icons/extracted/claw.svg new file mode 100644 index 000000000..bcbc1e10c --- /dev/null +++ b/src/icons/extracted/claw.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/icons/extracted/index.ts b/src/icons/extracted/index.ts index dcc9bb12d..a87ab5d88 100644 --- a/src/icons/extracted/index.ts +++ b/src/icons/extracted/index.ts @@ -37,6 +37,7 @@ export const icons: Record = { notion: `Notion`, ollama: `Ollama`, openai: `OpenAI`, + openclaw: `OpenClaw`, packycode: `PackyCode`, palm: `PaLM`, perplexity: `Perplexity`, diff --git a/src/icons/extracted/metadata.ts b/src/icons/extracted/metadata.ts index 0c103d028..6b2c8a8a1 100644 --- a/src/icons/extracted/metadata.ts +++ b/src/icons/extracted/metadata.ts @@ -247,6 +247,13 @@ export const iconMetadata: Record = { keywords: ["gpt", "chatgpt"], defaultColor: "currentColor", }, + openclaw: { + name: "openclaw", + displayName: "OpenClaw", + category: "ai-provider", + keywords: ["openclaw", "lobster", "claw"], + defaultColor: "#ff4f40", + }, packycode: { name: "packycode", displayName: "PackyCode", diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 8e48d0151..7a0bfd1c2 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -7,7 +7,9 @@ export { skillsApi } from "./skills"; export { usageApi } from "./usage"; export { vscodeApi } from "./vscode"; export { proxyApi } from "./proxy"; +export { openclawApi } from "./openclaw"; export { sessionsApi } from "./sessions"; +export { workspaceApi } from "./workspace"; export * as configApi from "./config"; export type { ProviderSwitchEvent } from "./providers"; export type { Prompt } from "./prompts"; diff --git a/src/lib/api/openclaw.ts b/src/lib/api/openclaw.ts new file mode 100644 index 000000000..b448ec73c --- /dev/null +++ b/src/lib/api/openclaw.ts @@ -0,0 +1,105 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { + OpenClawDefaultModel, + OpenClawModelCatalogEntry, + OpenClawAgentsDefaults, + OpenClawEnvConfig, + OpenClawToolsConfig, +} from "@/types"; + +/** + * OpenClaw configuration API + * + * Manages ~/.openclaw/openclaw.json sections: + * - agents.defaults (model, catalog) + * - env (environment variables) + * - tools (permissions) + */ +export const openclawApi = { + // ============================================================ + // Agents Configuration + // ============================================================ + + /** + * Get default model configuration (agents.defaults.model) + */ + async getDefaultModel(): Promise { + return await invoke("get_openclaw_default_model"); + }, + + /** + * Set default model configuration (agents.defaults.model) + */ + async setDefaultModel(model: OpenClawDefaultModel): Promise { + return await invoke("set_openclaw_default_model", { model }); + }, + + /** + * Get model catalog/allowlist (agents.defaults.models) + */ + async getModelCatalog(): Promise | null> { + return await invoke("get_openclaw_model_catalog"); + }, + + /** + * Set model catalog/allowlist (agents.defaults.models) + */ + async setModelCatalog( + catalog: Record, + ): Promise { + return await invoke("set_openclaw_model_catalog", { catalog }); + }, + + /** + * Get full agents.defaults config (all fields) + */ + async getAgentsDefaults(): Promise { + return await invoke("get_openclaw_agents_defaults"); + }, + + /** + * Set full agents.defaults config (all fields) + */ + async setAgentsDefaults(defaults: OpenClawAgentsDefaults): Promise { + return await invoke("set_openclaw_agents_defaults", { defaults }); + }, + + // ============================================================ + // Env Configuration + // ============================================================ + + /** + * Get env config (env section of openclaw.json) + */ + async getEnv(): Promise { + return await invoke("get_openclaw_env"); + }, + + /** + * Set env config (env section of openclaw.json) + */ + async setEnv(env: OpenClawEnvConfig): Promise { + return await invoke("set_openclaw_env", { env }); + }, + + // ============================================================ + // Tools Configuration + // ============================================================ + + /** + * Get tools config (tools section of openclaw.json) + */ + async getTools(): Promise { + return await invoke("get_openclaw_tools"); + }, + + /** + * Set tools config (tools section of openclaw.json) + */ + async setTools(tools: OpenClawToolsConfig): Promise { + return await invoke("set_openclaw_tools", { tools }); + }, +}; diff --git a/src/lib/api/providers.ts b/src/lib/api/providers.ts index dd154b4e5..d78ed0224 100644 --- a/src/lib/api/providers.ts +++ b/src/lib/api/providers.ts @@ -98,6 +98,14 @@ export const providersApi = { async getOpenCodeLiveProviderIds(): Promise { return await invoke("get_opencode_live_provider_ids"); }, + + /** + * 获取 OpenClaw live 配置中的供应商 ID 列表 + * 用于前端判断供应商是否已添加到 openclaw.json + */ + async getOpenClawLiveProviderIds(): Promise { + return await invoke("get_openclaw_live_provider_ids"); + }, }; // ============================================================================ diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index cd8de3666..6b791659d 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Settings } from "@/types"; +import type { Settings, WebDavSyncSettings, RemoteSnapshotInfo } from "@/types"; import type { AppId } from "./types"; export interface ConfigTransferResult { @@ -9,6 +9,15 @@ export interface ConfigTransferResult { backupId?: string; } +export interface WebDavTestResult { + success: boolean; + message?: string; +} + +export interface WebDavSyncResult { + status: string; +} + export const settingsApi = { async get(): Promise { return await invoke("get_settings"); @@ -93,6 +102,42 @@ export const settingsApi = { return await invoke("import_config_from_file", { filePath }); }, + // ─── WebDAV v2 sync ─────────────────────────────────────── + + async webdavTestConnection( + settings: WebDavSyncSettings, + preserveEmptyPassword = true, + ): Promise { + return await invoke("webdav_test_connection", { + settings, + preserveEmptyPassword, + }); + }, + + async webdavSyncUpload(): Promise { + return await invoke("webdav_sync_upload"); + }, + + async webdavSyncDownload(): Promise { + return await invoke("webdav_sync_download"); + }, + + async webdavSyncSaveSettings( + settings: WebDavSyncSettings, + passwordTouched = false, + ): Promise<{ success: boolean }> { + return await invoke("webdav_sync_save_settings", { + settings, + passwordTouched, + }); + }, + + async webdavSyncFetchRemoteInfo(): Promise< + RemoteSnapshotInfo | { empty: true } + > { + return await invoke("webdav_sync_fetch_remote_info"); + }, + async syncCurrentProvidersLive(): Promise { const result = (await invoke("sync_current_providers_live")) as { success?: boolean; diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index b3f3b940f..151090773 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -2,7 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { AppId } from "@/lib/api/types"; -// ========== 类型定义 ========== +export type AppType = "claude" | "codex" | "gemini" | "opencode" | "openclaw"; /** Skill 应用启用状态 */ export interface SkillApps { @@ -10,6 +10,7 @@ export interface SkillApps { codex: boolean; gemini: boolean; opencode: boolean; + openclaw: boolean; } /** 已安装的 Skill(v3.10.0+ 统一结构) */ diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 2e8e2b2ac..1fff721fc 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -1,2 +1,2 @@ // 前端统一使用 AppId 作为应用标识(与后端命令参数 `app` 一致) -export type AppId = "claude" | "codex" | "gemini" | "opencode"; +export type AppId = "claude" | "codex" | "gemini" | "opencode" | "openclaw"; diff --git a/src/lib/api/workspace.ts b/src/lib/api/workspace.ts new file mode 100644 index 000000000..095913483 --- /dev/null +++ b/src/lib/api/workspace.ts @@ -0,0 +1,11 @@ +import { invoke } from "@tauri-apps/api/core"; + +export const workspaceApi = { + async readFile(filename: string): Promise { + return invoke("read_workspace_file", { filename }); + }, + + async writeFile(filename: string, content: string): Promise { + return invoke("write_workspace_file", { filename, content }); + }, +}; diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index 89e3e486b..2b21ab330 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -5,6 +5,7 @@ import { providersApi, settingsApi, type AppId } from "@/lib/api"; import type { Provider, Settings } from "@/types"; import { extractErrorMessage } from "@/utils/errorUtils"; import { generateUUID } from "@/utils/uuid"; +import { openclawKeys } from "@/hooks/useOpenClaw"; export const useAddProviderMutation = (appId: AppId) => { const queryClient = useQueryClient(); @@ -16,12 +17,12 @@ export const useAddProviderMutation = (appId: AppId) => { ) => { let id: string; - if (appId === "opencode") { + if (appId === "opencode" || appId === "openclaw") { if (providerInput.category === "omo") { id = `omo-${generateUUID()}`; } else { if (!providerInput.providerKey) { - throw new Error("Provider key is required for OpenCode"); + throw new Error(`Provider key is required for ${appId}`); } id = providerInput.providerKey; } @@ -29,8 +30,10 @@ export const useAddProviderMutation = (appId: AppId) => { id = generateUUID(); } + const { providerKey: _providerKey, ...rest } = providerInput; + const newProvider: Provider = { - ...providerInput, + ...rest, id, createdAt: Date.now(), }; @@ -174,6 +177,7 @@ export const useSwitchProviderMutation = (appId: AppId) => { onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["providers", appId] }); + // OpenCode/OpenClaw: also invalidate live provider IDs cache to update button state if (appId === "opencode") { await queryClient.invalidateQueries({ queryKey: ["opencodeLiveProviderIds"], @@ -182,6 +186,14 @@ export const useSwitchProviderMutation = (appId: AppId) => { queryKey: ["omo", "current-provider-id"], }); } + if (appId === "openclaw") { + await queryClient.invalidateQueries({ + queryKey: openclawKeys.liveProviderIds, + }); + await queryClient.invalidateQueries({ + queryKey: openclawKeys.defaultModel, + }); + } try { await providersApi.updateTrayMenu(); diff --git a/src/lib/query/queryClient.ts b/src/lib/query/queryClient.ts index 2d3a9e2b8..ed5001759 100644 --- a/src/lib/query/queryClient.ts +++ b/src/lib/query/queryClient.ts @@ -4,8 +4,8 @@ export const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, - refetchOnWindowFocus: false, - staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: true, + staleTime: 0, }, mutations: { retry: false, diff --git a/src/lib/schemas/settings.ts b/src/lib/schemas/settings.ts index 74db80585..d226ef34c 100644 --- a/src/lib/schemas/settings.ts +++ b/src/lib/schemas/settings.ts @@ -28,6 +28,27 @@ export const settingsSchema = z.object({ // Skill 同步设置 skillSyncMethod: z.enum(["auto", "symlink", "copy"]).optional(), + + // WebDAV v2 同步设置(通过专用命令保存,schema 仅用于读取) + webdavSync: z + .object({ + enabled: z.boolean().optional(), + baseUrl: z.string().trim().optional().or(z.literal("")), + username: z.string().trim().optional().or(z.literal("")), + password: z.string().optional(), + remoteRoot: z.string().trim().optional().or(z.literal("")), + profile: z.string().trim().optional().or(z.literal("")), + status: z + .object({ + lastSyncAt: z.number().nullable().optional(), + lastError: z.string().nullable().optional(), + lastRemoteEtag: z.string().nullable().optional(), + lastLocalManifestHash: z.string().nullable().optional(), + lastRemoteManifestHash: z.string().nullable().optional(), + }) + .optional(), + }) + .optional(), }); export type SettingsFormData = z.infer; diff --git a/src/types.ts b/src/types.ts index 94af23ea7..d42b86ee4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -160,6 +160,37 @@ export interface VisibleApps { codex: boolean; gemini: boolean; opencode: boolean; + openclaw: boolean; +} + +// WebDAV v2 同步状态 +export interface WebDavSyncStatus { + lastSyncAt?: number | null; + lastError?: string | null; + lastRemoteEtag?: string | null; + lastLocalManifestHash?: string | null; + lastRemoteManifestHash?: string | null; +} + +// WebDAV v2 同步配置 +export interface WebDavSyncSettings { + enabled?: boolean; + baseUrl?: string; + username?: string; + password?: string; + remoteRoot?: string; + profile?: string; + status?: WebDavSyncStatus; +} + +// 远端快照信息(下载前预览) +export interface RemoteSnapshotInfo { + deviceName: string; + createdAt: string; + snapshotId: string; + version: number; + compatible: boolean; + artifacts: string[]; } // 应用设置类型(用于设置对话框与 Tauri API) @@ -193,6 +224,8 @@ export interface Settings { geminiConfigDir?: string; // 覆盖 OpenCode 配置目录(可选) opencodeConfigDir?: string; + // 覆盖 OpenClaw 配置目录(可选) + openclawConfigDir?: string; // ===== 当前供应商 ID(设备级)===== // 当前 Claude 供应商 ID(优先于数据库 is_current) @@ -206,6 +239,9 @@ export interface Settings { // Skill 同步方式:auto(默认,优先 symlink)、symlink、copy skillSyncMethod?: SkillSyncMethod; + // ===== WebDAV v2 同步设置 ===== + webdavSync?: WebDavSyncSettings; + // ===== 终端设置 ===== // 首选终端应用(可选,默认使用系统默认终端) // macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" @@ -254,6 +290,7 @@ export interface McpApps { codex: boolean; gemini: boolean; opencode: boolean; + openclaw: boolean; } // MCP 服务器条目(v3.7.0 统一结构) @@ -391,3 +428,64 @@ export interface OpenCodeMcpServerSpec { // 通用字段 enabled?: boolean; } + +// ============================================================================ +// OpenClaw 专属配置(v3.11.0+) +// ============================================================================ + +// OpenClaw 模型配置 +export interface OpenClawModel { + id: string; + name: string; + alias?: string; + reasoning?: boolean; // 是否支持推理模式(如 o1、DeepSeek R1) + input?: string[]; // 支持的输入类型(如 ["text"]、["text", "image"]) + cost?: { + input: number; + output: number; + cacheRead?: number; // 缓存读取价格 + cacheWrite?: number; // 缓存写入价格 + }; + contextWindow?: number; + maxTokens?: number; // 最大输出 token 数 +} + +// OpenClaw 默认模型配置(agents.defaults.model) +export interface OpenClawDefaultModel { + primary: string; + fallbacks?: string[]; +} + +// OpenClaw 模型目录条目(agents.defaults.models 中的值) +export interface OpenClawModelCatalogEntry { + alias?: string; +} + +// OpenClaw 供应商配置(settings_config 结构) +// 对应 OpenClaw 的 models.providers. 配置 +export interface OpenClawProviderConfig { + baseUrl?: string; // API 端点 + apiKey?: string; // API 密钥 + api?: string; // API 协议类型(如 "openai-completions"、"anthropic") + models?: OpenClawModel[]; // 可用模型列表 +} + +// OpenClaw agents.defaults 完整配置 +export interface OpenClawAgentsDefaults { + model?: OpenClawDefaultModel; + models?: Record; + [key: string]: unknown; // preserve unknown fields +} + +// OpenClaw env 配置(openclaw.json 的 env 节点) +export interface OpenClawEnvConfig { + [key: string]: unknown; +} + +// OpenClaw tools 配置(openclaw.json 的 tools 节点) +export interface OpenClawToolsConfig { + profile?: string; + allow?: string[]; + deny?: string[]; + [key: string]: unknown; // preserve unknown fields +} diff --git a/src/types/proxy.ts b/src/types/proxy.ts index 705c2288c..7aa6f42de 100644 --- a/src/types/proxy.ts +++ b/src/types/proxy.ts @@ -46,6 +46,7 @@ export interface ProxyTakeoverStatus { codex: boolean; gemini: boolean; opencode: boolean; + openclaw: boolean; } export interface ProviderHealth { diff --git a/tests/components/McpFormModal.test.tsx b/tests/components/McpFormModal.test.tsx index c70951186..8816ceaf9 100644 --- a/tests/components/McpFormModal.test.tsx +++ b/tests/components/McpFormModal.test.tsx @@ -433,6 +433,7 @@ type = "stdio" codex: false, gemini: false, opencode: false, + openclaw: false, }); expect(onSave).toHaveBeenCalledTimes(1); expect(toastErrorMock).not.toHaveBeenCalled(); diff --git a/tests/components/SettingsDialog.test.tsx b/tests/components/SettingsDialog.test.tsx index 2e8015843..ec29f17fd 100644 --- a/tests/components/SettingsDialog.test.tsx +++ b/tests/components/SettingsDialog.test.tsx @@ -1,7 +1,8 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import "@testing-library/jest-dom"; -import { createContext, useContext } from "react"; +import { createContext, useContext, type ComponentProps } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { SettingsPage } from "@/components/settings/SettingsPage"; const toastSuccessMock = vi.fn(); @@ -156,6 +157,7 @@ vi.mock("@/components/ui/dialog", () => ({ DialogHeader: ({ children }: any) =>
{children}
, DialogFooter: ({ children }: any) =>
{children}
, DialogTitle: ({ children }: any) =>

{children}

, + DialogDescription: ({ children }: any) =>
{children}
, })); vi.mock("@/components/ui/tabs", () => { @@ -235,8 +237,29 @@ vi.mock("@/components/settings/AboutSection", () => ({ AboutSection: ({ isPortable }: any) =>
about:{String(isPortable)}
, })); +vi.mock("@/components/settings/WebdavSyncSection", () => ({ + WebdavSyncSection: ({ config }: any) => ( +
webdav-sync-section:{config?.baseUrl ?? "none"}
+ ), +})); + let settingsApi: any; +const renderSettingsPage = ( + props?: Partial>, +) => { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + return render( + + + , + ); +}; + describe("SettingsPage Component", () => { beforeEach(async () => { tMock.mockImplementation((key: string) => key); @@ -263,7 +286,7 @@ describe("SettingsPage Component", () => { it("should not render form content when loading", () => { settingsMock = createSettingsMock({ settings: null, isLoading: true }); - render(); + renderSettingsPage(); expect(screen.queryByText("language:zh")).not.toBeInTheDocument(); // 加载状态下显示 spinner 而不是表单内容 @@ -271,13 +294,24 @@ describe("SettingsPage Component", () => { }); it("should reset import/export status when dialog transitions to open", () => { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); const { rerender } = render( - , + + + , ); importExportMock.resetStatus.mockClear(); - rerender(); + rerender( + + + , + ); expect(importExportMock.resetStatus).toHaveBeenCalledTimes(1); }); @@ -289,7 +323,7 @@ describe("SettingsPage Component", () => { selectedFile: "/tmp/config.json", }); - render(); + renderSettingsPage({ onOpenChange }); expect(screen.getByText("language:zh")).toBeInTheDocument(); expect(screen.getByText("theme-settings")).toBeInTheDocument(); @@ -306,6 +340,7 @@ describe("SettingsPage Component", () => { fireEvent.click(screen.getByText("settings.tabAdvanced")); fireEvent.click(screen.getByText("settings.advanced.data.title")); + expect(screen.getByText("webdav-sync-section:none")).toBeInTheDocument(); // 有文件时,点击导入按钮执行 importConfig fireEvent.click( @@ -326,13 +361,7 @@ describe("SettingsPage Component", () => { it("should pass onImportSuccess callback to useImportExport hook", async () => { const onImportSuccess = vi.fn(); - render( - , - ); + renderSettingsPage({ onImportSuccess }); expect(useImportExportSpy).toHaveBeenCalledWith( expect.objectContaining({ onImportSuccess }), @@ -349,7 +378,7 @@ describe("SettingsPage Component", () => { const onOpenChange = vi.fn(); importExportMock = createImportExportMock(); - render(); + renderSettingsPage({ onOpenChange }); // 保存按钮在 advanced tab 中 fireEvent.click(screen.getByText("settings.tabAdvanced")); @@ -370,7 +399,7 @@ describe("SettingsPage Component", () => { saveSettings: vi.fn().mockResolvedValue({ requiresRestart: true }), }); - render(); + renderSettingsPage(); expect( await screen.findByText("settings.restartRequired"), @@ -390,7 +419,7 @@ describe("SettingsPage Component", () => { const onOpenChange = vi.fn(); settingsMock = createSettingsMock({ requiresRestart: true }); - render(); + renderSettingsPage({ onOpenChange }); expect( await screen.findByText("settings.restartRequired"), @@ -409,7 +438,7 @@ describe("SettingsPage Component", () => { }); it("should trigger directory management callbacks inside advanced tab", () => { - render(); + renderSettingsPage(); fireEvent.click(screen.getByText("settings.tabAdvanced")); fireEvent.click(screen.getByText("settings.advanced.configDir.title")); diff --git a/tests/components/WebdavSyncSection.test.tsx b/tests/components/WebdavSyncSection.test.tsx new file mode 100644 index 000000000..27a487a7b --- /dev/null +++ b/tests/components/WebdavSyncSection.test.tsx @@ -0,0 +1,370 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import "@testing-library/jest-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { WebdavSyncSection } from "@/components/settings/WebdavSyncSection"; +import type { WebDavSyncSettings } from "@/types"; + +const toastSuccessMock = vi.fn(); +const toastErrorMock = vi.fn(); +const toastWarningMock = vi.fn(); +const toastInfoMock = vi.fn(); + +vi.mock("sonner", () => ({ + toast: { + success: (...args: unknown[]) => toastSuccessMock(...args), + error: (...args: unknown[]) => toastErrorMock(...args), + warning: (...args: unknown[]) => toastWarningMock(...args), + info: (...args: unknown[]) => toastInfoMock(...args), + }, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ children, ...props }: any) => , +})); + +vi.mock("@/components/ui/input", () => ({ + Input: (props: any) => , +})); + +vi.mock("@/components/ui/select", () => ({ + Select: ({ value, onValueChange, children }: any) => ( + + ), + SelectTrigger: ({ children }: any) => <>{children}, + SelectValue: () => null, + SelectContent: ({ children }: any) => <>{children}, + SelectItem: ({ value, children }: any) => ( + + ), +})); + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ open, children }: any) => (open ?
{children}
: null), + DialogContent: ({ children }: any) =>
{children}
, + DialogDescription: ({ children }: any) =>
{children}
, + DialogFooter: ({ children }: any) =>
{children}
, + DialogHeader: ({ children }: any) =>
{children}
, + DialogTitle: ({ children }: any) =>

{children}

, +})); + +const { settingsApiMock } = vi.hoisted(() => ({ + settingsApiMock: { + webdavTestConnection: vi.fn(), + webdavSyncSaveSettings: vi.fn(), + webdavSyncFetchRemoteInfo: vi.fn(), + webdavSyncUpload: vi.fn(), + webdavSyncDownload: vi.fn(), + }, +})); + +vi.mock("@/lib/api", () => ({ + settingsApi: settingsApiMock, +})); + +const baseConfig: WebDavSyncSettings = { + enabled: true, + baseUrl: "https://dav.example.com/dav/", + username: "alice", + password: "secret", + remoteRoot: "cc-switch-sync", + profile: "default", + status: {}, +}; + +function renderSection(config?: WebDavSyncSettings) { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return render( + + + , + ); +} + +describe("WebdavSyncSection", () => { + beforeEach(() => { + toastSuccessMock.mockReset(); + toastErrorMock.mockReset(); + toastWarningMock.mockReset(); + toastInfoMock.mockReset(); + settingsApiMock.webdavTestConnection.mockReset(); + settingsApiMock.webdavSyncSaveSettings.mockReset(); + settingsApiMock.webdavSyncFetchRemoteInfo.mockReset(); + settingsApiMock.webdavSyncUpload.mockReset(); + settingsApiMock.webdavSyncDownload.mockReset(); + + settingsApiMock.webdavSyncSaveSettings.mockResolvedValue({ success: true }); + settingsApiMock.webdavTestConnection.mockResolvedValue({ + success: true, + message: "ok", + }); + settingsApiMock.webdavSyncFetchRemoteInfo.mockResolvedValue({ + deviceName: "My MacBook", + createdAt: "2026-02-01T10:00:00Z", + snapshotId: "snapshot-1", + version: 2, + compatible: true, + artifacts: ["db.sql", "skills.zip"], + }); + settingsApiMock.webdavSyncUpload.mockResolvedValue({ status: "uploaded" }); + settingsApiMock.webdavSyncDownload.mockResolvedValue({ status: "downloaded" }); + }); + + it("shows validation error when saving without base url", async () => { + renderSection({ ...baseConfig, baseUrl: "" }); + + fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" })); + + expect(toastErrorMock).toHaveBeenCalledWith("settings.webdavSync.missingUrl"); + expect(settingsApiMock.webdavSyncSaveSettings).not.toHaveBeenCalled(); + }); + + it("saves settings and auto tests connection", async () => { + renderSection(baseConfig); + + fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" })); + + await waitFor(() => { + expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1); + }); + expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: "https://dav.example.com/dav/", + username: "alice", + password: "secret", + }), + false, + ); + await waitFor(() => { + expect(settingsApiMock.webdavTestConnection).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: "https://dav.example.com/dav/", + }), + true, + ); + }); + expect(toastSuccessMock).toHaveBeenCalledWith( + "settings.webdavSync.saveAndTestSuccess", + ); + }); + + it("blocks upload when there are unsaved changes", async () => { + renderSection(baseConfig); + + fireEvent.change( + screen.getByPlaceholderText("settings.webdavSync.usernamePlaceholder"), + { target: { value: "bob" } }, + ); + fireEvent.click( + screen.getByRole("button", { name: "settings.webdavSync.upload" }), + ); + + expect(toastErrorMock).toHaveBeenCalledWith( + "settings.webdavSync.unsavedChanges", + ); + expect(settingsApiMock.webdavSyncFetchRemoteInfo).not.toHaveBeenCalled(); + }); + + it("disables sync buttons until config is saved", () => { + renderSection(undefined); + + const uploadButton = screen.getByRole("button", { + name: "settings.webdavSync.upload", + }); + const downloadButton = screen.getByRole("button", { + name: "settings.webdavSync.download", + }); + + expect(uploadButton).toBeDisabled(); + expect(downloadButton).toBeDisabled(); + expect(settingsApiMock.webdavSyncFetchRemoteInfo).not.toHaveBeenCalled(); + }); + + it("fetches remote info and uploads after confirmation", async () => { + renderSection(baseConfig); + + fireEvent.click( + screen.getByRole("button", { name: "settings.webdavSync.upload" }), + ); + + await waitFor(() => { + expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1); + }); + + fireEvent.click( + screen.getByRole("button", { + name: "settings.webdavSync.confirmUpload.confirm", + }), + ); + + await waitFor(() => { + expect(settingsApiMock.webdavSyncUpload).toHaveBeenCalledTimes(1); + }); + expect(toastSuccessMock).toHaveBeenCalledWith( + "settings.webdavSync.uploadSuccess", + ); + }); + + it("blocks upload confirmation if form changes after dialog opens", async () => { + renderSection(baseConfig); + + fireEvent.click( + screen.getByRole("button", { name: "settings.webdavSync.upload" }), + ); + await waitFor(() => { + expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1); + }); + + fireEvent.change(screen.getByPlaceholderText("cc-switch-sync"), { + target: { value: "new-root" }, + }); + fireEvent.click( + screen.getByRole("button", { + name: "settings.webdavSync.confirmUpload.confirm", + }), + ); + + await waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith( + "settings.webdavSync.unsavedChanges", + ); + }); + expect(settingsApiMock.webdavSyncUpload).not.toHaveBeenCalled(); + }); + + it("fetches remote info and downloads after confirmation", async () => { + renderSection(baseConfig); + + fireEvent.click( + screen.getByRole("button", { name: "settings.webdavSync.download" }), + ); + + await waitFor(() => { + expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1); + }); + + fireEvent.click( + screen.getByRole("button", { + name: "settings.webdavSync.confirmDownload.confirm", + }), + ); + + await waitFor(() => { + expect(settingsApiMock.webdavSyncDownload).toHaveBeenCalledTimes(1); + }); + expect(toastSuccessMock).toHaveBeenCalledWith( + "settings.webdavSync.downloadSuccess", + ); + }); + + it("blocks download confirmation if form changes after dialog opens", async () => { + renderSection(baseConfig); + + fireEvent.click( + screen.getByRole("button", { name: "settings.webdavSync.download" }), + ); + await waitFor(() => { + expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1); + }); + + fireEvent.change(screen.getByPlaceholderText("default"), { + target: { value: "new-profile" }, + }); + fireEvent.click( + screen.getByRole("button", { + name: "settings.webdavSync.confirmDownload.confirm", + }), + ); + + await waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith( + "settings.webdavSync.unsavedChanges", + ); + }); + expect(settingsApiMock.webdavSyncDownload).not.toHaveBeenCalled(); + }); + + it("shows info when no remote snapshot is found for download", async () => { + settingsApiMock.webdavSyncFetchRemoteInfo.mockResolvedValueOnce({ empty: true }); + renderSection(baseConfig); + + fireEvent.click( + screen.getByRole("button", { name: "settings.webdavSync.download" }), + ); + + await waitFor(() => { + expect(toastInfoMock).toHaveBeenCalledWith("settings.webdavSync.noRemoteData"); + }); + expect(settingsApiMock.webdavSyncDownload).not.toHaveBeenCalled(); + }); + + it("blocks download when remote snapshot is incompatible", async () => { + settingsApiMock.webdavSyncFetchRemoteInfo.mockResolvedValueOnce({ + deviceName: "Legacy Machine", + createdAt: "2025-01-01T00:00:00Z", + snapshotId: "legacy-snapshot", + version: 1, + compatible: false, + artifacts: ["db.sql"], + }); + renderSection(baseConfig); + + fireEvent.click( + screen.getByRole("button", { name: "settings.webdavSync.download" }), + ); + + await waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith( + "settings.webdavSync.incompatibleVersion", + ); + }); + expect(settingsApiMock.webdavSyncDownload).not.toHaveBeenCalled(); + expect( + screen.queryByRole("button", { + name: "settings.webdavSync.confirmDownload.confirm", + }), + ).not.toBeInTheDocument(); + }); + + it("shows error when download fails after confirmation", async () => { + settingsApiMock.webdavSyncDownload.mockRejectedValueOnce(new Error("boom")); + renderSection(baseConfig); + + fireEvent.click( + screen.getByRole("button", { name: "settings.webdavSync.download" }), + ); + await waitFor(() => { + expect(settingsApiMock.webdavSyncFetchRemoteInfo).toHaveBeenCalledTimes(1); + }); + + fireEvent.click( + screen.getByRole("button", { + name: "settings.webdavSync.confirmDownload.confirm", + }), + ); + + await waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith( + "settings.webdavSync.downloadFailed", + ); + }); + }); +}); diff --git a/tests/integration/SettingsDialog.test.tsx b/tests/integration/SettingsDialog.test.tsx index 0772e4a8c..be1a689f8 100644 --- a/tests/integration/SettingsDialog.test.tsx +++ b/tests/integration/SettingsDialog.test.tsx @@ -28,6 +28,7 @@ vi.mock("@/components/ui/dialog", () => ({ DialogHeader: ({ children }: any) =>
{children}
, DialogFooter: ({ children }: any) =>
{children}
, DialogTitle: ({ children }: any) =>

{children}

, + DialogDescription: ({ children }: any) =>
{children}
, })); const TabsContext = React.createContext<{ diff --git a/tests/msw/state.ts b/tests/msw/state.ts index ac385a675..2db234840 100644 --- a/tests/msw/state.ts +++ b/tests/msw/state.ts @@ -58,6 +58,7 @@ const createDefaultProviders = (): ProvidersByApp => ({ }, }, opencode: {}, + openclaw: {}, }); const createDefaultCurrent = (): CurrentProviderState => ({ @@ -65,6 +66,7 @@ const createDefaultCurrent = (): CurrentProviderState => ({ codex: "codex-1", gemini: "gemini-1", opencode: "", + openclaw: "", }); let providers = createDefaultProviders(); @@ -84,7 +86,7 @@ let mcpConfigs: McpConfigState = { id: "sample", name: "Sample Claude Server", enabled: true, - apps: { claude: true, codex: false, gemini: false, opencode: false }, + apps: { claude: true, codex: false, gemini: false, opencode: false, openclaw: false }, server: { type: "stdio", command: "claude-server", @@ -96,7 +98,7 @@ let mcpConfigs: McpConfigState = { id: "httpServer", name: "HTTP Codex Server", enabled: false, - apps: { claude: false, codex: true, gemini: false, opencode: false }, + apps: { claude: false, codex: true, gemini: false, opencode: false, openclaw: false }, server: { type: "http", url: "http://localhost:3000", @@ -105,6 +107,7 @@ let mcpConfigs: McpConfigState = { }, gemini: {}, opencode: {}, + openclaw: {}, }; const cloneProviders = (value: ProvidersByApp) => @@ -128,7 +131,7 @@ export const resetProviderState = () => { id: "sample", name: "Sample Claude Server", enabled: true, - apps: { claude: true, codex: false, gemini: false, opencode: false }, + apps: { claude: true, codex: false, gemini: false, opencode: false, openclaw: false }, server: { type: "stdio", command: "claude-server", @@ -140,7 +143,7 @@ export const resetProviderState = () => { id: "httpServer", name: "HTTP Codex Server", enabled: false, - apps: { claude: false, codex: true, gemini: false, opencode: false }, + apps: { claude: false, codex: true, gemini: false, opencode: false, openclaw: false }, server: { type: "http", url: "http://localhost:3000", @@ -149,6 +152,7 @@ export const resetProviderState = () => { }, gemini: {}, opencode: {}, + openclaw: {}, }; };