From 6098fa7536281b417e8e8fec85643996f1797507 Mon Sep 17 00:00:00 2001 From: clx <75774737+clx20000410@users.noreply.github.com> Date: Sat, 14 Feb 2026 15:24:24 +0800 Subject: [PATCH 01/44] Webdav (#923) * feat: WebDAV backup/restore - Add WebDAV test/backup/restore commands and settings\n- Fix ja i18n missing keys; decode PROPFIND href as UTF-8\n- Stabilize Windows prompt auto-import tests via CC_SWITCH_TEST_HOME * chore: format and minor cleanups * fix: update build config * feat(webdav): unify sync UX and hardening fixes * fix(webdav): harden sync flow and stabilize sync UX/tests * fix(webdav): add resource limits to skills.zip extraction Prevent zip bomb / resource exhaustion by enforcing: - MAX_EXTRACT_ENTRIES (10,000 files) - MAX_EXTRACT_BYTES (512 MB cumulative) * refactor(webdav): drop deviceId and display deviceName only --------- Co-authored-by: small-lovely-cat <77799160+small-lovely-cat@users.noreply.github.com> Co-authored-by: saladday <1203511142@qq.com> --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/commands/import_export.rs | 29 +- src-tauri/src/commands/mod.rs | 3 + src-tauri/src/commands/settings.rs | 68 +- src-tauri/src/commands/sync_support.rs | 97 +++ src-tauri/src/commands/webdav_sync.rs | 357 ++++++++ src-tauri/src/database/backup.rs | 15 +- src-tauri/src/lib.rs | 5 + src-tauri/src/services/mod.rs | 2 + src-tauri/src/services/webdav.rs | 507 ++++++++++++ src-tauri/src/services/webdav_sync.rs | 649 +++++++++++++++ src-tauri/src/services/webdav_sync/archive.rs | 346 ++++++++ src-tauri/src/settings.rs | 183 ++++- src/components/settings/SettingsPage.tsx | 6 + src/components/settings/WebdavSyncSection.tsx | 775 ++++++++++++++++++ src/hooks/useSettings.ts | 6 +- src/i18n/locales/en.json | 71 ++ src/i18n/locales/ja.json | 71 ++ src/i18n/locales/zh.json | 71 ++ src/lib/api/settings.ts | 47 +- src/lib/schemas/settings.ts | 21 + src/types.ts | 33 + tests/components/SettingsDialog.test.tsx | 61 +- tests/components/WebdavSyncSection.test.tsx | 370 +++++++++ tests/integration/SettingsDialog.test.tsx | 1 + 26 files changed, 3755 insertions(+), 41 deletions(-) create mode 100644 src-tauri/src/commands/sync_support.rs create mode 100644 src-tauri/src/commands/webdav_sync.rs create mode 100644 src-tauri/src/services/webdav.rs create mode 100644 src-tauri/src/services/webdav_sync.rs create mode 100644 src-tauri/src/services/webdav_sync/archive.rs create mode 100644 src/components/settings/WebdavSyncSection.tsx create mode 100644 tests/components/WebdavSyncSection.test.tsx diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 49e1759f0..0950d9063 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -727,6 +727,7 @@ dependencies = [ "serde_json", "serde_yaml", "serial_test", + "sha2", "tauri", "tauri-build", "tauri-plugin-deep-link", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7346899a6..12e288837 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -61,6 +61,7 @@ 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" [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] tauri-plugin-single-instance = "2" 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..f7a1b81c0 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -17,7 +17,9 @@ mod session_manager; mod settings; pub mod skill; mod stream_check; +mod sync_support; mod usage; +mod webdav_sync; pub use config::*; pub use deeplink::*; @@ -37,3 +39,4 @@ pub use settings::*; pub use skill::*; pub use stream_check::*; pub use usage::*; +pub use webdav_sync::*; 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/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/lib.rs b/src-tauri/src/lib.rs index e2657745a..ef47fdaee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -886,6 +886,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, 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/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..e1ae3b213 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}; @@ -58,6 +59,101 @@ impl VisibleApps { } } +/// 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`,不随数据库同步。 @@ -118,6 +214,14 @@ pub struct AppSettings { #[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" @@ -155,6 +259,8 @@ impl Default for AppSettings { current_provider_gemini: None, current_provider_opencode: None, skill_sync_method: SyncMethod::default(), + webdav_sync: None, + webdav_backup: None, preferred_terminal: None, } } @@ -205,6 +311,13 @@ impl AppSettings { .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 +358,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 +416,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 +437,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> { @@ -433,3 +591,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/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 6f339610f..41e212151 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -39,6 +39,7 @@ import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSe import { TerminalSettings } from "@/components/settings/TerminalSettings"; import { DirectorySettings } from "@/components/settings/DirectorySettings"; import { ImportExportSection } from "@/components/settings/ImportExportSection"; +import { WebdavSyncSection } from "@/components/settings/WebdavSyncSection"; import { AboutSection } from "@/components/settings/AboutSection"; import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings"; import { ProxyPanel } from "@/components/proxy"; @@ -595,6 +596,11 @@ export function SettingsPage({ onExport={exportConfig} onClear={clearSelection} /> +
+ +
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/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 7c65a6857..695a776fb 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -266,6 +266,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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index f8c9243fe..825e11a6d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -266,6 +266,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", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index a5f54bd4e..c044c40e7 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -266,6 +266,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", 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/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..5d219c4f5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -162,6 +162,36 @@ export interface VisibleApps { opencode: 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) // 存储在本地 ~/.cc-switch/settings.json,不随数据库同步 export interface Settings { @@ -206,6 +236,9 @@ export interface Settings { // Skill 同步方式:auto(默认,优先 symlink)、symlink、copy skillSyncMethod?: SkillSyncMethod; + // ===== WebDAV v2 同步设置 ===== + webdavSync?: WebDavSyncSettings; + // ===== 终端设置 ===== // 首选终端应用(可选,默认使用系统默认终端) // macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" 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<{ From 9c34d04def9cd4eb04afa7209f90c588f1f200c6 Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Sat, 14 Feb 2026 15:29:54 +0800 Subject: [PATCH 02/44] fix(mcp): add missing OpenCode checkbox in MCP add/edit form (#1026) Fixes https://github.com/farion1231/cc-switch/issues/1020 --- src/components/mcp/McpFormModal.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx index 5904e49d7..93d12745d 100644 --- a/src/components/mcp/McpFormModal.tsx +++ b/src/components/mcp/McpFormModal.tsx @@ -561,6 +561,22 @@ const McpFormModal: React.FC = ({ {t("mcp.unifiedPanel.apps.gemini")} + +
+ + setEnabledApps({ ...enabledApps, opencode: checked }) + } + /> + +
From 7f46a0b9104cfd356475e737887afbc469d7174a Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 1 Feb 2026 20:30:32 +0800 Subject: [PATCH 03/44] feat(core): add OpenClaw to AppType enum and database schema - Add OpenClaw variant to AppType enum - Update is_additive_mode() to return true for OpenClaw - Update McpApps, SkillApps, McpRoot, PromptRoot structs - Add database migration v5 to v6 for enabled_openclaw columns - Update mcp_servers and skills table definitions --- src-tauri/src/app_config.rs | 51 ++++++++++++++++++++++++---- src-tauri/src/database/dao/mcp.rs | 9 +++-- src-tauri/src/database/dao/skills.rs | 19 ++++++----- src-tauri/src/database/mod.rs | 2 +- src-tauri/src/database/schema.rs | 34 ++++++++++++++++++- 5 files changed, 95 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 530f6a798..d53e7f38e 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -15,6 +15,8 @@ pub struct McpApps { pub gemini: bool, #[serde(default)] pub opencode: bool, + #[serde(default)] + pub openclaw: bool, } impl McpApps { @@ -25,6 +27,7 @@ impl McpApps { AppType::Codex => self.codex, AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, + AppType::OpenClaw => self.openclaw, } } @@ -35,6 +38,7 @@ impl McpApps { AppType::Codex => self.codex = enabled, AppType::Gemini => self.gemini = enabled, AppType::OpenCode => self.opencode = enabled, + AppType::OpenClaw => self.openclaw = enabled, } } @@ -53,12 +57,15 @@ impl McpApps { if self.opencode { apps.push(AppType::OpenCode); } + if self.openclaw { + apps.push(AppType::OpenClaw); + } apps } /// 检查是否所有应用都未启用 pub fn is_empty(&self) -> bool { - !self.claude && !self.codex && !self.gemini && !self.opencode + !self.claude && !self.codex && !self.gemini && !self.opencode && !self.openclaw } } @@ -73,6 +80,8 @@ pub struct SkillApps { pub gemini: bool, #[serde(default)] pub opencode: bool, + #[serde(default)] + pub openclaw: bool, } impl SkillApps { @@ -83,6 +92,7 @@ impl SkillApps { AppType::Codex => self.codex, AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, + AppType::OpenClaw => self.openclaw, } } @@ -93,6 +103,7 @@ impl SkillApps { AppType::Codex => self.codex = enabled, AppType::Gemini => self.gemini = enabled, AppType::OpenCode => self.opencode = enabled, + AppType::OpenClaw => self.openclaw = enabled, } } @@ -111,12 +122,15 @@ impl SkillApps { if self.opencode { apps.push(AppType::OpenCode); } + if self.openclaw { + apps.push(AppType::OpenClaw); + } apps } /// 检查是否所有应用都未启用 pub fn is_empty(&self) -> bool { - !self.claude && !self.codex && !self.gemini && !self.opencode + !self.claude && !self.codex && !self.gemini && !self.opencode && !self.openclaw } /// 仅启用指定应用(其他应用设为禁用) @@ -222,6 +236,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 { @@ -234,6 +251,7 @@ impl Default for McpRoot { codex: McpConfig::default(), gemini: McpConfig::default(), opencode: McpConfig::default(), + openclaw: McpConfig::default(), } } } @@ -256,6 +274,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}; @@ -271,6 +291,7 @@ pub enum AppType { Codex, Gemini, OpenCode, + OpenClaw, } impl AppType { @@ -280,15 +301,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 @@ -298,6 +320,7 @@ impl AppType { AppType::Codex, AppType::Gemini, AppType::OpenCode, + AppType::OpenClaw, ] .into_iter() } @@ -313,10 +336,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."), )), } } @@ -336,6 +360,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 { @@ -346,6 +373,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(), } } @@ -356,6 +384,7 @@ impl CommonConfigSnippets { AppType::Codex => self.codex = snippet, AppType::Gemini => self.gemini = snippet, AppType::OpenCode => self.opencode = snippet, + AppType::OpenClaw => self.openclaw = snippet, } } } @@ -396,6 +425,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, @@ -555,6 +585,7 @@ impl MultiAppConfig { AppType::Codex => &self.mcp.codex, AppType::Gemini => &self.mcp.gemini, AppType::OpenCode => &self.mcp.opencode, + AppType::OpenClaw => &self.mcp.openclaw, } } @@ -565,6 +596,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, } } @@ -579,6 +611,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) } @@ -599,6 +632,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); } @@ -611,6 +645,7 @@ impl MultiAppConfig { AppType::Codex, AppType::Gemini, AppType::OpenCode, + AppType::OpenClaw, ] { // 复用已有的单应用导入逻辑 if Self::auto_import_prompt_if_exists(self, app)? { @@ -681,6 +716,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); @@ -709,12 +745,13 @@ 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/database/dao/mcp.rs b/src-tauri/src/database/dao/mcp.rs index d5c60163f..52d73ef87 100644 --- a/src-tauri/src/database/dao/mcp.rs +++ b/src-tauri/src/database/dao/mcp.rs @@ -13,7 +13,7 @@ impl Database { pub fn get_all_mcp_servers(&self) -> Result, AppError> { let conn = lock_conn!(self.conn); let mut stmt = conn.prepare( - "SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode + "SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_openclaw FROM mcp_servers ORDER BY name ASC, id ASC" ).map_err(|e| AppError::Database(e.to_string()))?; @@ -31,6 +31,7 @@ impl Database { let enabled_codex: bool = row.get(8)?; let enabled_gemini: bool = row.get(9)?; let enabled_opencode: bool = row.get(10)?; + let enabled_openclaw: bool = row.get(11)?; let server = serde_json::from_str(&server_config_str).unwrap_or_default(); let tags = serde_json::from_str(&tags_str).unwrap_or_default(); @@ -46,6 +47,7 @@ impl Database { codex: enabled_codex, gemini: enabled_gemini, opencode: enabled_opencode, + openclaw: enabled_openclaw, }, description, homepage, @@ -70,8 +72,8 @@ impl Database { conn.execute( "INSERT OR REPLACE INTO mcp_servers ( id, name, server_config, description, homepage, docs, tags, - enabled_claude, enabled_codex, enabled_gemini, enabled_opencode - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_openclaw + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", params![ server.id, server.name, @@ -87,6 +89,7 @@ impl Database { server.apps.codex, server.apps.gemini, server.apps.opencode, + server.apps.openclaw, ], ) .map_err(|e| AppError::Database(e.to_string()))?; diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index 2254cb061..f29e89a63 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -22,7 +22,7 @@ impl Database { let mut stmt = conn .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_openclaw, installed_at FROM skills ORDER BY name ASC", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -43,8 +43,9 @@ impl Database { codex: row.get(9)?, gemini: row.get(10)?, opencode: row.get(11)?, + openclaw: row.get(12)?, }, - installed_at: row.get(12)?, + installed_at: row.get(13)?, }) }) .map_err(|e| AppError::Database(e.to_string()))?; @@ -63,7 +64,7 @@ impl Database { let mut stmt = conn .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_openclaw, installed_at FROM skills WHERE id = ?1", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -83,8 +84,9 @@ impl Database { codex: row.get(9)?, gemini: row.get(10)?, opencode: row.get(11)?, + openclaw: row.get(12)?, }, - installed_at: row.get(12)?, + installed_at: row.get(13)?, }) }); @@ -101,8 +103,8 @@ impl Database { conn.execute( "INSERT OR REPLACE INTO skills (id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_openclaw, installed_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", params![ skill.id, skill.name, @@ -116,6 +118,7 @@ impl Database { skill.apps.codex, skill.apps.gemini, skill.apps.opencode, + skill.apps.openclaw, skill.installed_at, ], ) @@ -145,8 +148,8 @@ impl Database { let conn = lock_conn!(self.conn); let affected = conn .execute( - "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4 WHERE id = ?5", - params![apps.claude, apps.codex, apps.gemini, apps.opencode, id], + "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4, enabled_openclaw = ?5 WHERE id = ?6", + params![apps.claude, apps.codex, apps.gemini, apps.opencode, apps.openclaw, id], ) .map_err(|e| AppError::Database(e.to_string()))?; Ok(affected > 0) diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 41c26e978..352593fd6 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -48,7 +48,7 @@ const DB_BACKUP_RETAIN: usize = 10; /// 当前 Schema 版本号 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 -pub(crate) const SCHEMA_VERSION: i32 = 5; +pub(crate) const SCHEMA_VERSION: i32 = 6; /// 安全地序列化 JSON,避免 unwrap panic pub(crate) fn to_json_string(value: &T) -> Result { diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index bca3c2de3..924939125 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -58,7 +58,8 @@ impl Database { id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL, description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]', enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0, - enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0 + enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0, + enabled_openclaw BOOLEAN NOT NULL DEFAULT 0 )", [], ) @@ -86,6 +87,7 @@ impl Database { enabled_codex BOOLEAN NOT NULL DEFAULT 0, enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0, + enabled_openclaw BOOLEAN NOT NULL DEFAULT 0, installed_at INTEGER NOT NULL DEFAULT 0 )", [], @@ -360,6 +362,11 @@ impl Database { Self::migrate_v4_to_v5(conn)?; Self::set_user_version(conn, 5)?; } + 5 => { + log::info!("迁移数据库从 v5 到 v6(OpenClaw 支持)"); + Self::migrate_v5_to_v6(conn)?; + Self::set_user_version(conn, 6)?; + } _ => { return Err(AppError::Database(format!( "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" @@ -852,6 +859,7 @@ impl Database { enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0, enabled_gemini BOOLEAN NOT NULL DEFAULT 0, + enabled_openclaw BOOLEAN NOT NULL DEFAULT 0, installed_at INTEGER NOT NULL DEFAULT 0 )", [], @@ -914,6 +922,30 @@ impl Database { Ok(()) } + /// v5 -> v6 迁移:添加 OpenClaw 支持 + /// + /// 为 mcp_servers 和 skills 表添加 enabled_openclaw 列。 + fn migrate_v5_to_v6(conn: &Connection) -> Result<(), AppError> { + // 为 mcp_servers 表添加 enabled_openclaw 列 + Self::add_column_if_missing( + conn, + "mcp_servers", + "enabled_openclaw", + "BOOLEAN NOT NULL DEFAULT 0", + )?; + + // 为 skills 表添加 enabled_openclaw 列 + Self::add_column_if_missing( + conn, + "skills", + "enabled_openclaw", + "BOOLEAN NOT NULL DEFAULT 0", + )?; + + log::info!("v5 -> v6 迁移完成:已添加 OpenClaw 支持"); + Ok(()) + } + /// 插入默认模型定价数据 /// 格式: (model_id, display_name, input, output, cache_read, cache_creation) /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 From 63fafd6608478a7672a4afaf3ac2eda82621d3f2 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 1 Feb 2026 20:30:47 +0800 Subject: [PATCH 04/44] feat(openclaw): add OpenClaw configuration module - Create openclaw_config.rs for reading/writing OpenClaw config - Support JSON5 format (~/.openclaw/openclaw.json) - Implement provider CRUD operations for additive mode - Add json5 dependency to Cargo.toml --- src-tauri/Cargo.lock | 61 ++++++ src-tauri/Cargo.toml | 1 + src-tauri/src/openclaw_config.rs | 326 +++++++++++++++++++++++++++++++ 3 files changed, 388 insertions(+) create mode 100644 src-tauri/src/openclaw_config.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0950d9063..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", @@ -2521,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" @@ -3405,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" @@ -5888,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 12e288837..9547eb342 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -62,6 +62,7 @@ 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/openclaw_config.rs b/src-tauri/src/openclaw_config.rs new file mode 100644 index 000000000..5a30d2565 --- /dev/null +++ b/src-tauri/src/openclaw_config.rs @@ -0,0 +1,326 @@ +//! 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::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") +} + +/// 获取 OpenClaw Skills 目录路径 +/// +/// 返回 `~/.openclaw/skills/` +#[allow(dead_code)] +pub fn get_openclaw_skills_dir() -> PathBuf { + get_openclaw_dir().join("skills") +} + +// ============================================================================ +// 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, + + /// 其他自定义字段(保留原始配置) + #[serde(flatten)] + pub extra: Map, +} + +/// OpenClaw 模型条目 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenClawModelEntry { + /// 模型 ID + pub id: String, + + /// 模型显示名称 + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// 其他自定义字段 + #[serde(flatten)] + pub extra: Map, +} + +// ============================================================================ +// 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) +} + +// ============================================================================ +// Environment Variables +// ============================================================================ + +/// 获取环境变量配置 +#[allow(dead_code)] +pub fn get_env_vars() -> Result, AppError> { + let config = read_openclaw_config()?; + Ok(config + .get("env") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default()) +} + +/// 设置环境变量 +#[allow(dead_code)] +pub fn set_env_var(key: &str, value: &str) -> Result<(), AppError> { + let mut config = read_openclaw_config()?; + + if config.get("env").is_none() { + config["env"] = json!({}); + } + + if let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) { + env.insert(key.to_string(), Value::String(value.to_string())); + } + + write_openclaw_config(&config) +} + +// ============================================================================ +// MCP Functions (Reserved for future use) +// ============================================================================ + +/// 获取所有 MCP 服务器配置 +/// +/// OpenClaw MCP 配置在 `agents.list[].mcp.servers` +/// 由于 OpenClaw MCP 支持仍在开发中(Issue #4834),暂时返回空 +#[allow(dead_code)] +pub fn get_mcp_servers() -> Result, AppError> { + // OpenClaw MCP support is under development + // Return empty for now + Ok(Map::new()) +} + +/// 设置 MCP 服务器配置 +#[allow(dead_code)] +pub fn set_mcp_server(_id: &str, _config: Value) -> Result<(), AppError> { + // OpenClaw MCP support is under development + log::warn!("OpenClaw MCP support is not yet implemented"); + Ok(()) +} + +/// 删除 MCP 服务器配置 +#[allow(dead_code)] +pub fn remove_mcp_server(_id: &str) -> Result<(), AppError> { + // OpenClaw MCP support is under development + log::warn!("OpenClaw MCP support is not yet implemented"); + Ok(()) +} From 433c86b2d3c5add1eab48a3083e7e9651eb9f096 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 1 Feb 2026 20:31:11 +0800 Subject: [PATCH 05/44] feat(provider): add OpenClaw provider service support - Add import_openclaw_providers_from_live() function - Add remove_openclaw_provider_from_live() function - Update write_live_snapshot() for OpenClaw - Add openclaw fields to VisibleApps and AppSettings - Add get_openclaw_override_dir() function --- src-tauri/src/services/provider/live.rs | 139 ++++++++++++++++++ src-tauri/src/services/provider/mod.rs | 180 +++++++++++++++++------- src-tauri/src/settings.rs | 28 ++++ 3 files changed, 294 insertions(+), 53 deletions(-) diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 52437e6dd..b9997bb33 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -191,6 +191,45 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re } } } + AppType::OpenClaw => { + // OpenClaw uses additive mode - write provider to config + use crate::openclaw_config; + use crate::openclaw_config::OpenClawProviderConfig; + + // Convert settings_config to OpenClawProviderConfig + let openclaw_config_result = + serde_json::from_value::(provider.settings_config.clone()); + + match openclaw_config_result { + Ok(config) => { + openclaw_config::set_typed_provider(&provider.id, &config)?; + log::info!("OpenClaw provider '{}' written to live config", provider.id); + } + Err(e) => { + log::warn!( + "Failed to parse OpenClaw provider config for '{}': {}", + provider.id, + e + ); + // Try to write as raw JSON if it looks valid + if provider.settings_config.get("baseUrl").is_some() + || provider.settings_config.get("api").is_some() + || provider.settings_config.get("models").is_some() + { + openclaw_config::set_provider(&provider.id, provider.settings_config.clone())?; + log::info!( + "OpenClaw provider '{}' written as raw JSON to live config", + provider.id + ); + } else { + log::error!( + "OpenClaw provider '{}' has invalid config structure, skipping write", + provider.id + ); + } + } + } + } } Ok(()) } @@ -340,6 +379,21 @@ pub fn read_live_settings(app_type: AppType) -> Result { let config = read_opencode_config()?; Ok(config) } + AppType::OpenClaw => { + use crate::openclaw_config::{get_openclaw_config_path, read_openclaw_config}; + + let config_path = get_openclaw_config_path(); + if !config_path.exists() { + return Err(AppError::localized( + "openclaw.config.missing", + "OpenClaw 配置文件不存在", + "OpenClaw configuration file not found", + )); + } + + let config = read_openclaw_config()?; + Ok(config) + } } } @@ -433,6 +487,23 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result { + // OpenClaw uses additive mode - import from live is not the same pattern + use crate::openclaw_config::{get_openclaw_config_path, read_openclaw_config}; + + let config_path = get_openclaw_config_path(); + if !config_path.exists() { + return Err(AppError::localized( + "openclaw.live.missing", + "OpenClaw 配置文件不存在", + "OpenClaw configuration file is missing", + )); + } + + // For OpenClaw, we return the full config - but note that OpenClaw + // uses additive mode, so importing defaults works differently + read_openclaw_config()? + } }; let mut provider = Provider::with_id( @@ -609,3 +680,71 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result Result { + use crate::openclaw_config; + + let providers = openclaw_config::get_typed_providers()?; + if providers.is_empty() { + return Ok(0); + } + + let mut imported = 0; + let existing = state.db.get_all_providers("openclaw")?; + + for (id, config) in providers { + // Skip if already exists in database + if existing.contains_key(&id) { + log::debug!("OpenClaw provider '{id}' already exists in database, skipping"); + continue; + } + + // Convert to Value for settings_config + let settings_config = match serde_json::to_value(&config) { + Ok(v) => v, + Err(e) => { + log::warn!("Failed to serialize OpenClaw provider '{id}': {e}"); + continue; + } + }; + + // Determine display name: use first model name if available, otherwise use id + let display_name = config + .models + .first() + .and_then(|m| m.name.clone()) + .unwrap_or_else(|| id.clone()); + + // Create provider + let provider = Provider::with_id(id.clone(), display_name, settings_config, None); + + // Save to database + if let Err(e) = state.db.save_provider("openclaw", &provider) { + log::warn!("Failed to import OpenClaw provider '{id}': {e}"); + continue; + } + + imported += 1; + log::info!("Imported OpenClaw provider '{id}' from live config"); + } + + Ok(imported) +} + +/// Remove an OpenClaw provider from live config +/// +/// This removes a specific provider from ~/.openclaw/openclaw.json +/// without affecting other providers in the file. +pub fn remove_openclaw_provider_from_live(provider_id: &str) -> Result<(), AppError> { + use crate::openclaw_config; + + openclaw_config::remove_provider(provider_id)?; + log::info!("OpenClaw provider '{provider_id}' removed from live config"); + + Ok(()) +} diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index c5f6abef7..aa163ce3b 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -21,8 +21,8 @@ use crate::store::AppState; // Re-export sub-module functions for external access pub use live::{ - import_default_config, import_opencode_providers_from_live, read_live_settings, - sync_current_to_live, + import_default_config, import_openclaw_providers_from_live, import_opencode_providers_from_live, + read_live_settings, sync_current_to_live, }; // Internal re-exports (pub(crate)) @@ -30,7 +30,9 @@ pub(crate) use live::sanitize_claude_settings_for_live; pub(crate) use live::write_live_snapshot; // Internal re-exports -use live::{remove_opencode_provider_from_live, write_gemini_live}; +use live::{ + remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live, +}; use usage::validate_usage_script; /// Provider business logic service @@ -142,10 +144,10 @@ impl ProviderService { /// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。 /// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。 /// - /// 对于 OpenCode(累加模式),不存在"当前供应商"概念,直接返回空字符串。 + /// 对于累加模式应用(OpenCode, OpenClaw),不存在"当前供应商"概念,直接返回空字符串。 pub fn current(state: &AppState, app_type: AppType) -> Result { - // OpenCode uses additive mode - no "current" provider concept - if matches!(app_type, AppType::OpenCode) { + // Additive mode apps have no "current" provider concept + if app_type.is_additive_mode() { return Ok(String::new()); } crate::settings::get_effective_current_provider(&state.db, &app_type) @@ -162,10 +164,12 @@ impl ProviderService { // Save to database state.db.save_provider(app_type.as_str(), &provider)?; - // OpenCode uses additive mode - always write to live config - if matches!(app_type, AppType::OpenCode) { + // Additive mode apps (OpenCode, OpenClaw) - always write to live config + if app_type.is_additive_mode() { // OMO providers use exclusive mode and write to dedicated config file. - if provider.category.as_deref() == Some("omo") { + if matches!(app_type, AppType::OpenCode) + && provider.category.as_deref() == Some("omo") + { // Do not auto-enable newly added OMO providers. // Users must explicitly switch/apply an OMO provider to activate it. return Ok(true); @@ -201,9 +205,11 @@ impl ProviderService { // Save to database state.db.save_provider(app_type.as_str(), &provider)?; - // OpenCode uses additive mode - always update in live config - if matches!(app_type, AppType::OpenCode) { - if provider.category.as_deref() == Some("omo") { + // Additive mode apps (OpenCode, OpenClaw) - always update in live config + if app_type.is_additive_mode() { + if matches!(app_type, AppType::OpenCode) + && provider.category.as_deref() == Some("omo") + { let is_omo_current = state .db .is_omo_provider_current(app_type.as_str(), &provider.id)?; @@ -253,43 +259,48 @@ impl ProviderService { /// Delete a provider /// /// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。 - /// 对于 OpenCode(累加模式),可以随时删除任意供应商,同时从 live 配置中移除。 + /// 对于累加模式应用(OpenCode, OpenClaw),可以随时删除任意供应商,同时从 live 配置中移除。 pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { - // OpenCode uses additive mode - no current provider concept - if matches!(app_type, AppType::OpenCode) { - let is_omo = state - .db - .get_provider_by_id(id, app_type.as_str())? - .and_then(|p| p.category) - .as_deref() - == Some("omo"); - - if is_omo { - let was_current = state.db.is_omo_provider_current(app_type.as_str(), id)?; - let omo_count = state + // Additive mode apps - no current provider concept + if app_type.is_additive_mode() { + if matches!(app_type, AppType::OpenCode) { + let is_omo = state .db - .get_all_providers(app_type.as_str())? - .values() - .filter(|p| p.category.as_deref() == Some("omo")) - .count(); + .get_provider_by_id(id, app_type.as_str())? + .and_then(|p| p.category) + .as_deref() + == Some("omo"); - if omo_count <= 1 && was_current { - return Err(AppError::Message( - "无法删除当前启用的最后一个 OMO 配置,请先停用".to_string(), - )); - } + if is_omo { + let was_current = state.db.is_omo_provider_current(app_type.as_str(), id)?; + let omo_count = state + .db + .get_all_providers(app_type.as_str())? + .values() + .filter(|p| p.category.as_deref() == Some("omo")) + .count(); - state.db.delete_provider(app_type.as_str(), id)?; - if was_current { - crate::services::OmoService::delete_config_file()?; + if omo_count <= 1 && was_current { + return Err(AppError::Message( + "无法删除当前启用的最后一个 OMO 配置,请先停用".to_string(), + )); + } + + state.db.delete_provider(app_type.as_str(), id)?; + if was_current { + crate::services::OmoService::delete_config_file()?; + } + return Ok(()); } - return Ok(()); } - // Remove from database state.db.delete_provider(app_type.as_str(), id)?; // Also remove from live config - remove_opencode_provider_from_live(id)?; + match app_type { + AppType::OpenCode => remove_opencode_provider_from_live(id)?, + AppType::OpenClaw => remove_openclaw_provider_from_live(id)?, + _ => {} // Should not reach here + } return Ok(()); } @@ -306,7 +317,7 @@ impl ProviderService { state.db.delete_provider(app_type.as_str(), id) } - /// Remove provider from live config only (for additive mode apps like OpenCode) + /// Remove provider from live config only (for additive mode apps like OpenCode, OpenClaw) /// /// Does NOT delete from database - provider remains in the list. /// This is used when user wants to "remove" a provider from active config @@ -338,7 +349,9 @@ impl ProviderService { remove_opencode_provider_from_live(id)?; } } - // Future: add other additive mode apps here + AppType::OpenClaw => { + remove_openclaw_provider_from_live(id)?; + } _ => { return Err(AppError::Message(format!( "App {} does not support remove from live config", @@ -454,22 +467,25 @@ impl ProviderService { // Use effective current provider (validated existence) to ensure backfill targets valid provider let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?; - match (current_id, matches!(app_type, AppType::OpenCode)) { - (Some(current_id), false) if current_id != id => { - // Only backfill when switching to a different provider. - if let Ok(live_config) = read_live_settings(app_type.clone()) { - if let Some(mut current_provider) = providers.get(¤t_id).cloned() { - current_provider.settings_config = live_config; - // Ignore backfill failure, don't affect switch flow. - let _ = state.db.save_provider(app_type.as_str(), ¤t_provider); + if let Some(current_id) = current_id { + if current_id != id { + // Additive mode apps - all providers coexist in the same file, + // no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini) + if !app_type.is_additive_mode() { + // Only backfill when switching to a different provider + if let Ok(live_config) = read_live_settings(app_type.clone()) { + if let Some(mut current_provider) = providers.get(¤t_id).cloned() { + current_provider.settings_config = live_config; + // Ignore backfill failure, don't affect switch flow + let _ = state.db.save_provider(app_type.as_str(), ¤t_provider); + } } } } - _ => {} } - // OpenCode uses additive mode - skip setting is_current (no such concept) - if !matches!(app_type, AppType::OpenCode) { + // Additive mode apps skip setting is_current (no such concept) + if !app_type.is_additive_mode() { // Update local settings (device-level, takes priority) crate::settings::set_current_provider(&app_type, Some(id))?; @@ -515,6 +531,7 @@ impl ProviderService { AppType::Codex => Self::extract_codex_common_config(&provider.settings_config), AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config), AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config), + AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config), } } @@ -528,6 +545,7 @@ impl ProviderService { AppType::Codex => Self::extract_codex_common_config(settings_config), AppType::Gemini => Self::extract_gemini_common_config(settings_config), AppType::OpenCode => Self::extract_opencode_common_config(settings_config), + AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config), } } @@ -684,6 +702,27 @@ impl ProviderService { .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) } + /// Extract common config for OpenClaw (JSON format) + fn extract_openclaw_common_config(settings: &Value) -> Result { + // OpenClaw uses a different config structure with baseUrl, apiKey, api, models + // For common config, we exclude provider-specific fields like apiKey + let mut config = settings.clone(); + + // Remove provider-specific fields + if let Some(obj) = config.as_object_mut() { + obj.remove("apiKey"); + obj.remove("baseUrl"); + // Keep api and models as they might be common + } + + if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) { + return Ok("{}".to_string()); + } + + serde_json::to_string_pretty(&config) + .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) + } + /// Import default configuration from live files (re-export) /// /// Returns `Ok(true)` if imported, `Ok(false)` if skipped. @@ -861,6 +900,17 @@ impl ProviderService { )); } } + AppType::OpenClaw => { + // OpenClaw uses config structure: { baseUrl, apiKey, api, models } + // Basic validation - must be an object + if !provider.settings_config.is_object() { + return Err(AppError::localized( + "provider.openclaw.settings.not_object", + "OpenClaw 配置必须是 JSON 对象", + "OpenClaw configuration must be a JSON object", + )); + } + } } // Validate and clean UsageScript configuration (common for all app types) @@ -1032,6 +1082,30 @@ impl ProviderService { Ok((api_key, base_url)) } + AppType::OpenClaw => { + // OpenClaw uses apiKey and baseUrl directly on the object + let api_key = provider + .settings_config + .get("apiKey") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + AppError::localized( + "provider.openclaw.api_key.missing", + "缺少 API Key", + "API key is missing", + ) + })? + .to_string(); + + let base_url = provider + .settings_config + .get("baseUrl") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + Ok((api_key, base_url)) + } } } } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index e1ae3b213..1735cac9a 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -34,6 +34,8 @@ pub struct VisibleApps { pub gemini: bool, #[serde(default = "default_true")] pub opencode: bool, + #[serde(default = "default_true")] + pub openclaw: bool, } impl Default for VisibleApps { @@ -43,6 +45,7 @@ impl Default for VisibleApps { codex: true, gemini: true, opencode: true, + openclaw: true, } } } @@ -55,6 +58,7 @@ impl VisibleApps { AppType::Codex => self.codex, AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, + AppType::OpenClaw => self.openclaw, } } } @@ -194,6 +198,8 @@ pub struct AppSettings { pub gemini_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub opencode_config_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub openclaw_config_dir: Option, // ===== 当前供应商 ID(设备级)===== /// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current) @@ -208,6 +214,9 @@ pub struct AppSettings { /// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致) #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_opencode: Option, + /// 当前 OpenClaw 供应商 ID(本地存储,对 OpenClaw 可能无意义,但保持结构一致) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_provider_openclaw: Option, // ===== Skill 同步设置 ===== /// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy @@ -254,10 +263,12 @@ impl Default for AppSettings { codex_config_dir: None, gemini_config_dir: None, opencode_config_dir: None, + openclaw_config_dir: None, current_provider_claude: None, current_provider_codex: None, current_provider_gemini: None, current_provider_opencode: None, + current_provider_openclaw: None, skill_sync_method: SyncMethod::default(), webdav_sync: None, webdav_backup: None, @@ -305,6 +316,13 @@ impl AppSettings { .filter(|s| !s.is_empty()) .map(|s| s.to_string()); + self.openclaw_config_dir = self + .openclaw_config_dir + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + self.language = self .language .as_ref() @@ -497,6 +515,14 @@ pub fn get_opencode_override_dir() -> Option { .map(|p| resolve_override_path(p)) } +pub fn get_openclaw_override_dir() -> Option { + let settings = settings_store().read().ok()?; + settings + .openclaw_config_dir + .as_ref() + .map(|p| resolve_override_path(p)) +} + // ===== 当前供应商管理函数 ===== /// 获取指定应用类型的当前供应商 ID(从本地 settings 读取) @@ -510,6 +536,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option { AppType::Codex => settings.current_provider_codex.clone(), AppType::Gemini => settings.current_provider_gemini.clone(), AppType::OpenCode => settings.current_provider_opencode.clone(), + AppType::OpenClaw => settings.current_provider_openclaw.clone(), } } @@ -525,6 +552,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()), AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()), AppType::OpenCode => settings.current_provider_opencode = id.map(|s| s.to_string()), + AppType::OpenClaw => settings.current_provider_openclaw = id.map(|s| s.to_string()), } update_settings(settings) From e85878e95a6961049571281d24cb10ae1aceca91 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 1 Feb 2026 20:31:21 +0800 Subject: [PATCH 06/44] feat(services): add OpenClaw branches to backend services - Add OpenClaw branches to proxy service (not supported) - Add OpenClaw to MCP service (skip sync, MCP still in development) - Add OpenClaw skills directory path - Update ProxyTakeoverStatus with openclaw field - Add OpenClaw to stream check (not supported) --- src-tauri/src/proxy/providers/mod.rs | 8 ++++++ src-tauri/src/proxy/types.rs | 2 ++ src-tauri/src/services/config.rs | 4 +++ src-tauri/src/services/mcp.rs | 9 ++++++ src-tauri/src/services/proxy.rs | 38 ++++++++++++++++++++++++++ src-tauri/src/services/skill.rs | 8 ++++++ src-tauri/src/services/stream_check.rs | 28 +++++++++++++++++++ 7 files changed, 97 insertions(+) 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/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 4a2e671ef..f02136075 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -230,6 +230,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")); + } + } } // 默认路径:回退到用户主目录下的标准位置 @@ -244,6 +249,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"), }) } @@ -562,6 +568,7 @@ impl SkillService { AppType::Codex => "codex", AppType::Gemini => "gemini", AppType::OpenCode => "opencode", + AppType::OpenClaw => "openclaw", }; unmanaged @@ -611,6 +618,7 @@ impl SkillService { AppType::Codex => "codex", AppType::Gemini => "gemini", AppType::OpenCode => "opencode", + AppType::OpenClaw => "openclaw", }; found_in.push(app_str.to_string()); } 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 From 6952360f70ca23eceb5b5845b746c1a81ea1710b Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 1 Feb 2026 20:31:32 +0800 Subject: [PATCH 07/44] feat(backend): add OpenClaw support to commands and deeplinks - Register openclaw_config module in lib.rs - Add OpenClaw commands and provider import on startup - Add OpenClaw branches to config and provider commands - Add build_openclaw_settings() for deeplink imports - Update MCP handlers with openclaw field in McpApps - Add OpenClaw prompt file path --- src-tauri/src/commands/config.rs | 11 +++++++++++ src-tauri/src/commands/provider.rs | 24 ++++++++++++++++++++++++ src-tauri/src/deeplink/mcp.rs | 2 ++ src-tauri/src/deeplink/provider.rs | 30 ++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 15 +++++++++++++++ src-tauri/src/mcp/claude.rs | 1 + src-tauri/src/mcp/codex.rs | 1 + src-tauri/src/mcp/gemini.rs | 1 + src-tauri/src/mcp/opencode.rs | 1 + src-tauri/src/prompt_files.rs | 3 +++ 10 files changed, 89 insertions(+) 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/provider.rs b/src-tauri/src/commands/provider.rs index 47de928f5..70ce9048e 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -318,3 +318,27 @@ pub fn get_opencode_live_provider_ids() -> Result, String> { .map(|providers| providers.keys().cloned().collect()) .map_err(|e| e.to_string()) } + +// ============================================================================ +// OpenClaw 专属命令 +// ============================================================================ + +/// 从 OpenClaw live 配置导入供应商到数据库 +/// +/// 这是 OpenClaw 特有的功能,因为 OpenClaw 使用累加模式, +/// 用户可能已经在 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()) +} + +/// 获取 OpenClaw live 配置中的供应商 ID 列表 +/// +/// 用于前端判断供应商是否已添加到 openclaw.json +#[tauri::command] +pub fn get_openclaw_live_provider_ids() -> Result, String> { + crate::openclaw_config::get_providers() + .map(|providers| providers.keys().cloned().collect()) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/deeplink/mcp.rs b/src-tauri/src/deeplink/mcp.rs index 04d3f11b9..67f3d92cb 100644 --- a/src-tauri/src/deeplink/mcp.rs +++ b/src-tauri/src/deeplink/mcp.rs @@ -167,6 +167,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result { codex: false, gemini: false, opencode: false, + openclaw: false, }; for app in apps_str.split(',') { @@ -175,6 +176,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result { "codex" => apps.codex = true, "gemini" => apps.gemini = true, "opencode" => apps.opencode = true, + "openclaw" => apps.openclaw = true, 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..0528a2e49 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 // ============================================================================= diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ef47fdaee..dc36d60d9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ mod gemini_mcp; mod init_status; mod mcp; mod opencode_config; +mod openclaw_config; mod panic_hook; mod prompt; mod prompt_files; @@ -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::debug!("○ 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..."); @@ -992,6 +1004,9 @@ 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, // Global upstream proxy commands::get_global_proxy_url, commands::set_global_proxy_url, diff --git a/src-tauri/src/mcp/claude.rs b/src-tauri/src/mcp/claude.rs index 25d2f426a..3ba1a310b 100644 --- a/src-tauri/src/mcp/claude.rs +++ b/src-tauri/src/mcp/claude.rs @@ -92,6 +92,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result Result codex: true, gemini: false, opencode: false, + openclaw: false, }, description: None, homepage: None, diff --git a/src-tauri/src/mcp/gemini.rs b/src-tauri/src/mcp/gemini.rs index c8a7809f3..b2725da27 100644 --- a/src-tauri/src/mcp/gemini.rs +++ b/src-tauri/src/mcp/gemini.rs @@ -88,6 +88,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result Result Result { @@ -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)) From 31ec5d73a631f67065cfb6b215229b6a16266690 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 1 Feb 2026 20:31:40 +0800 Subject: [PATCH 08/44] feat(types): add OpenClaw TypeScript type definitions - Add OpenClawProviderConfig and OpenClawModel interfaces - Add openclaw to McpApps, VisibleApps, ProxyTakeoverStatus - Add "openclaw" to AppId union type - Add openclaw to SkillApps interface --- src/lib/api/skills.ts | 3 ++- src/lib/api/types.ts | 2 +- src/types.ts | 21 +++++++++++++++++++++ src/types/proxy.ts | 1 + 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index e7a6e34f2..673cf7a34 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/types.ts b/src/types.ts index 5d219c4f5..0aaa90ac1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -160,6 +160,7 @@ export interface VisibleApps { codex: boolean; gemini: boolean; opencode: boolean; + openclaw: boolean; } // WebDAV v2 同步状态 @@ -287,6 +288,7 @@ export interface McpApps { codex: boolean; gemini: boolean; opencode: boolean; + openclaw: boolean; } // MCP 服务器条目(v3.7.0 统一结构) @@ -424,3 +426,22 @@ export interface OpenCodeMcpServerSpec { // 通用字段 enabled?: boolean; } + +// ============================================================================ +// OpenClaw 专属配置(v3.11.0+) +// ============================================================================ + +// OpenClaw 模型配置 +export interface OpenClawModel { + id: string; + name: string; +} + +// OpenClaw 供应商配置(settings_config 结构) +// 对应 OpenClaw 的 models.providers. 配置 +export interface OpenClawProviderConfig { + baseUrl?: string; // API 端点 + apiKey?: string; // API 密钥 + api?: string; // API 协议类型(如 "openai-completions"、"anthropic") + models?: OpenClawModel[]; // 可用模型列表 +} 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 { From 545be56698682946aced1d5eaf3f2465c2a1feda Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 1 Feb 2026 20:31:53 +0800 Subject: [PATCH 09/44] feat(presets): add OpenClaw provider presets - Create openclawProviderPresets.ts with provider templates - Include Chinese officials (DeepSeek, Zhipu, Qwen, Kimi, MiniMax) - Include aggregators (AiHubMix, DMXAPI, OpenRouter, ModelScope) - Include third party partners (PackyCode, Cubence, AIGoCode) - Include OpenAI Compatible custom template --- src/config/openclawProviderPresets.ts | 386 ++++++++++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 src/config/openclawProviderPresets.ts diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts new file mode 100644 index 000000000..91b3fbad3 --- /dev/null +++ b/src/config/openclawProviderPresets.ts @@ -0,0 +1,386 @@ +/** + * OpenClaw provider presets configuration + * OpenClaw uses models.providers structure with custom provider configs + */ +import type { ProviderCategory, OpenClawProviderConfig } from "../types"; +import type { PresetTheme, TemplateValueConfig } from "./claudeProviderPresets"; + +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; +} + +/** + * OpenClaw API protocol options + */ +export const openclawApiProtocols = [ + { value: "openai-completions", label: "OpenAI Completions" }, + { value: "anthropic", label: "Anthropic" }, + { value: "google-generative-ai", label: "Google Generative AI" }, +] 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" }, + { id: "deepseek-reasoner", name: "DeepSeek R1" }, + ], + }, + category: "cn_official", + icon: "deepseek", + iconColor: "#1E88E5", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "sk-...", + editorValue: "", + }, + }, + }, + { + 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" }], + }, + 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: "", + }, + }, + }, + { + 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" }], + }, + 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: "", + }, + }, + }, + { + 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" }], + }, + 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: "", + }, + }, + }, + { + 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" }], + }, + category: "cn_official", + isPartner: true, + partnerPromotionKey: "minimax_cn", + theme: { + backgroundColor: "#f64551", + textColor: "#FFFFFF", + }, + icon: "minimax", + iconColor: "#FF6B6B", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + }, + + // ========== Aggregators ========== + { + name: "AiHubMix", + websiteUrl: "https://aihubmix.com", + apiKeyUrl: "https://aihubmix.com", + settingsConfig: { + baseUrl: "https://aihubmix.com/v1", + apiKey: "", + api: "anthropic", + models: [ + { id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5" }, + { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5" }, + ], + }, + category: "aggregator", + icon: "aihubmix", + iconColor: "#006FFB", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + }, + { + name: "DMXAPI", + websiteUrl: "https://www.dmxapi.cn", + apiKeyUrl: "https://www.dmxapi.cn", + settingsConfig: { + baseUrl: "https://www.dmxapi.cn/v1", + apiKey: "", + api: "anthropic", + models: [ + { id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5" }, + { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5" }, + ], + }, + category: "aggregator", + isPartner: true, + partnerPromotionKey: "dmxapi", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + }, + { + 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" }, + { id: "anthropic/claude-opus-4.5", name: "Claude Opus 4.5" }, + ], + }, + category: "aggregator", + icon: "openrouter", + iconColor: "#6566F1", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "sk-or-...", + editorValue: "", + }, + }, + }, + { + 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" }], + }, + 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: "", + }, + }, + }, + + // ========== 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/v1", + apiKey: "", + api: "anthropic", + models: [ + { id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5" }, + { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5" }, + ], + }, + category: "third_party", + isPartner: true, + partnerPromotionKey: "packycode", + icon: "packycode", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + }, + { + name: "Cubence", + websiteUrl: "https://cubence.com", + apiKeyUrl: "https://cubence.com/signup?code=CCSWITCH&source=ccs", + settingsConfig: { + baseUrl: "https://api.cubence.com/v1", + apiKey: "", + api: "anthropic", + models: [ + { id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5" }, + { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5" }, + ], + }, + category: "third_party", + isPartner: true, + partnerPromotionKey: "cubence", + icon: "cubence", + iconColor: "#000000", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + }, + { + name: "AIGoCode", + websiteUrl: "https://aigocode.com", + apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH", + settingsConfig: { + baseUrl: "https://api.aigocode.com/v1", + apiKey: "", + api: "anthropic", + models: [ + { id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5" }, + { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5" }, + ], + }, + category: "third_party", + isPartner: true, + partnerPromotionKey: "aigocode", + icon: "aigocode", + iconColor: "#5B7FFF", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + }, + + // ========== 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: "", + }, + }, + }, +]; From fedb08e846c0c3e7f265057d8019384c3f7f1a5b Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 1 Feb 2026 20:32:02 +0800 Subject: [PATCH 10/44] feat(ui): add OpenClaw support to frontend components - Update App.tsx with openclaw visibility and skills fallback - Add OpenClaw to AppSwitcher icon and display name maps - Update McpFormModal with openclaw in enabled apps - Update PromptFormModal/Panel with openclaw filename map - Update EndpointSpeedTest with openclaw timeout - Update AppVisibilitySettings with openclaw toggle - Update test state with openclaw defaults --- src/App.tsx | 4 +++- src/components/AppSwitcher.tsx | 2 ++ src/components/mcp/McpFormModal.tsx | 2 ++ src/components/prompts/PromptFormModal.tsx | 1 + src/components/prompts/PromptFormPanel.tsx | 1 + src/components/providers/forms/EndpointSpeedTest.tsx | 1 + .../providers/forms/hooks/useBaseUrlState.ts | 3 ++- src/components/settings/AppVisibilitySettings.tsx | 2 ++ tests/msw/state.ts | 12 ++++++++---- 9 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 4f0d47785..713fc8e1e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -123,6 +123,7 @@ function App() { codex: true, gemini: true, opencode: true, + openclaw: true, }; const getFirstVisibleApp = (): AppId => { @@ -130,6 +131,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 }; @@ -586,7 +588,7 @@ function App() { return ( ); case "mcp": diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx index b91c05273..717bb690c 100644 --- a/src/components/AppSwitcher.tsx +++ b/src/components/AppSwitcher.tsx @@ -29,12 +29,14 @@ export function AppSwitcher({ codex: "openai", gemini: "gemini", opencode: "opencode", + openclaw: "openclaw", }; const appDisplayName: Record = { claude: "Claude", codex: "Codex", gemini: "Gemini", opencode: "OpenCode", + openclaw: "OpenClaw", }; // Filter apps based on visibility settings (default all visible) diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx index 93d12745d..70ab3b03a 100644 --- a/src/components/mcp/McpFormModal.tsx +++ b/src/components/mcp/McpFormModal.tsx @@ -66,6 +66,7 @@ const McpFormModal: React.FC = ({ codex: boolean; gemini: boolean; opencode: boolean; + openclaw: boolean; }>(() => { if (initialData?.apps) { return { ...initialData.apps }; @@ -75,6 +76,7 @@ const McpFormModal: React.FC = ({ codex: defaultEnabledApps.includes("codex"), gemini: defaultEnabledApps.includes("gemini"), opencode: defaultEnabledApps.includes("opencode"), + openclaw: defaultEnabledApps.includes("openclaw"), }; }); diff --git a/src/components/prompts/PromptFormModal.tsx b/src/components/prompts/PromptFormModal.tsx index 970265d06..195d856f4 100644 --- a/src/components/prompts/PromptFormModal.tsx +++ b/src/components/prompts/PromptFormModal.tsx @@ -35,6 +35,7 @@ const PromptFormModal: 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/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/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/hooks/useBaseUrlState.ts b/src/components/providers/forms/hooks/useBaseUrlState.ts index 75e39594a..037dfeb3b 100644 --- a/src/components/providers/forms/hooks/useBaseUrlState.ts +++ b/src/components/providers/forms/hooks/useBaseUrlState.ts @@ -4,9 +4,10 @@ import { setCodexBaseUrl as setCodexBaseUrlInConfig, } from "@/utils/providerConfigUtils"; import type { ProviderCategory } from "@/types"; +import type { AppId } from "@/lib/api"; interface UseBaseUrlStateProps { - appType: "claude" | "codex" | "gemini" | "opencode"; + appType: AppId; category: ProviderCategory | undefined; settingsConfig: string; codexConfig?: string; diff --git a/src/components/settings/AppVisibilitySettings.tsx b/src/components/settings/AppVisibilitySettings.tsx index 3ed80dc55..0c30472ce 100644 --- a/src/components/settings/AppVisibilitySettings.tsx +++ b/src/components/settings/AppVisibilitySettings.tsx @@ -20,6 +20,7 @@ const APP_CONFIG: Array<{ { id: "codex", icon: "openai", nameKey: "apps.codex" }, { id: "gemini", icon: "gemini", nameKey: "apps.gemini" }, { id: "opencode", icon: "opencode", nameKey: "apps.opencode" }, + { id: "openclaw", icon: "openclaw", nameKey: "apps.openclaw" }, ]; export function AppVisibilitySettings({ @@ -33,6 +34,7 @@ export function AppVisibilitySettings({ codex: true, gemini: true, opencode: true, + openclaw: true, }; // Count how many apps are currently visible 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: {}, }; }; From d56e0b0344d1efccbe2efa9782f4d7e7b5a80753 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 1 Feb 2026 20:32:13 +0800 Subject: [PATCH 11/44] feat(i18n): add OpenClaw translations - Add "openclaw": "OpenClaw" to apps translations in zh.json - Add "openclaw": "OpenClaw" to apps translations in en.json - Add "openclaw": "OpenClaw" to apps translations in ja.json --- src/i18n/locales/en.json | 9 ++++++--- src/i18n/locales/ja.json | 9 ++++++--- src/i18n/locales/zh.json | 9 ++++++--- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 695a776fb..137f2cf62 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -478,7 +478,8 @@ "claude": "Claude", "codex": "Codex", "gemini": "Gemini", - "opencode": "OpenCode" + "opencode": "OpenCode", + "openclaw": "OpenClaw" }, "sessionManager": { "title": "Session Manager", @@ -926,7 +927,8 @@ "claude": "Claude", "codex": "Codex", "gemini": "Gemini", - "opencode": "OpenCode" + "opencode": "OpenCode", + "openclaw": "OpenClaw" } }, "userLevelPath": "User-level MCP path", @@ -1241,7 +1243,8 @@ "claude": "Claude", "codex": "Codex", "gemini": "Gemini", - "opencode": "OpenCode" + "opencode": "OpenCode", + "openclaw": "OpenClaw" }, "installFromZip": { "button": "Install from ZIP", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 825e11a6d..e458b6f87 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -478,7 +478,8 @@ "claude": "Claude", "codex": "Codex", "gemini": "Gemini", - "opencode": "OpenCode" + "opencode": "OpenCode", + "openclaw": "OpenClaw" }, "sessionManager": { "title": "セッション管理", @@ -926,7 +927,8 @@ "claude": "Claude", "codex": "Codex", "gemini": "Gemini", - "opencode": "OpenCode" + "opencode": "OpenCode", + "openclaw": "OpenClaw" } }, "userLevelPath": "ユーザーレベルの MCP パス", @@ -1239,7 +1241,8 @@ "claude": "Claude", "codex": "Codex", "gemini": "Gemini", - "opencode": "OpenCode" + "opencode": "OpenCode", + "openclaw": "OpenClaw" }, "installFromZip": { "button": "ZIP からインストール", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index c044c40e7..82d36b804 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -478,7 +478,8 @@ "claude": "Claude", "codex": "Codex", "gemini": "Gemini", - "opencode": "OpenCode" + "opencode": "OpenCode", + "openclaw": "OpenClaw" }, "sessionManager": { "title": "会话管理", @@ -926,7 +927,8 @@ "claude": "Claude", "codex": "Codex", "gemini": "Gemini", - "opencode": "OpenCode" + "opencode": "OpenCode", + "openclaw": "OpenClaw" } }, "userLevelPath": "用户级 MCP 配置路径", @@ -1241,7 +1243,8 @@ "claude": "Claude", "codex": "Codex", "gemini": "Gemini", - "opencode": "OpenCode" + "opencode": "OpenCode", + "openclaw": "OpenClaw" }, "installFromZip": { "button": "从 ZIP 安装", From 28b125b34f324affbfc37fcb80ec8ad5fcbd7ebb Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 1 Feb 2026 22:27:22 +0800 Subject: [PATCH 12/44] fix(openclaw): remove MCP/Skills/Prompts support from OpenClaw OpenClaw only needs provider management functionality, not MCP, Skills, or Prompts features. This commit removes the incorrectly added support: - Revert SCHEMA_VERSION from 6 to 5 (remove v5->v6 migration) - Remove enabled_openclaw field from mcp_servers and skills tables - Remove openclaw field from McpApps and SkillApps structs - Update DAO queries to exclude enabled_openclaw column - Fix frontend components and types to exclude openclaw from MCP/Prompts - Update all related test files --- src-tauri/src/app_config.rs | 22 ++++---------- src-tauri/src/database/dao/mcp.rs | 9 ++---- src-tauri/src/database/dao/skills.rs | 19 +++++------- src-tauri/src/database/mod.rs | 2 +- src-tauri/src/database/schema.rs | 34 +--------------------- src-tauri/src/deeplink/mcp.rs | 6 ++-- src-tauri/src/mcp/claude.rs | 1 - src-tauri/src/mcp/codex.rs | 1 - src-tauri/src/mcp/gemini.rs | 1 - src-tauri/src/mcp/opencode.rs | 1 - src/components/mcp/McpFormModal.tsx | 2 -- src/components/prompts/PromptFormModal.tsx | 5 ++-- src/components/prompts/PromptFormPanel.tsx | 5 ++-- src/types.ts | 1 - tests/msw/state.ts | 8 ++--- 15 files changed, 31 insertions(+), 86 deletions(-) diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index d53e7f38e..323f8a511 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -15,8 +15,6 @@ pub struct McpApps { pub gemini: bool, #[serde(default)] pub opencode: bool, - #[serde(default)] - pub openclaw: bool, } impl McpApps { @@ -27,7 +25,7 @@ impl McpApps { AppType::Codex => self.codex, AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, - AppType::OpenClaw => self.openclaw, + AppType::OpenClaw => false, // OpenClaw doesn't support MCP } } @@ -38,7 +36,7 @@ impl McpApps { AppType::Codex => self.codex = enabled, AppType::Gemini => self.gemini = enabled, AppType::OpenCode => self.opencode = enabled, - AppType::OpenClaw => self.openclaw = enabled, + AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore } } @@ -57,15 +55,12 @@ impl McpApps { if self.opencode { apps.push(AppType::OpenCode); } - if self.openclaw { - apps.push(AppType::OpenClaw); - } apps } /// 检查是否所有应用都未启用 pub fn is_empty(&self) -> bool { - !self.claude && !self.codex && !self.gemini && !self.opencode && !self.openclaw + !self.claude && !self.codex && !self.gemini && !self.opencode } } @@ -80,8 +75,6 @@ pub struct SkillApps { pub gemini: bool, #[serde(default)] pub opencode: bool, - #[serde(default)] - pub openclaw: bool, } impl SkillApps { @@ -92,7 +85,7 @@ impl SkillApps { AppType::Codex => self.codex, AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, - AppType::OpenClaw => self.openclaw, + AppType::OpenClaw => false, // OpenClaw doesn't support Skills } } @@ -103,7 +96,7 @@ impl SkillApps { AppType::Codex => self.codex = enabled, AppType::Gemini => self.gemini = enabled, AppType::OpenCode => self.opencode = enabled, - AppType::OpenClaw => self.openclaw = enabled, + AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore } } @@ -122,15 +115,12 @@ impl SkillApps { if self.opencode { apps.push(AppType::OpenCode); } - if self.openclaw { - apps.push(AppType::OpenClaw); - } apps } /// 检查是否所有应用都未启用 pub fn is_empty(&self) -> bool { - !self.claude && !self.codex && !self.gemini && !self.opencode && !self.openclaw + !self.claude && !self.codex && !self.gemini && !self.opencode } /// 仅启用指定应用(其他应用设为禁用) diff --git a/src-tauri/src/database/dao/mcp.rs b/src-tauri/src/database/dao/mcp.rs index 52d73ef87..d5c60163f 100644 --- a/src-tauri/src/database/dao/mcp.rs +++ b/src-tauri/src/database/dao/mcp.rs @@ -13,7 +13,7 @@ impl Database { pub fn get_all_mcp_servers(&self) -> Result, AppError> { let conn = lock_conn!(self.conn); let mut stmt = conn.prepare( - "SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_openclaw + "SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode FROM mcp_servers ORDER BY name ASC, id ASC" ).map_err(|e| AppError::Database(e.to_string()))?; @@ -31,7 +31,6 @@ impl Database { let enabled_codex: bool = row.get(8)?; let enabled_gemini: bool = row.get(9)?; let enabled_opencode: bool = row.get(10)?; - let enabled_openclaw: bool = row.get(11)?; let server = serde_json::from_str(&server_config_str).unwrap_or_default(); let tags = serde_json::from_str(&tags_str).unwrap_or_default(); @@ -47,7 +46,6 @@ impl Database { codex: enabled_codex, gemini: enabled_gemini, opencode: enabled_opencode, - openclaw: enabled_openclaw, }, description, homepage, @@ -72,8 +70,8 @@ impl Database { conn.execute( "INSERT OR REPLACE INTO mcp_servers ( id, name, server_config, description, homepage, docs, tags, - enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_openclaw - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + enabled_claude, enabled_codex, enabled_gemini, enabled_opencode + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", params![ server.id, server.name, @@ -89,7 +87,6 @@ impl Database { server.apps.codex, server.apps.gemini, server.apps.opencode, - server.apps.openclaw, ], ) .map_err(|e| AppError::Database(e.to_string()))?; diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index f29e89a63..2254cb061 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -22,7 +22,7 @@ impl Database { let mut stmt = conn .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_openclaw, installed_at + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at FROM skills ORDER BY name ASC", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -43,9 +43,8 @@ impl Database { codex: row.get(9)?, gemini: row.get(10)?, opencode: row.get(11)?, - openclaw: row.get(12)?, }, - installed_at: row.get(13)?, + installed_at: row.get(12)?, }) }) .map_err(|e| AppError::Database(e.to_string()))?; @@ -64,7 +63,7 @@ impl Database { let mut stmt = conn .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_openclaw, installed_at + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at FROM skills WHERE id = ?1", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -84,9 +83,8 @@ impl Database { codex: row.get(9)?, gemini: row.get(10)?, opencode: row.get(11)?, - openclaw: row.get(12)?, }, - installed_at: row.get(13)?, + installed_at: row.get(12)?, }) }); @@ -103,8 +101,8 @@ impl Database { conn.execute( "INSERT OR REPLACE INTO skills (id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_openclaw, installed_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", params![ skill.id, skill.name, @@ -118,7 +116,6 @@ impl Database { skill.apps.codex, skill.apps.gemini, skill.apps.opencode, - skill.apps.openclaw, skill.installed_at, ], ) @@ -148,8 +145,8 @@ impl Database { let conn = lock_conn!(self.conn); let affected = conn .execute( - "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4, enabled_openclaw = ?5 WHERE id = ?6", - params![apps.claude, apps.codex, apps.gemini, apps.opencode, apps.openclaw, id], + "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4 WHERE id = ?5", + params![apps.claude, apps.codex, apps.gemini, apps.opencode, id], ) .map_err(|e| AppError::Database(e.to_string()))?; Ok(affected > 0) diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 352593fd6..41c26e978 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -48,7 +48,7 @@ const DB_BACKUP_RETAIN: usize = 10; /// 当前 Schema 版本号 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 -pub(crate) const SCHEMA_VERSION: i32 = 6; +pub(crate) const SCHEMA_VERSION: i32 = 5; /// 安全地序列化 JSON,避免 unwrap panic pub(crate) fn to_json_string(value: &T) -> Result { diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 924939125..bca3c2de3 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -58,8 +58,7 @@ impl Database { id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL, description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]', enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0, - enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0, - enabled_openclaw BOOLEAN NOT NULL DEFAULT 0 + enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0 )", [], ) @@ -87,7 +86,6 @@ impl Database { enabled_codex BOOLEAN NOT NULL DEFAULT 0, enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0, - enabled_openclaw BOOLEAN NOT NULL DEFAULT 0, installed_at INTEGER NOT NULL DEFAULT 0 )", [], @@ -362,11 +360,6 @@ impl Database { Self::migrate_v4_to_v5(conn)?; Self::set_user_version(conn, 5)?; } - 5 => { - log::info!("迁移数据库从 v5 到 v6(OpenClaw 支持)"); - Self::migrate_v5_to_v6(conn)?; - Self::set_user_version(conn, 6)?; - } _ => { return Err(AppError::Database(format!( "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" @@ -859,7 +852,6 @@ impl Database { enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0, enabled_gemini BOOLEAN NOT NULL DEFAULT 0, - enabled_openclaw BOOLEAN NOT NULL DEFAULT 0, installed_at INTEGER NOT NULL DEFAULT 0 )", [], @@ -922,30 +914,6 @@ impl Database { Ok(()) } - /// v5 -> v6 迁移:添加 OpenClaw 支持 - /// - /// 为 mcp_servers 和 skills 表添加 enabled_openclaw 列。 - fn migrate_v5_to_v6(conn: &Connection) -> Result<(), AppError> { - // 为 mcp_servers 表添加 enabled_openclaw 列 - Self::add_column_if_missing( - conn, - "mcp_servers", - "enabled_openclaw", - "BOOLEAN NOT NULL DEFAULT 0", - )?; - - // 为 skills 表添加 enabled_openclaw 列 - Self::add_column_if_missing( - conn, - "skills", - "enabled_openclaw", - "BOOLEAN NOT NULL DEFAULT 0", - )?; - - log::info!("v5 -> v6 迁移完成:已添加 OpenClaw 支持"); - Ok(()) - } - /// 插入默认模型定价数据 /// 格式: (model_id, display_name, input, output, cache_read, cache_creation) /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 diff --git a/src-tauri/src/deeplink/mcp.rs b/src-tauri/src/deeplink/mcp.rs index 67f3d92cb..37df96579 100644 --- a/src-tauri/src/deeplink/mcp.rs +++ b/src-tauri/src/deeplink/mcp.rs @@ -167,7 +167,6 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result { codex: false, gemini: false, opencode: false, - openclaw: false, }; for app in apps_str.split(',') { @@ -176,7 +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" => apps.openclaw = 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/mcp/claude.rs b/src-tauri/src/mcp/claude.rs index 3ba1a310b..25d2f426a 100644 --- a/src-tauri/src/mcp/claude.rs +++ b/src-tauri/src/mcp/claude.rs @@ -92,7 +92,6 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result Result codex: true, gemini: false, opencode: false, - openclaw: false, }, description: None, homepage: None, diff --git a/src-tauri/src/mcp/gemini.rs b/src-tauri/src/mcp/gemini.rs index b2725da27..c8a7809f3 100644 --- a/src-tauri/src/mcp/gemini.rs +++ b/src-tauri/src/mcp/gemini.rs @@ -88,7 +88,6 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result Result = ({ codex: boolean; gemini: boolean; opencode: boolean; - openclaw: boolean; }>(() => { if (initialData?.apps) { return { ...initialData.apps }; @@ -76,7 +75,6 @@ const McpFormModal: React.FC = ({ codex: defaultEnabledApps.includes("codex"), gemini: defaultEnabledApps.includes("gemini"), opencode: defaultEnabledApps.includes("opencode"), - openclaw: defaultEnabledApps.includes("openclaw"), }; }); diff --git a/src/components/prompts/PromptFormModal.tsx b/src/components/prompts/PromptFormModal.tsx index 195d856f4..84e18d255 100644 --- a/src/components/prompts/PromptFormModal.tsx +++ b/src/components/prompts/PromptFormModal.tsx @@ -30,14 +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", - openclaw: "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 c4481fa4c..fcdc458b5 100644 --- a/src/components/prompts/PromptFormPanel.tsx +++ b/src/components/prompts/PromptFormPanel.tsx @@ -24,14 +24,13 @@ const PromptFormPanel: 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", - openclaw: "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/types.ts b/src/types.ts index 0aaa90ac1..dba9bace4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -288,7 +288,6 @@ export interface McpApps { codex: boolean; gemini: boolean; opencode: boolean; - openclaw: boolean; } // MCP 服务器条目(v3.7.0 统一结构) diff --git a/tests/msw/state.ts b/tests/msw/state.ts index 2db234840..bb08e101f 100644 --- a/tests/msw/state.ts +++ b/tests/msw/state.ts @@ -86,7 +86,7 @@ let mcpConfigs: McpConfigState = { id: "sample", name: "Sample Claude Server", enabled: true, - apps: { claude: true, codex: false, gemini: false, opencode: false, openclaw: false }, + apps: { claude: true, codex: false, gemini: false, opencode: false }, server: { type: "stdio", command: "claude-server", @@ -98,7 +98,7 @@ let mcpConfigs: McpConfigState = { id: "httpServer", name: "HTTP Codex Server", enabled: false, - apps: { claude: false, codex: true, gemini: false, opencode: false, openclaw: false }, + apps: { claude: false, codex: true, gemini: false, opencode: false }, server: { type: "http", url: "http://localhost:3000", @@ -131,7 +131,7 @@ export const resetProviderState = () => { id: "sample", name: "Sample Claude Server", enabled: true, - apps: { claude: true, codex: false, gemini: false, opencode: false, openclaw: false }, + apps: { claude: true, codex: false, gemini: false, opencode: false }, server: { type: "stdio", command: "claude-server", @@ -143,7 +143,7 @@ export const resetProviderState = () => { id: "httpServer", name: "HTTP Codex Server", enabled: false, - apps: { claude: false, codex: true, gemini: false, opencode: false, openclaw: false }, + apps: { claude: false, codex: true, gemini: false, opencode: false }, server: { type: "http", url: "http://localhost:3000", From 95391f19acffeec3aa84ba0a0aa3da6d6c066a66 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 1 Feb 2026 22:28:38 +0800 Subject: [PATCH 13/44] fix(tests): add mcp_servers table to v4 migration test fixture Also includes OpenClaw icon that was added in previous commits. --- src-tauri/src/database/tests.rs | 9 +++++++++ src/icons/extracted/index.ts | 1 + src/icons/extracted/metadata.ts | 7 +++++++ 3 files changed, 17 insertions(+) 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/icons/extracted/index.ts b/src/icons/extracted/index.ts index dcc9bb12d..c93ccd6bb 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", From 5c62c478783218d7b481a160eb91f63389c42f69 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 1 Feb 2026 23:02:33 +0800 Subject: [PATCH 14/44] chore(openclaw): remove dead code functions from openclaw_config Remove 6 unused functions marked with #[allow(dead_code)]: - get_openclaw_skills_dir: Skills not supported - get_env_vars/set_env_var: Environment vars unused - get_mcp_servers/set_mcp_server/remove_mcp_server: MCP not supported OpenClaw integration only requires provider management functionality. --- src-tauri/src/openclaw_config.rs | 70 -------------------------------- 1 file changed, 70 deletions(-) diff --git a/src-tauri/src/openclaw_config.rs b/src-tauri/src/openclaw_config.rs index 5a30d2565..9488216c8 100644 --- a/src-tauri/src/openclaw_config.rs +++ b/src-tauri/src/openclaw_config.rs @@ -70,14 +70,6 @@ pub fn get_openclaw_config_path() -> PathBuf { get_openclaw_dir().join("openclaw.json") } -/// 获取 OpenClaw Skills 目录路径 -/// -/// 返回 `~/.openclaw/skills/` -#[allow(dead_code)] -pub fn get_openclaw_skills_dir() -> PathBuf { - get_openclaw_dir().join("skills") -} - // ============================================================================ // Type Definitions // ============================================================================ @@ -262,65 +254,3 @@ pub fn set_typed_provider(id: &str, config: &OpenClawProviderConfig) -> Result<( let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?; set_provider(id, value) } - -// ============================================================================ -// Environment Variables -// ============================================================================ - -/// 获取环境变量配置 -#[allow(dead_code)] -pub fn get_env_vars() -> Result, AppError> { - let config = read_openclaw_config()?; - Ok(config - .get("env") - .and_then(|v| v.as_object()) - .cloned() - .unwrap_or_default()) -} - -/// 设置环境变量 -#[allow(dead_code)] -pub fn set_env_var(key: &str, value: &str) -> Result<(), AppError> { - let mut config = read_openclaw_config()?; - - if config.get("env").is_none() { - config["env"] = json!({}); - } - - if let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) { - env.insert(key.to_string(), Value::String(value.to_string())); - } - - write_openclaw_config(&config) -} - -// ============================================================================ -// MCP Functions (Reserved for future use) -// ============================================================================ - -/// 获取所有 MCP 服务器配置 -/// -/// OpenClaw MCP 配置在 `agents.list[].mcp.servers` -/// 由于 OpenClaw MCP 支持仍在开发中(Issue #4834),暂时返回空 -#[allow(dead_code)] -pub fn get_mcp_servers() -> Result, AppError> { - // OpenClaw MCP support is under development - // Return empty for now - Ok(Map::new()) -} - -/// 设置 MCP 服务器配置 -#[allow(dead_code)] -pub fn set_mcp_server(_id: &str, _config: Value) -> Result<(), AppError> { - // OpenClaw MCP support is under development - log::warn!("OpenClaw MCP support is not yet implemented"); - Ok(()) -} - -/// 删除 MCP 服务器配置 -#[allow(dead_code)] -pub fn remove_mcp_server(_id: &str) -> Result<(), AppError> { - // OpenClaw MCP support is under development - log::warn!("OpenClaw MCP support is not yet implemented"); - Ok(()) -} From 9bbb6c51ff0b7c963bd43f91346ebaf0f3d64e34 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 3 Feb 2026 22:45:59 +0800 Subject: [PATCH 15/44] feat(ui): add OpenClaw brand icon and component --- src/components/BrandIcons.tsx | 14 ++++++++++++++ src/icons/extracted/claw.svg | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 src/icons/extracted/claw.svg diff --git a/src/components/BrandIcons.tsx b/src/components/BrandIcons.tsx index f6a68c67a..65bdb487a 100644 --- a/src/components/BrandIcons.tsx +++ b/src/components/BrandIcons.tsx @@ -7,6 +7,7 @@ interface IconProps { import ClaudeSvg from "@/icons/extracted/claude.svg?url"; import OpenAISvg from "@/icons/extracted/openai.svg?url"; import GeminiSvg from "@/icons/extracted/gemini.svg?url"; +import OpenClawSvg from "@/icons/extracted/claw.svg?url"; export function ClaudeIcon({ size = 16, className = "" }: IconProps) { return ( @@ -47,6 +48,19 @@ export function GeminiIcon({ size = 16, className = "" }: IconProps) { ); } +export function OpenClawIcon({ size = 16, className = "" }: IconProps) { + return ( + OpenClaw + ); +} + // MCP icon uses inline SVG to support currentColor for hover effects export function McpIcon({ size = 16, className = "" }: IconProps) { return ( 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 @@ + + + + + + + + + + + + + + + + + + + + + + From 27bcfcb2b86fa2e380619a42ab957fe0ba3a6bb9 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 3 Feb 2026 23:29:28 +0800 Subject: [PATCH 16/44] fix(ui): add OpenClaw to app switcher Add "openclaw" to ALL_APPS array so OpenClaw appears in the main interface app switcher when enabled in visibility settings. --- src/components/AppSwitcher.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx index 717bb690c..3be13a0d9 100644 --- a/src/components/AppSwitcher.tsx +++ b/src/components/AppSwitcher.tsx @@ -9,7 +9,7 @@ interface AppSwitcherProps { compact?: boolean; } -const ALL_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"]; +const ALL_APPS: AppId[] = ["claude", "codex", "gemini", "opencode", "openclaw"]; const STORAGE_KEY = "cc-switch-last-app"; export function AppSwitcher({ From 52db7941eaf10e052079e2e6b8feef0c8b466818 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 4 Feb 2026 00:09:25 +0800 Subject: [PATCH 17/44] fix(ui): update OpenClaw icon in icon index Replace old pixel-art style icon with new gradient lobster icon to match the SVG added in 2dad4729. --- src/icons/extracted/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/icons/extracted/index.ts b/src/icons/extracted/index.ts index c93ccd6bb..a87ab5d88 100644 --- a/src/icons/extracted/index.ts +++ b/src/icons/extracted/index.ts @@ -37,7 +37,7 @@ export const icons: Record = { notion: `Notion`, ollama: `Ollama`, openai: `OpenAI`, - openclaw: `OpenClaw`, + openclaw: `OpenClaw`, packycode: `PackyCode`, palm: `PaLM`, perplexity: `Perplexity`, From 47f2c47a2ff2ede69de9b4853c12cacf9f7f7c3e Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 4 Feb 2026 21:19:13 +0800 Subject: [PATCH 18/44] feat(openclaw): add agents.defaults config support - Add types for default model config (primary + fallbacks) - Add types for model catalog/allowlist with aliases - Extend OpenClawModelEntry with cost and contextWindow fields - Add Tauri commands: get/set_openclaw_default_model, get/set_openclaw_model_catalog - Create frontend API (src/lib/api/openclaw.ts) - Add suggestedDefaults to provider presets with model metadata - Add i18n translations (zh/en/ja) for openclawConfig.* --- src-tauri/src/commands/provider.rs | 35 ++++ src-tauri/src/lib.rs | 4 + src-tauri/src/openclaw_config.rs | 168 ++++++++++++++++- src/config/openclawProviderPresets.ts | 249 +++++++++++++++++++++++--- src/i18n/locales/en.json | 29 +++ src/i18n/locales/ja.json | 29 +++ src/i18n/locales/zh.json | 29 +++ src/lib/api/openclaw.ts | 59 ++++++ src/types.ts | 14 ++ 9 files changed, 595 insertions(+), 21 deletions(-) create mode 100644 src/lib/api/openclaw.ts diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 70ce9048e..d6d51397d 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -342,3 +342,38 @@ pub fn get_openclaw_live_provider_ids() -> Result, String> { .map(|providers| providers.keys().cloned().collect()) .map_err(|e| e.to_string()) } + +// ============================================================================ +// OpenClaw Agents Configuration Commands +// ============================================================================ + +/// 获取 OpenClaw 默认模型配置(agents.defaults.model) +#[tauri::command] +pub fn get_openclaw_default_model( +) -> Result, String> { + crate::openclaw_config::get_default_model().map_err(|e| e.to_string()) +} + +/// 设置 OpenClaw 默认模型配置(agents.defaults.model) +#[tauri::command] +pub fn set_openclaw_default_model( + model: crate::openclaw_config::OpenClawDefaultModel, +) -> Result<(), String> { + crate::openclaw_config::set_default_model(&model).map_err(|e| e.to_string()) +} + +/// 获取 OpenClaw 模型目录/允许列表(agents.defaults.models) +#[tauri::command] +pub fn get_openclaw_model_catalog( +) -> Result>, String> +{ + crate::openclaw_config::get_model_catalog().map_err(|e| e.to_string()) +} + +/// 设置 OpenClaw 模型目录/允许列表(agents.defaults.models) +#[tauri::command] +pub fn set_openclaw_model_catalog( + catalog: std::collections::HashMap, +) -> Result<(), String> { + crate::openclaw_config::set_model_catalog(&catalog).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dc36d60d9..05cde9676 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1007,6 +1007,10 @@ pub fn run() { // 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, // Global upstream proxy commands::get_global_proxy_url, commands::set_global_proxy_url, diff --git a/src-tauri/src/openclaw_config.rs b/src-tauri/src/openclaw_config.rs index 9488216c8..ab6082c1d 100644 --- a/src-tauri/src/openclaw_config.rs +++ b/src-tauri/src/openclaw_config.rs @@ -42,6 +42,7 @@ 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; // ============================================================================ @@ -101,6 +102,7 @@ pub struct OpenClawProviderConfig { /// OpenClaw 模型条目 #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct OpenClawModelEntry { /// 模型 ID pub id: String, @@ -109,11 +111,92 @@ pub struct OpenClawModelEntry { #[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, + + /// 其他自定义字段(保留未知字段) #[serde(flatten)] pub extra: Map, } +/// OpenClaw 模型成本配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenClawModelCost { + /// 输入价格(每百万 token) + pub input: f64, + + /// 输出价格(每百万 token) + pub output: f64, + + /// 其他自定义字段(保留未知字段) + #[serde(flatten)] + pub other: 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, + + /// 其他自定义字段(保留未知字段) + #[serde(flatten)] + pub other: HashMap, +} + +/// OpenClaw 模型目录条目(agents.defaults.models 中的值) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenClawModelCatalogEntry { + /// 模型别名(用于 UI 显示) + #[serde(skip_serializing_if = "Option::is_none")] + pub alias: Option, + + /// 其他自定义字段(保留未知字段) + #[serde(flatten)] + pub other: 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>, + + /// 其他自定义字段(保留未知字段) + #[serde(flatten)] + pub other: HashMap, +} + +/// OpenClaw agents 顶层配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenClawAgents { + /// 默认配置 + #[serde(skip_serializing_if = "Option::is_none")] + pub defaults: Option, + + /// 其他自定义字段(保留未知字段) + #[serde(flatten)] + pub other: HashMap, +} + // ============================================================================ // Core Read/Write Functions // ============================================================================ @@ -254,3 +337,86 @@ pub fn set_typed_provider(id: &str, config: &OpenClawProviderConfig) -> Result<( 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!({}); + } +} diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts index 91b3fbad3..3ef6e8796 100644 --- a/src/config/openclawProviderPresets.ts +++ b/src/config/openclawProviderPresets.ts @@ -2,9 +2,21 @@ * OpenClaw provider presets configuration * OpenClaw uses models.providers structure with custom provider configs */ -import type { ProviderCategory, OpenClawProviderConfig } from "../types"; +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; @@ -25,6 +37,8 @@ export interface OpenClawProviderPreset { iconColor?: string; /** Mark as custom template (for UI distinction) */ isCustomTemplate?: boolean; + /** Suggested default model configuration */ + suggestedDefaults?: OpenClawSuggestedDefaults; } /** @@ -50,8 +64,18 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ apiKey: "", api: "openai-completions", models: [ - { id: "deepseek-chat", name: "DeepSeek V3.2" }, - { id: "deepseek-reasoner", name: "DeepSeek R1" }, + { + 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", @@ -64,6 +88,16 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ 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", @@ -73,7 +107,14 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ baseUrl: "https://open.bigmodel.cn/api/paas/v4", apiKey: "", api: "openai-completions", - models: [{ id: "glm-4.7", name: "GLM-4.7" }], + models: [ + { + id: "glm-4.7", + name: "GLM-4.7", + contextWindow: 128000, + cost: { input: 0.001, output: 0.001 }, + }, + ], }, category: "cn_official", isPartner: true, @@ -93,6 +134,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ editorValue: "", }, }, + suggestedDefaults: { + model: { primary: "zhipu/glm-4.7" }, + modelCatalog: { "zhipu/glm-4.7": { alias: "GLM" } }, + }, }, { name: "Qwen Coder", @@ -102,7 +147,14 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", apiKey: "", api: "openai-completions", - models: [{ id: "qwen3-max", name: "Qwen3 Max" }], + models: [ + { + id: "qwen3-max", + name: "Qwen3 Max", + contextWindow: 32000, + cost: { input: 0.002, output: 0.006 }, + }, + ], }, category: "cn_official", icon: "qwen", @@ -120,6 +172,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ editorValue: "", }, }, + suggestedDefaults: { + model: { primary: "qwen/qwen3-max" }, + modelCatalog: { "qwen/qwen3-max": { alias: "Qwen" } }, + }, }, { name: "Kimi k2.5", @@ -129,7 +185,14 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ baseUrl: "https://api.moonshot.cn/v1", apiKey: "", api: "openai-completions", - models: [{ id: "kimi-k2.5", name: "Kimi K2.5" }], + models: [ + { + id: "kimi-k2.5", + name: "Kimi K2.5", + contextWindow: 131072, + cost: { input: 0.002, output: 0.006 }, + }, + ], }, category: "cn_official", icon: "kimi", @@ -147,6 +210,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ editorValue: "", }, }, + suggestedDefaults: { + model: { primary: "kimi/kimi-k2.5" }, + modelCatalog: { "kimi/kimi-k2.5": { alias: "Kimi" } }, + }, }, { name: "MiniMax", @@ -156,7 +223,14 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ baseUrl: "https://api.minimaxi.com/v1", apiKey: "", api: "openai-completions", - models: [{ id: "MiniMax-M2.1", name: "MiniMax M2.1" }], + models: [ + { + id: "MiniMax-M2.1", + name: "MiniMax M2.1", + contextWindow: 200000, + cost: { input: 0.001, output: 0.004 }, + }, + ], }, category: "cn_official", isPartner: true, @@ -174,6 +248,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ editorValue: "", }, }, + suggestedDefaults: { + model: { primary: "minimax/MiniMax-M2.1" }, + modelCatalog: { "minimax/MiniMax-M2.1": { alias: "MiniMax" } }, + }, }, // ========== Aggregators ========== @@ -186,8 +264,18 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ apiKey: "", api: "anthropic", models: [ - { id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5" }, - { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5" }, + { + id: "claude-sonnet-4-5-20250929", + name: "Claude Sonnet 4.5", + contextWindow: 200000, + cost: { input: 3, output: 15 }, + }, + { + id: "claude-opus-4-5-20251101", + name: "Claude Opus 4.5", + contextWindow: 200000, + cost: { input: 15, output: 75 }, + }, ], }, category: "aggregator", @@ -200,6 +288,16 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ editorValue: "", }, }, + suggestedDefaults: { + model: { + primary: "aihubmix/claude-sonnet-4-5-20250929", + fallbacks: ["aihubmix/claude-opus-4-5-20251101"], + }, + modelCatalog: { + "aihubmix/claude-sonnet-4-5-20250929": { alias: "Sonnet" }, + "aihubmix/claude-opus-4-5-20251101": { alias: "Opus" }, + }, + }, }, { name: "DMXAPI", @@ -210,8 +308,18 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ apiKey: "", api: "anthropic", models: [ - { id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5" }, - { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5" }, + { + id: "claude-sonnet-4-5-20250929", + name: "Claude Sonnet 4.5", + contextWindow: 200000, + cost: { input: 3, output: 15 }, + }, + { + id: "claude-opus-4-5-20251101", + name: "Claude Opus 4.5", + contextWindow: 200000, + cost: { input: 15, output: 75 }, + }, ], }, category: "aggregator", @@ -224,6 +332,16 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ editorValue: "", }, }, + suggestedDefaults: { + model: { + primary: "dmxapi/claude-sonnet-4-5-20250929", + fallbacks: ["dmxapi/claude-opus-4-5-20251101"], + }, + modelCatalog: { + "dmxapi/claude-sonnet-4-5-20250929": { alias: "Sonnet" }, + "dmxapi/claude-opus-4-5-20251101": { alias: "Opus" }, + }, + }, }, { name: "OpenRouter", @@ -234,8 +352,18 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ apiKey: "", api: "openai-completions", models: [ - { id: "anthropic/claude-sonnet-4.5", name: "Claude Sonnet 4.5" }, - { id: "anthropic/claude-opus-4.5", name: "Claude Opus 4.5" }, + { + id: "anthropic/claude-sonnet-4.5", + name: "Claude Sonnet 4.5", + contextWindow: 200000, + cost: { input: 3, output: 15 }, + }, + { + id: "anthropic/claude-opus-4.5", + name: "Claude Opus 4.5", + contextWindow: 200000, + cost: { input: 15, output: 75 }, + }, ], }, category: "aggregator", @@ -248,6 +376,16 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ editorValue: "", }, }, + suggestedDefaults: { + model: { + primary: "openrouter/anthropic/claude-sonnet-4.5", + fallbacks: ["openrouter/anthropic/claude-opus-4.5"], + }, + modelCatalog: { + "openrouter/anthropic/claude-sonnet-4.5": { alias: "Sonnet" }, + "openrouter/anthropic/claude-opus-4.5": { alias: "Opus" }, + }, + }, }, { name: "ModelScope", @@ -257,7 +395,14 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ baseUrl: "https://api-inference.modelscope.cn/v1", apiKey: "", api: "openai-completions", - models: [{ id: "ZhipuAI/GLM-4.7", name: "GLM-4.7" }], + models: [ + { + id: "ZhipuAI/GLM-4.7", + name: "GLM-4.7", + contextWindow: 128000, + cost: { input: 0.001, output: 0.001 }, + }, + ], }, category: "aggregator", icon: "modelscope", @@ -275,6 +420,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ editorValue: "", }, }, + suggestedDefaults: { + model: { primary: "modelscope/ZhipuAI/GLM-4.7" }, + modelCatalog: { "modelscope/ZhipuAI/GLM-4.7": { alias: "GLM" } }, + }, }, // ========== Third Party Partners ========== @@ -287,8 +436,18 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ apiKey: "", api: "anthropic", models: [ - { id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5" }, - { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5" }, + { + id: "claude-sonnet-4-5-20250929", + name: "Claude Sonnet 4.5", + contextWindow: 200000, + cost: { input: 3, output: 15 }, + }, + { + id: "claude-opus-4-5-20251101", + name: "Claude Opus 4.5", + contextWindow: 200000, + cost: { input: 15, output: 75 }, + }, ], }, category: "third_party", @@ -302,6 +461,16 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ editorValue: "", }, }, + suggestedDefaults: { + model: { + primary: "packycode/claude-sonnet-4-5-20250929", + fallbacks: ["packycode/claude-opus-4-5-20251101"], + }, + modelCatalog: { + "packycode/claude-sonnet-4-5-20250929": { alias: "Sonnet" }, + "packycode/claude-opus-4-5-20251101": { alias: "Opus" }, + }, + }, }, { name: "Cubence", @@ -312,8 +481,18 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ apiKey: "", api: "anthropic", models: [ - { id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5" }, - { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5" }, + { + id: "claude-sonnet-4-5-20250929", + name: "Claude Sonnet 4.5", + contextWindow: 200000, + cost: { input: 3, output: 15 }, + }, + { + id: "claude-opus-4-5-20251101", + name: "Claude Opus 4.5", + contextWindow: 200000, + cost: { input: 15, output: 75 }, + }, ], }, category: "third_party", @@ -328,6 +507,16 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ editorValue: "", }, }, + suggestedDefaults: { + model: { + primary: "cubence/claude-sonnet-4-5-20250929", + fallbacks: ["cubence/claude-opus-4-5-20251101"], + }, + modelCatalog: { + "cubence/claude-sonnet-4-5-20250929": { alias: "Sonnet" }, + "cubence/claude-opus-4-5-20251101": { alias: "Opus" }, + }, + }, }, { name: "AIGoCode", @@ -338,8 +527,18 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ apiKey: "", api: "anthropic", models: [ - { id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5" }, - { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5" }, + { + id: "claude-sonnet-4-5-20250929", + name: "Claude Sonnet 4.5", + contextWindow: 200000, + cost: { input: 3, output: 15 }, + }, + { + id: "claude-opus-4-5-20251101", + name: "Claude Opus 4.5", + contextWindow: 200000, + cost: { input: 15, output: 75 }, + }, ], }, category: "third_party", @@ -354,6 +553,16 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ editorValue: "", }, }, + suggestedDefaults: { + model: { + primary: "aigocode/claude-sonnet-4-5-20250929", + fallbacks: ["aigocode/claude-opus-4-5-20251101"], + }, + modelCatalog: { + "aigocode/claude-sonnet-4-5-20250929": { alias: "Sonnet" }, + "aigocode/claude-opus-4-5-20251101": { alias: "Opus" }, + }, + }, }, // ========== Custom Template ========== diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 137f2cf62..b1ab8e460 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1745,5 +1745,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 e458b6f87..3412ee1d5 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1726,5 +1726,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 82d36b804..1deeb99c0 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1745,5 +1745,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/lib/api/openclaw.ts b/src/lib/api/openclaw.ts new file mode 100644 index 000000000..cfad98ce0 --- /dev/null +++ b/src/lib/api/openclaw.ts @@ -0,0 +1,59 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { + OpenClawDefaultModel, + OpenClawModelCatalogEntry, +} from "@/types"; + +/** + * OpenClaw agents configuration API + * + * Manages agents.defaults configuration in ~/.openclaw/openclaw.json + */ +export const openclawApi = { + /** + * 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 }); + }, + + /** + * Import providers from live config (openclaw.json) to database + */ + async importProvidersFromLive(): Promise { + return await invoke("import_openclaw_providers_from_live"); + }, + + /** + * Get provider IDs that exist in live config (openclaw.json) + */ + async getLiveProviderIds(): Promise { + return await invoke("get_openclaw_live_provider_ids"); + }, +}; diff --git a/src/types.ts b/src/types.ts index dba9bace4..f53ae86b9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -434,6 +434,20 @@ export interface OpenCodeMcpServerSpec { export interface OpenClawModel { id: string; name: string; + alias?: string; + cost?: { input: number; output: number }; + contextWindow?: number; +} + +// OpenClaw 默认模型配置(agents.defaults.model) +export interface OpenClawDefaultModel { + primary: string; + fallbacks?: string[]; +} + +// OpenClaw 模型目录条目(agents.defaults.models 中的值) +export interface OpenClawModelCatalogEntry { + alias?: string; } // OpenClaw 供应商配置(settings_config 结构) From 715e9e89c488f4f1f40eb5595ef2441c8b9aa6de Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 5 Feb 2026 15:25:33 +0800 Subject: [PATCH 19/44] feat(openclaw): add additive mode frontend support - Add getOpenClawLiveProviderIds() API for querying live config - Update ProviderList to query OpenClaw live IDs - Rename isOpenCodeMode to isAdditiveMode (covers OpenCode + OpenClaw) - Update ProviderCard shouldAutoQuery and isActiveProvider logic - Update App.tsx onRemoveFromConfig and invalidateQueries for OpenClaw --- src/App.tsx | 17 +++++++++++++---- src/components/providers/ProviderActions.tsx | 15 ++++++++++----- src/components/providers/ProviderCard.tsx | 12 ++++++++++-- src/components/providers/ProviderList.tsx | 19 ++++++++++++++++--- src/lib/api/providers.ts | 8 ++++++++ 5 files changed, 57 insertions(+), 14 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 713fc8e1e..febd4fa34 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -425,10 +425,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: ["openclawLiveProviderIds"], + }); + } toast.success( t("notifications.removeFromConfigSuccess", { defaultValue: "已从配置移除", @@ -642,7 +651,7 @@ function App() { setConfirmAction({ provider, action: "delete" }) } onRemoveFromConfig={ - activeApp === "opencode" + activeApp === "opencode" || activeApp === "openclaw" ? (provider) => setConfirmAction({ provider, action: "remove" }) : undefined diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index e1184fefc..b54c04a4a 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -62,10 +62,13 @@ export function ProviderActions({ 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 +77,8 @@ export function ProviderActions({ } else { onSwitch(); } - } else if (isOpenCodeMode) { + } else if (isAdditiveMode) { + // 累加模式:切换配置状态(添加/移除) if (isInConfig) { if (onRemoveFromConfig) { onRemoveFromConfig(); @@ -112,7 +116,8 @@ export function ProviderActions({ }; } - if (isOpenCodeMode) { + // 累加模式(OpenCode 非 OMO / OpenClaw) + if (isAdditiveMode) { if (isInConfig) { return { disabled: false, @@ -180,7 +185,7 @@ export function ProviderActions({ const canDelete = isOmo ? !(isLastOmo && isCurrent) - : isOpenCodeMode + : isAdditiveMode ? true : !isCurrent; diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 67310b45b..2a3a2946b 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -133,7 +133,10 @@ export function ProviderCard({ const usageEnabled = provider.meta?.usage_script?.enabled ?? false; - const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent; + // 获取用量数据以判断是否有多套餐 + // 累加模式应用(OpenCode/OpenClaw):使用 isInConfig 代替 isCurrent + const shouldAutoQuery = + appId === "opencode" || appId === "openclaw" ? isInConfig : isCurrent; const autoQueryInterval = shouldAutoQuery ? provider.meta?.usage_script?.autoQueryInterval || 0 : 0; @@ -176,9 +179,14 @@ export function ProviderCard({ onOpenWebsite(displayUrl); }; + // 判断是否是"当前使用中"的供应商 + // - OMO 供应商:使用 isCurrent + // - 累加模式应用(OpenCode 非 OMO / OpenClaw):不存在"当前"概念,始终返回 false + // - 故障转移模式:代理实际使用的供应商(activeProviderId) + // - 普通模式:isCurrent const isActiveProvider = isOmo ? isCurrent - : appId === "opencode" + : appId === "opencode" || appId === "openclaw" ? false : isAutoFailoverEnabled ? activeProviderId === provider.id diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index 7b1ab9eae..ebc4b7cf2 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -84,12 +84,25 @@ export function ProviderList({ enabled: appId === "opencode", }); + // OpenClaw: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig + const { data: openclawLiveIds } = useQuery({ + queryKey: ["openclawLiveProviderIds"], + queryFn: () => providersApi.getOpenClawLiveProviderIds(), + enabled: appId === "openclaw", + }); + + // 判断供应商是否已添加到配置(累加模式应用:OpenCode/OpenClaw) const isProviderInConfig = useCallback( (providerId: string): boolean => { - if (appId !== "opencode") return true; // 非 OpenCode 应用始终返回 true - return opencodeLiveIds?.includes(providerId) ?? false; + if (appId === "opencode") { + return opencodeLiveIds?.includes(providerId) ?? false; + } + if (appId === "openclaw") { + return openclawLiveIds?.includes(providerId) ?? false; + } + return true; // 其他应用始终返回 true }, - [appId, opencodeLiveIds], + [appId, opencodeLiveIds, openclawLiveIds], ); const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId); 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"); + }, }; // ============================================================================ From 512f22e83a224f736ae972be0c377d2b702e16fe Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 5 Feb 2026 15:25:57 +0800 Subject: [PATCH 20/44] feat(openclaw): add provider form fields and UI components - Add OpenClawFormFields component for provider configuration - Add Collapsible UI component (radix-ui dependency) - Update ProviderForm with OpenClaw-specific handlers and preset logic - Extend OpenClawModel type with reasoning, input, maxTokens fields - Exclude OpenClaw from universal provider tab in AddProviderDialog --- package.json | 1 + pnpm-lock.yaml | 3 + .../providers/AddProviderDialog.tsx | 8 +- .../providers/forms/OpenClawFormFields.tsx | 452 ++++++++++++++++++ .../providers/forms/ProviderForm.tsx | 243 +++++++++- src/components/ui/collapsible.tsx | 9 + src/types.ts | 10 +- 7 files changed, 722 insertions(+), 4 deletions(-) create mode 100644 src/components/providers/forms/OpenClawFormFields.tsx create mode 100644 src/components/ui/collapsible.tsx 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/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index 32c17f5dd..71974e7c0 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -35,7 +35,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", ); @@ -185,6 +186,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); diff --git a/src/components/providers/forms/OpenClawFormFields.tsx b/src/components/providers/forms/OpenClawFormFields.tsx new file mode 100644 index 000000000..7db6ef7df --- /dev/null +++ b/src/components/providers/forms/OpenClawFormFields.tsx @@ -0,0 +1,452 @@ +import { useTranslation } from "react-i18next"; +import { useState } 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>( + {} + ); + + // Toggle advanced section for a model + const toggleModelAdvanced = (index: number) => { + setExpandedModels((prev) => ({ ...prev, [index]: !prev[index] })); + }; + + // Add a new model entry + const handleAddModel = () => { + onModelsChange([ + ...models, + { + id: "", + name: "", + contextWindow: undefined, + maxTokens: undefined, + cost: undefined, + }, + ]); + }; + + // Remove a model entry + const handleRemoveModel = (index: number) => { + 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", + })} + /> +
+ +
+ + {/* 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: "关闭" })} + +
+
+ {/* Spacer for alignment with delete button */} +
+
+ + {/* Basic Cost row */} +
+
+ + + handleCostChange(index, "input", e.target.value) + } + placeholder="3" + /> +
+
+ + + handleCostChange(index, "output", e.target.value) + } + placeholder="15" + /> +
+ {/* Spacer for alignment */} +
+
+
+ + {/* Advanced Options (Collapsible) */} + toggleModelAdvanced(index)} + > + + + + +
+
+ + + handleCostChange(index, "cacheRead", e.target.value) + } + placeholder="0.3" + /> +
+
+ + + handleCostChange( + index, + "cacheWrite", + e.target.value + ) + } + placeholder="3.75" + /> +
+ {/* Spacer for alignment */} +
+
+
+

+ {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..347492506 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -34,7 +34,13 @@ import { OPENCODE_PRESET_MODEL_VARIANTS, type OpenCodeProviderPreset, } from "@/config/opencodeProviderPresets"; +import { + openclawProviderPresets, + type OpenClawProviderPreset, +} from "@/config/openclawProviderPresets"; import { OpenCodeFormFields } from "./OpenCodeFormFields"; +import { OpenClawFormFields } from "./OpenClawFormFields"; +import type { OpenCodeModel, OpenClawModel } from "@/types"; import type { UniversalProviderPreset } from "@/config/universalProviderPresets"; import { applyTemplateValues } from "@/utils/providerConfigUtils"; import { mergeProviderMeta } from "@/utils/providerMetaUtils"; @@ -172,13 +178,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 { @@ -337,7 +355,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 +484,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}`, @@ -987,6 +1012,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 { @@ -1399,6 +1546,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 +1592,13 @@ export function ProviderForm({ setOpencodeExtraOptions({}); resetOmoDraftState(); } + // OpenClaw 自定义模式:重置为空配置 + if (appId === "openclaw") { + setOpenclawBaseUrl(""); + setOpenclawApiKey(""); + setOpenclawApi("openai-completions"); + setOpenclawModels([]); + } return; } @@ -1513,6 +1682,28 @@ export function ProviderForm({ return; } + // OpenClaw preset handling + if (appId === "openclaw") { + const preset = entry.preset as OpenClawProviderPreset; + const config = preset.settingsConfig; + + // 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, @@ -1752,6 +1943,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" + /> +
+ ( + + + + )} + /> + ) : ( <> Date: Thu, 5 Feb 2026 15:26:12 +0800 Subject: [PATCH 21/44] fix(openclaw): update API protocol values in provider presets Update anthropic -> anthropic-messages to match OpenClaw gateway config --- src/config/openclawProviderPresets.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts index 3ef6e8796..85687b005 100644 --- a/src/config/openclawProviderPresets.ts +++ b/src/config/openclawProviderPresets.ts @@ -43,11 +43,14 @@ export interface OpenClawProviderPreset { /** * 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: "anthropic", label: "Anthropic" }, + { 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; /** @@ -262,7 +265,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ settingsConfig: { baseUrl: "https://aihubmix.com/v1", apiKey: "", - api: "anthropic", + api: "anthropic-messages", models: [ { id: "claude-sonnet-4-5-20250929", @@ -306,7 +309,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ settingsConfig: { baseUrl: "https://www.dmxapi.cn/v1", apiKey: "", - api: "anthropic", + api: "anthropic-messages", models: [ { id: "claude-sonnet-4-5-20250929", @@ -434,7 +437,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ settingsConfig: { baseUrl: "https://www.packyapi.com/v1", apiKey: "", - api: "anthropic", + api: "anthropic-messages", models: [ { id: "claude-sonnet-4-5-20250929", @@ -479,7 +482,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ settingsConfig: { baseUrl: "https://api.cubence.com/v1", apiKey: "", - api: "anthropic", + api: "anthropic-messages", models: [ { id: "claude-sonnet-4-5-20250929", @@ -525,7 +528,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ settingsConfig: { baseUrl: "https://api.aigocode.com/v1", apiKey: "", - api: "anthropic", + api: "anthropic-messages", models: [ { id: "claude-sonnet-4-5-20250929", From 392344e1e9ab788a6e6b6337fc23ef95efd4842c Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 5 Feb 2026 16:25:57 +0800 Subject: [PATCH 22/44] fix(openclaw): prevent creating default provider on first launch Add additive mode guard in import_default_config() to skip OpenCode and OpenClaw apps, which should use their dedicated import functions. --- src-tauri/src/services/provider/live.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index b9997bb33..e7f7500d3 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -402,6 +402,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() { From 227f08e910d3bd8c045d2156335ee416e189fcb0 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 5 Feb 2026 21:29:24 +0800 Subject: [PATCH 23/44] fix(openclaw): pass providerKey in AddProviderDialog submit handler The providerKey was only being passed for opencode, causing openclaw provider creation to fail with "Provider key is required" error. --- src/components/providers/AddProviderDialog.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index 71974e7c0..5bd4d386d 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -94,7 +94,8 @@ 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; } From 7b2cf6681281ff7e0e1cb705ba530294160819ab Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 5 Feb 2026 21:42:54 +0800 Subject: [PATCH 24/44] feat(openclaw): add providerKey input field with validation - Add providerKey input field in ProviderForm for OpenClaw - Implement duplicate key detection by querying existing providers - Add format validation (lowercase letters, numbers, hyphens only) - Disable field in edit mode (key cannot be changed after creation) - Update mutations.ts to support OpenClaw providerKey - Add i18n translations for zh/en/ja --- .../providers/forms/ProviderForm.tsx | 90 +++++++++++++++++++ src/i18n/locales/en.json | 8 ++ src/i18n/locales/ja.json | 8 ++ src/i18n/locales/zh.json | 8 ++ src/lib/query/mutations.ts | 4 +- 5 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 347492506..98a667144 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -871,6 +871,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; @@ -1261,6 +1279,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()) { @@ -1394,6 +1430,8 @@ export function ProviderForm({ } else { payload.providerKey = opencodeProviderKey; } + } else if (appId === "openclaw") { + payload.providerKey = openclawProviderKey; } if (category === "omo" && !payload.presetCategory) { @@ -1594,6 +1632,7 @@ export function ProviderForm({ } // OpenClaw 自定义模式:重置为空配置 if (appId === "openclaw") { + setOpenclawProviderKey(""); setOpenclawBaseUrl(""); setOpenclawApiKey(""); setOpenclawApi("openai-completions"); @@ -1687,6 +1726,9 @@ export function ProviderForm({ const preset = entry.preset as OpenClawProviderPreset; const config = preset.settingsConfig; + // Clear provider key (user must enter their own unique key) + setOpenclawProviderKey(""); + // Update OpenClaw-specific states setOpenclawBaseUrl(config.baseUrl || ""); setOpenclawApiKey(config.apiKey || ""); @@ -1808,6 +1850,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 } /> diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b1ab8e460..2f52a6b49 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -732,6 +732,14 @@ "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." + }, "providerPreset": { "label": "Provider Preset", "custom": "Custom Configuration", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3412ee1d5..4fe768253 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -732,6 +732,14 @@ "modelOptionKeyPlaceholder": "provider", "modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}" }, + "openclaw": { + "providerKey": "プロバイダーキー", + "providerKeyPlaceholder": "my-provider", + "providerKeyHint": "設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。", + "providerKeyRequired": "プロバイダーキーを入力してください", + "providerKeyDuplicate": "このキーは既に使用されています", + "providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。" + }, "providerPreset": { "label": "プロバイダータイプ", "custom": "カスタム設定", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 1deeb99c0..7308d55c8 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -732,6 +732,14 @@ "modelOptionKeyPlaceholder": "provider", "modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}" }, + "openclaw": { + "providerKey": "供应商标识", + "providerKeyPlaceholder": "my-provider", + "providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符", + "providerKeyRequired": "请填写供应商标识", + "providerKeyDuplicate": "此标识已被使用,请更换", + "providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符" + }, "providerPreset": { "label": "预设供应商", "custom": "自定义配置", diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index 89e3e486b..a05ca8d49 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -16,12 +16,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; } From 46ab15f9f6b85722299cdb7532bc031d2292457a Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 5 Feb 2026 21:43:01 +0800 Subject: [PATCH 25/44] style: apply code formatting --- src/App.tsx | 6 +++++- .../providers/forms/OpenClawFormFields.tsx | 16 +++++++++------- src/components/ui/collapsible.tsx | 10 +++++----- src/lib/api/openclaw.ts | 5 +---- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index febd4fa34..5c237e4c5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -597,7 +597,11 @@ function App() { return ( ); case "mcp": diff --git a/src/components/providers/forms/OpenClawFormFields.tsx b/src/components/providers/forms/OpenClawFormFields.tsx index 7db6ef7df..ccf1e17d3 100644 --- a/src/components/providers/forms/OpenClawFormFields.tsx +++ b/src/components/providers/forms/OpenClawFormFields.tsx @@ -61,7 +61,7 @@ export function OpenClawFormFields({ }: OpenClawFormFieldsProps) { const { t } = useTranslation(); const [expandedModels, setExpandedModels] = useState>( - {} + {}, ); // Toggle advanced section for a model @@ -100,7 +100,7 @@ export function OpenClawFormFields({ const handleModelChange = ( index: number, field: keyof OpenClawModel, - value: unknown + value: unknown, ) => { const newModels = [...models]; newModels[index] = { ...newModels[index], [field]: value }; @@ -111,7 +111,7 @@ export function OpenClawFormFields({ const handleCostChange = ( index: number, costField: "input" | "output" | "cacheRead" | "cacheWrite", - value: string + value: string, ) => { const newModels = [...models]; const numValue = parseFloat(value); @@ -275,7 +275,7 @@ export function OpenClawFormFields({ handleModelChange( index, "contextWindow", - e.target.value ? parseInt(e.target.value) : undefined + e.target.value ? parseInt(e.target.value) : undefined, ) } placeholder="200000" @@ -294,7 +294,7 @@ export function OpenClawFormFields({ handleModelChange( index, "maxTokens", - e.target.value ? parseInt(e.target.value) : undefined + e.target.value ? parseInt(e.target.value) : undefined, ) } placeholder="32000" @@ -316,7 +316,9 @@ export function OpenClawFormFields({ {model.reasoning ? t("openclaw.reasoningOn", { defaultValue: "启用" }) - : t("openclaw.reasoningOff", { defaultValue: "关闭" })} + : t("openclaw.reasoningOff", { + defaultValue: "关闭", + })}
@@ -417,7 +419,7 @@ export function OpenClawFormFields({ handleCostChange( index, "cacheWrite", - e.target.value + e.target.value, ) } placeholder="3.75" diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx index a23e7a281..5c28cbcc3 100644 --- a/src/components/ui/collapsible.tsx +++ b/src/components/ui/collapsible.tsx @@ -1,9 +1,9 @@ -import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; -const Collapsible = CollapsiblePrimitive.Root +const Collapsible = CollapsiblePrimitive.Root; -const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; -const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; -export { Collapsible, CollapsibleTrigger, CollapsibleContent } +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/src/lib/api/openclaw.ts b/src/lib/api/openclaw.ts index cfad98ce0..251650a22 100644 --- a/src/lib/api/openclaw.ts +++ b/src/lib/api/openclaw.ts @@ -1,8 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { - OpenClawDefaultModel, - OpenClawModelCatalogEntry, -} from "@/types"; +import type { OpenClawDefaultModel, OpenClawModelCatalogEntry } from "@/types"; /** * OpenClaw agents configuration API From c431a860640e5f268cc3a1204b4f4920c645d358 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 5 Feb 2026 22:58:05 +0800 Subject: [PATCH 26/44] fix(openclaw): update button state and toast message after switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add cache invalidation for openclawLiveProviderIds in useSwitchProviderMutation to ensure button state updates correctly after adding/removing providers. Also fix toast message to show "已添加到配置" for OpenClaw (same as OpenCode). --- src/hooks/useProviderActions.ts | 16 +++++++++------- src/lib/query/mutations.ts | 6 ++++++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index ede55f8a2..daf4229b4 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -104,13 +104,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, diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index a05ca8d49..c5a263deb 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -174,6 +174,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 +183,11 @@ export const useSwitchProviderMutation = (appId: AppId) => { queryKey: ["omo", "current-provider-id"], }); } + if (appId === "openclaw") { + await queryClient.invalidateQueries({ + queryKey: ["openclawLiveProviderIds"], + }); + } try { await providersApi.updateTrayMenu(); From 02f9f2e3b8ee40a7bf789660f2827f5bf694f1f3 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 6 Feb 2026 11:28:22 +0800 Subject: [PATCH 27/44] feat(openclaw): register suggested models to allowlist on provider add When adding an OpenClaw provider with suggestedDefaults, automatically merge its model catalog into the allowlist and set the default model if not already configured. --- .../providers/AddProviderDialog.tsx | 22 ++++++-- .../providers/forms/ProviderForm.tsx | 18 ++++++- src/hooks/useProviderActions.ts | 52 +++++++++++++++++-- src/i18n/locales/en.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/zh.json | 3 +- src/lib/api/index.ts | 2 +- 7 files changed, 92 insertions(+), 11 deletions(-) diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index 5bd4d386d..0040f4a14 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; } @@ -83,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, @@ -95,7 +103,10 @@ export function AddProviderDialog({ }; // OpenCode/OpenClaw: pass providerKey for ID generation - if ((appId === "opencode" || appId === "openclaw") && values.providerKey) { + if ( + (appId === "opencode" || appId === "openclaw") && + values.providerKey + ) { providerData.providerKey = values.providerKey; } @@ -213,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); }, diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 98a667144..2cfb91af8 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -37,6 +37,7 @@ import { import { openclawProviderPresets, type OpenClawProviderPreset, + type OpenClawSuggestedDefaults, } from "@/config/openclawProviderPresets"; import { OpenCodeFormFields } from "./OpenCodeFormFields"; import { OpenClawFormFields } from "./OpenClawFormFields"; @@ -277,6 +278,7 @@ export function ProviderForm({ category?: ProviderCategory; isPartner?: boolean; partnerPromotionKey?: string; + suggestedDefaults?: OpenClawSuggestedDefaults; } | null>(null); const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false); const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] = @@ -1446,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) { @@ -1726,6 +1732,15 @@ export function ProviderForm({ 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(""); @@ -2206,5 +2221,6 @@ export type ProviderFormValues = ProviderFormData & { presetCategory?: ProviderCategory; isPartner?: boolean; meta?: ProviderMeta; - providerKey?: string; + providerKey?: string; // OpenCode/OpenClaw: user-defined provider key + suggestedDefaults?: OpenClawSuggestedDefaults; // OpenClaw: suggested default model configuration }; diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index daf4229b4..0f1d9bb91 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -2,8 +2,9 @@ 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 { providersApi, settingsApi, openclawApi, type AppId } from "@/lib/api"; import type { Provider, UsageScript } from "@/types"; +import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets"; import { useAddProviderMutation, useUpdateProviderMutation, @@ -54,10 +55,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], ); // 更新供应商 diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2f52a6b49..8d9a687ed 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -162,7 +162,8 @@ "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" }, "confirm": { "deleteProvider": "Delete Provider", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 4fe768253..3f7458300 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -162,7 +162,8 @@ "settingsSaved": "設定を保存しました", "settingsSaveFailed": "設定の保存に失敗しました: {{error}}", "openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です", - "openLinkFailed": "リンクを開けませんでした" + "openLinkFailed": "リンクを開けませんでした", + "openclawModelsRegistered": "モデルが /model リストに登録されました" }, "confirm": { "deleteProvider": "プロバイダーを削除", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7308d55c8..3d9d9ec54 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -162,7 +162,8 @@ "settingsSaved": "设置已保存", "settingsSaveFailed": "保存设置失败:{{error}}", "openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用", - "openLinkFailed": "链接打开失败" + "openLinkFailed": "链接打开失败", + "openclawModelsRegistered": "模型已注册到 /model 列表" }, "confirm": { "deleteProvider": "删除供应商", diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 8e48d0151..38d6b09e2 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -7,7 +7,7 @@ export { skillsApi } from "./skills"; export { usageApi } from "./usage"; export { vscodeApi } from "./vscode"; export { proxyApi } from "./proxy"; -export { sessionsApi } from "./sessions"; +export { openclawApi } from "./openclaw"; export * as configApi from "./config"; export type { ProviderSwitchEvent } from "./providers"; export type { Prompt } from "./prompts"; From 26a2fc4fe047946dc860e0373e3f8b82eb4b5bb3 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 6 Feb 2026 15:49:40 +0800 Subject: [PATCH 28/44] fix(openclaw,opencode): correct Opus pricing and upgrade to Opus 4.6 - Fix Opus pricing from $15/$75 (Opus 4 price) to $5/$25 in openclaw presets - Upgrade model ID from claude-opus-4-5-20251101 to claude-opus-4-6 - Update all suggestedDefaults references (fallbacks + modelCatalog) - Apply same model upgrade in opencode presets (7 providers) --- src/config/openclawProviderPresets.ts | 60 +++++++++++++-------------- src/config/opencodeProviderPresets.ts | 14 +++---- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts index 85687b005..5c0761135 100644 --- a/src/config/openclawProviderPresets.ts +++ b/src/config/openclawProviderPresets.ts @@ -274,10 +274,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ cost: { input: 3, output: 15 }, }, { - id: "claude-opus-4-5-20251101", - name: "Claude Opus 4.5", + id: "claude-opus-4-6", + name: "Claude Opus 4.6", contextWindow: 200000, - cost: { input: 15, output: 75 }, + cost: { input: 5, output: 25 }, }, ], }, @@ -294,11 +294,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ suggestedDefaults: { model: { primary: "aihubmix/claude-sonnet-4-5-20250929", - fallbacks: ["aihubmix/claude-opus-4-5-20251101"], + fallbacks: ["aihubmix/claude-opus-4-6"], }, modelCatalog: { "aihubmix/claude-sonnet-4-5-20250929": { alias: "Sonnet" }, - "aihubmix/claude-opus-4-5-20251101": { alias: "Opus" }, + "aihubmix/claude-opus-4-6": { alias: "Opus" }, }, }, }, @@ -318,10 +318,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ cost: { input: 3, output: 15 }, }, { - id: "claude-opus-4-5-20251101", - name: "Claude Opus 4.5", + id: "claude-opus-4-6", + name: "Claude Opus 4.6", contextWindow: 200000, - cost: { input: 15, output: 75 }, + cost: { input: 5, output: 25 }, }, ], }, @@ -338,11 +338,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ suggestedDefaults: { model: { primary: "dmxapi/claude-sonnet-4-5-20250929", - fallbacks: ["dmxapi/claude-opus-4-5-20251101"], + fallbacks: ["dmxapi/claude-opus-4-6"], }, modelCatalog: { "dmxapi/claude-sonnet-4-5-20250929": { alias: "Sonnet" }, - "dmxapi/claude-opus-4-5-20251101": { alias: "Opus" }, + "dmxapi/claude-opus-4-6": { alias: "Opus" }, }, }, }, @@ -362,10 +362,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ cost: { input: 3, output: 15 }, }, { - id: "anthropic/claude-opus-4.5", - name: "Claude Opus 4.5", + id: "anthropic/claude-opus-4.6", + name: "Claude Opus 4.6", contextWindow: 200000, - cost: { input: 15, output: 75 }, + cost: { input: 5, output: 25 }, }, ], }, @@ -382,11 +382,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ suggestedDefaults: { model: { primary: "openrouter/anthropic/claude-sonnet-4.5", - fallbacks: ["openrouter/anthropic/claude-opus-4.5"], + fallbacks: ["openrouter/anthropic/claude-opus-4.6"], }, modelCatalog: { "openrouter/anthropic/claude-sonnet-4.5": { alias: "Sonnet" }, - "openrouter/anthropic/claude-opus-4.5": { alias: "Opus" }, + "openrouter/anthropic/claude-opus-4.6": { alias: "Opus" }, }, }, }, @@ -446,10 +446,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ cost: { input: 3, output: 15 }, }, { - id: "claude-opus-4-5-20251101", - name: "Claude Opus 4.5", + id: "claude-opus-4-6", + name: "Claude Opus 4.6", contextWindow: 200000, - cost: { input: 15, output: 75 }, + cost: { input: 5, output: 25 }, }, ], }, @@ -467,11 +467,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ suggestedDefaults: { model: { primary: "packycode/claude-sonnet-4-5-20250929", - fallbacks: ["packycode/claude-opus-4-5-20251101"], + fallbacks: ["packycode/claude-opus-4-6"], }, modelCatalog: { "packycode/claude-sonnet-4-5-20250929": { alias: "Sonnet" }, - "packycode/claude-opus-4-5-20251101": { alias: "Opus" }, + "packycode/claude-opus-4-6": { alias: "Opus" }, }, }, }, @@ -491,10 +491,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ cost: { input: 3, output: 15 }, }, { - id: "claude-opus-4-5-20251101", - name: "Claude Opus 4.5", + id: "claude-opus-4-6", + name: "Claude Opus 4.6", contextWindow: 200000, - cost: { input: 15, output: 75 }, + cost: { input: 5, output: 25 }, }, ], }, @@ -513,11 +513,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ suggestedDefaults: { model: { primary: "cubence/claude-sonnet-4-5-20250929", - fallbacks: ["cubence/claude-opus-4-5-20251101"], + fallbacks: ["cubence/claude-opus-4-6"], }, modelCatalog: { "cubence/claude-sonnet-4-5-20250929": { alias: "Sonnet" }, - "cubence/claude-opus-4-5-20251101": { alias: "Opus" }, + "cubence/claude-opus-4-6": { alias: "Opus" }, }, }, }, @@ -537,10 +537,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ cost: { input: 3, output: 15 }, }, { - id: "claude-opus-4-5-20251101", - name: "Claude Opus 4.5", + id: "claude-opus-4-6", + name: "Claude Opus 4.6", contextWindow: 200000, - cost: { input: 15, output: 75 }, + cost: { input: 5, output: 25 }, }, ], }, @@ -559,11 +559,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ suggestedDefaults: { model: { primary: "aigocode/claude-sonnet-4-5-20250929", - fallbacks: ["aigocode/claude-opus-4-5-20251101"], + fallbacks: ["aigocode/claude-opus-4-6"], }, modelCatalog: { "aigocode/claude-sonnet-4-5-20250929": { alias: "Sonnet" }, - "aigocode/claude-opus-4-5-20251101": { alias: "Opus" }, + "aigocode/claude-opus-4-6": { alias: "Opus" }, }, }, }, 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", From f1fbe324d1b2dbe75109b1b6676874017691c5dd Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 7 Feb 2026 09:37:59 +0800 Subject: [PATCH 29/44] feat(openclaw): add default model button and fix sessionsApi export - Add "Enable as Default" button to OpenClaw provider cards - Place enable button before add/remove button for better UX - Disable remove button when provider is set as default - Add sessionsApi re-export from api barrel file to fix white screen - Add i18n keys for default model feature (zh/en/ja) --- src/App.tsx | 4 ++ src/components/providers/ProviderActions.tsx | 33 ++++++++++++- src/components/providers/ProviderCard.tsx | 9 ++++ src/components/providers/ProviderList.tsx | 32 +++++++++++++ src/hooks/useProviderActions.ts | 49 +++++++++++++++++++- src/i18n/locales/en.json | 7 ++- src/i18n/locales/ja.json | 7 ++- src/i18n/locales/zh.json | 7 ++- src/lib/api/index.ts | 1 + src/lib/query/mutations.ts | 3 ++ 10 files changed, 146 insertions(+), 6 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 5c237e4c5..a4af76b4a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -198,6 +198,7 @@ function App() { switchProvider, deleteProvider, saveUsageScript, + setAsDefaultModel, } = useProviderActions(activeApp); const disableOmoMutation = useDisableCurrentOmo(); @@ -670,6 +671,9 @@ function App() { activeApp === "claude" ? handleOpenTerminal : undefined } onCreate={() => setIsAddOpen(true)} + onSetAsDefault={ + activeApp === "openclaw" ? setAsDefaultModel : undefined + } /> diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index b54c04a4a..d53c0a486 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -10,6 +10,7 @@ import { Terminal, TestTube2, Trash2, + Zap, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; @@ -36,6 +37,9 @@ interface ProviderActionsProps { isAutoFailoverEnabled?: boolean; isInFailoverQueue?: boolean; onToggleFailover?: (enabled: boolean) => void; + // OpenClaw: default model + isDefaultModel?: boolean; + onSetAsDefault?: () => void; } export function ProviderActions({ @@ -58,6 +62,9 @@ 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"; @@ -120,10 +127,12 @@ export function ProviderActions({ 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: "移除" }), }; @@ -191,6 +200,26 @@ export function ProviderActions({ return (
+ {appId === "openclaw" && isInConfig && onSetAsDefault && ( + + )} +
diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index ebc4b7cf2..8c4f635cc 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -19,6 +19,7 @@ import { useQuery } from "@tanstack/react-query"; import type { Provider } from "@/types"; import type { AppId } from "@/lib/api"; import { providersApi } from "@/lib/api/providers"; +import { openclawApi } from "@/lib/api/openclaw"; import { useDragSort } from "@/hooks/useDragSort"; import { ProviderCard } from "@/components/providers/ProviderCard"; import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState"; @@ -51,6 +52,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 +73,7 @@ export function ProviderList({ isProxyRunning = false, isProxyTakeover = false, activeProviderId, + onSetAsDefault, }: ProviderListProps) { const { t } = useTranslation(); const { sortedProviders, sensors, handleDragEnd } = useDragSort( @@ -105,6 +108,22 @@ export function ProviderList({ [appId, opencodeLiveIds, openclawLiveIds], ); + // OpenClaw: query default model to determine which provider is default + const { data: openclawDefaultModel } = useQuery({ + queryKey: ["openclawDefaultModel"], + queryFn: () => openclawApi.getDefaultModel(), + enabled: 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(); @@ -253,6 +272,11 @@ export function ProviderList({ handleToggleFailover(provider.id, enabled) } activeProviderId={activeProviderId} + // OpenClaw: default model + isDefaultModel={isProviderDefaultModel(provider.id)} + onSetAsDefault={ + onSetAsDefault ? () => onSetAsDefault(provider) : undefined + } /> ); })} @@ -365,6 +389,9 @@ interface SortableProviderCardProps { isInFailoverQueue: boolean; onToggleFailover: (enabled: boolean) => void; activeProviderId?: string; + // OpenClaw: default model + isDefaultModel?: boolean; + onSetAsDefault?: () => void; } function SortableProviderCard({ @@ -392,6 +419,8 @@ function SortableProviderCard({ isInFailoverQueue, onToggleFailover, activeProviderId, + isDefaultModel, + onSetAsDefault, }: SortableProviderCardProps) { const { setNodeRef, @@ -441,6 +470,9 @@ function SortableProviderCard({ isInFailoverQueue={isInFailoverQueue} onToggleFailover={onToggleFailover} activeProviderId={activeProviderId} + // OpenClaw: default model + isDefaultModel={isDefaultModel} + onSetAsDefault={onSetAsDefault} />
); diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 0f1d9bb91..2f2083df8 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -3,7 +3,12 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { providersApi, settingsApi, openclawApi, type AppId } from "@/lib/api"; -import type { Provider, UsageScript } from "@/types"; +import type { + Provider, + UsageScript, + OpenClawProviderConfig, + OpenClawDefaultModel, +} from "@/types"; import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets"; import { useAddProviderMutation, @@ -218,12 +223,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: ["openclawDefaultModel"], + }); + 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/i18n/locales/en.json b/src/i18n/locales/en.json index 8d9a687ed..d9df8cbe6 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", @@ -163,7 +165,10 @@ "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", - "openclawModelsRegistered": "Models have been registered to /model list" + "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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3f7458300..02effd72b 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 プロバイダーを編集", @@ -163,7 +165,10 @@ "settingsSaveFailed": "設定の保存に失敗しました: {{error}}", "openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です", "openLinkFailed": "リンクを開けませんでした", - "openclawModelsRegistered": "モデルが /model リストに登録されました" + "openclawModelsRegistered": "モデルが /model リストに登録されました", + "openclawDefaultModelSet": "デフォルトモデルに設定しました", + "openclawDefaultModelSetFailed": "デフォルトモデルの設定に失敗しました", + "openclawNoModels": "モデルが設定されていません" }, "confirm": { "deleteProvider": "プロバイダーを削除", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 3d9d9ec54..c4012ac51 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 供应商", @@ -163,7 +165,10 @@ "settingsSaveFailed": "保存设置失败:{{error}}", "openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用", "openLinkFailed": "链接打开失败", - "openclawModelsRegistered": "模型已注册到 /model 列表" + "openclawModelsRegistered": "模型已注册到 /model 列表", + "openclawDefaultModelSet": "已设为默认模型", + "openclawDefaultModelSetFailed": "设置默认模型失败", + "openclawNoModels": "该供应商没有配置模型" }, "confirm": { "deleteProvider": "删除供应商", diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 38d6b09e2..6a797f1ea 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -8,6 +8,7 @@ export { usageApi } from "./usage"; export { vscodeApi } from "./vscode"; export { proxyApi } from "./proxy"; export { openclawApi } from "./openclaw"; +export { sessionsApi } from "./sessions"; export * as configApi from "./config"; export type { ProviderSwitchEvent } from "./providers"; export type { Prompt } from "./prompts"; diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index c5a263deb..da83c204f 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -187,6 +187,9 @@ export const useSwitchProviderMutation = (appId: AppId) => { await queryClient.invalidateQueries({ queryKey: ["openclawLiveProviderIds"], }); + await queryClient.invalidateQueries({ + queryKey: ["openclawDefaultModel"], + }); } try { From a33f8fe9735d3c045b59a36736919393d4d1d451 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 7 Feb 2026 11:05:57 +0800 Subject: [PATCH 30/44] perf: remove unnecessary query cache for Tauri local IPC Tauri IPC calls are in-process (microsecond latency), so the 5-minute staleTime and disabled refetchOnWindowFocus from web app templates are counterproductive. Set staleTime to 0 and enable refetchOnWindowFocus so external config changes are reflected immediately. --- src/lib/query/queryClient.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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, From 182015264cacdc60230e31233572f2e21c52b874 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 7 Feb 2026 14:49:15 +0800 Subject: [PATCH 31/44] fix(openclaw): add openclaw to all Record usages Expand McpApps interface, APP_IDS, APP_ICON_MAP, enabledCounts initializers, and test mock data to include the openclaw key, resolving TypeScript errors after AppId union was extended. --- src/components/mcp/McpFormModal.tsx | 2 ++ src/components/mcp/UnifiedMcpPanel.tsx | 2 +- src/components/skills/UnifiedSkillsPanel.tsx | 2 +- src/config/appConfig.tsx | 17 +++++++++++++++-- src/types.ts | 1 + tests/msw/state.ts | 8 ++++---- 6 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx index 93d12745d..70ab3b03a 100644 --- a/src/components/mcp/McpFormModal.tsx +++ b/src/components/mcp/McpFormModal.tsx @@ -66,6 +66,7 @@ const McpFormModal: React.FC = ({ codex: boolean; gemini: boolean; opencode: boolean; + openclaw: boolean; }>(() => { if (initialData?.apps) { return { ...initialData.apps }; @@ -75,6 +76,7 @@ const McpFormModal: React.FC = ({ codex: defaultEnabledApps.includes("codex"), gemini: defaultEnabledApps.includes("gemini"), opencode: defaultEnabledApps.includes("opencode"), + openclaw: defaultEnabledApps.includes("openclaw"), }; }); 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/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx index 6d6f62ce2..aa647c70d 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/config/appConfig.tsx b/src/config/appConfig.tsx index 6ab2b5046..9bb977462 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,7 @@ 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 +57,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/types.ts b/src/types.ts index 4138cd766..45b22d620 100644 --- a/src/types.ts +++ b/src/types.ts @@ -288,6 +288,7 @@ export interface McpApps { codex: boolean; gemini: boolean; opencode: boolean; + openclaw: boolean; } // MCP 服务器条目(v3.7.0 统一结构) diff --git a/tests/msw/state.ts b/tests/msw/state.ts index bb08e101f..2db234840 100644 --- a/tests/msw/state.ts +++ b/tests/msw/state.ts @@ -86,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", @@ -98,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", @@ -131,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", @@ -143,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", From 705cc8a5af9afc7ce5b45100fca92e934dc561a5 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 7 Feb 2026 21:42:36 +0800 Subject: [PATCH 32/44] feat(openclaw): add Workspace Files panel for managing bootstrap md files Add a dedicated panel to read/write OpenClaw's 6 workspace bootstrap files (AGENTS.md, SOUL.md, USER.md, IDENTITY.md, TOOLS.md, MEMORY.md) directly from ~/.openclaw/workspace/, with a whitelist-secured backend and Markdown editor UI. Also fix prompt auto-import missing OpenCode/OpenClaw and the PromptFormPanel filenameMap type exclusion. --- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/workspace.rs | 59 +++++++++ src-tauri/src/lib.rs | 5 + src/App.tsx | 22 +++- src/components/prompts/PromptFormPanel.tsx | 5 +- .../workspace/WorkspaceFileEditor.tsx | 95 +++++++++++++++ .../workspace/WorkspaceFilesPanel.tsx | 115 ++++++++++++++++++ src/i18n/locales/en.json | 16 +++ src/i18n/locales/ja.json | 16 +++ src/i18n/locales/zh.json | 16 +++ src/lib/api/index.ts | 1 + src/lib/api/workspace.ts | 11 ++ tests/components/McpFormModal.test.tsx | 1 + 13 files changed, 360 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/commands/workspace.rs create mode 100644 src/components/workspace/WorkspaceFileEditor.tsx create mode 100644 src/components/workspace/WorkspaceFilesPanel.tsx create mode 100644 src/lib/api/workspace.ts diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f7a1b81c0..6a102b864 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -20,6 +20,7 @@ mod stream_check; mod sync_support; mod usage; mod webdav_sync; +mod workspace; pub use config::*; pub use deeplink::*; @@ -40,3 +41,4 @@ pub use skill::*; pub use stream_check::*; pub use usage::*; pub use webdav_sync::*; +pub use workspace::*; diff --git a/src-tauri/src/commands/workspace.rs b/src-tauri/src/commands/workspace.rs new file mode 100644 index 000000000..8e092de4f --- /dev/null +++ b/src-tauri/src/commands/workspace.rs @@ -0,0 +1,59 @@ +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", +]; + +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/lib.rs b/src-tauri/src/lib.rs index 05cde9676..26c2f4cbd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -582,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, @@ -1023,6 +1025,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/App.tsx b/src/App.tsx index a4af76b4a..95c946c28 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import { Download, FolderArchive, Search, + FolderOpen, } from "lucide-react"; import type { Provider, VisibleApps } from "@/types"; import type { EnvConflict } from "@/types/env"; @@ -56,6 +57,7 @@ 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"; type View = | "providers" @@ -66,14 +68,15 @@ type View = | "mcp" | "agents" | "universal" - | "sessions"; + | "sessions" + | "workspace"; 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 +97,7 @@ const VALID_VIEWS: View[] = [ "agents", "universal", "sessions", + "workspace", ]; const getInitialView = (): View => { @@ -625,6 +629,8 @@ function App() { case "sessions": return ; + case "workspace": + return ; default: return (
@@ -783,6 +789,7 @@ function App() { defaultValue: "统一供应商", })} {currentView === "sessions" && t("sessionManager.title")} + {currentView === "workspace" && t("workspace.title")}
) : ( @@ -985,6 +992,17 @@ function App() { > + {activeApp === "openclaw" && ( + + )} + } + > + {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..04d151ca4 --- /dev/null +++ b/src/components/workspace/WorkspaceFilesPanel.tsx @@ -0,0 +1,115 @@ +import React, { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { + FileCode, + Heart, + User, + IdCard, + Wrench, + Brain, + 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" }, +]; + +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/i18n/locales/en.json b/src/i18n/locales/en.json index d9df8cbe6..1dee7f489 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1115,6 +1115,22 @@ "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" + }, + "editing": "Edit {{filename}}", + "saveSuccess": "Saved successfully", + "saveFailed": "Failed to save", + "loadFailed": "Failed to load" + }, "env": { "warning": { "title": "Environment Variable Conflicts Detected", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 02effd72b..f2d753d37 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1115,6 +1115,22 @@ "deleteMessage": "プロンプト「{{name}}」を削除してもよろしいですか?" } }, + "workspace": { + "title": "ワークスペースファイル", + "manage": "ワークスペース", + "files": { + "agents": "エージェントの操作指示とルール", + "soul": "エージェントの人格とコミュニケーションスタイル", + "user": "ユーザープロファイルと設定", + "identity": "エージェントの名前とアバター", + "tools": "ローカルツールドキュメント", + "memory": "長期記憶と意思決定記録" + }, + "editing": "{{filename}} を編集", + "saveSuccess": "保存しました", + "saveFailed": "保存に失敗しました", + "loadFailed": "読み込みに失敗しました" + }, "env": { "warning": { "title": "競合する環境変数を検出しました", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index c4012ac51..f0c2b08dc 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1115,6 +1115,22 @@ "deleteMessage": "确定要删除提示词 \"{{name}}\" 吗?" } }, + "workspace": { + "title": "Workspace 文件管理", + "manage": "Workspace", + "files": { + "agents": "Agent 操作指令和规则", + "soul": "Agent 人格和沟通风格", + "user": "用户档案和偏好", + "identity": "Agent 名称和头像", + "tools": "本地工具文档", + "memory": "长期记忆和决策记录" + }, + "editing": "编辑 {{filename}}", + "saveSuccess": "保存成功", + "saveFailed": "保存失败", + "loadFailed": "读取失败" + }, "env": { "warning": { "title": "检测到系统环境变量冲突", diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 6a797f1ea..7a0bfd1c2 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -9,6 +9,7 @@ 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/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/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(); From 4f2812f4720ea88129f1f4073bc48d9d35b91aab Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 7 Feb 2026 22:05:15 +0800 Subject: [PATCH 33/44] feat(openclaw): show only Workspace button in menu bar Hide Skills, Prompts, Sessions, and MCP buttons when OpenClaw is active, as it only needs Workspace file management functionality. --- src/App.tsx | 109 ++++++++++++++++++++++++++++------------------------ 1 file changed, 59 insertions(+), 50 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 95c946c28..2096b3fc6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -76,7 +76,13 @@ 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", "openclaw"]; +const VALID_APPS: AppId[] = [ + "claude", + "codex", + "gemini", + "opencode", + "openclaw", +]; const getInitialApp = (): AppId => { const saved = localStorage.getItem(STORAGE_KEY) as AppId | null; @@ -968,31 +974,7 @@ function App() { />
- - - {activeApp === "openclaw" && ( + {activeApp === "openclaw" ? ( + ) : ( + <> + + + + + )} - -
+ <> + + + + + ) : ( <> + + + ); +}; + +export default AgentsDefaultsPanel; diff --git a/src/components/openclaw/EnvPanel.tsx b/src/components/openclaw/EnvPanel.tsx new file mode 100644 index 000000000..9a36844a0 --- /dev/null +++ b/src/components/openclaw/EnvPanel.tsx @@ -0,0 +1,179 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { Plus, Trash2, Save, Eye, EyeOff } from "lucide-react"; +import { toast } from "sonner"; +import { openclawApi } from "@/lib/api/openclaw"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import type { OpenClawEnvConfig } from "@/types"; + +interface EnvEntry { + key: string; + value: string; + isNew?: boolean; +} + +const EnvPanel: React.FC = () => { + const { t } = useTranslation(); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [visibleKeys, setVisibleKeys] = useState>(new Set()); + + const loadEnv = useCallback(async () => { + try { + setLoading(true); + const env = await openclawApi.getEnv(); + const items: EnvEntry[] = Object.entries(env).map(([key, value]) => ({ + key, + value: String(value ?? ""), + })); + setEntries(items.length > 0 ? items : []); + } catch (err) { + toast.error(t("openclaw.env.loadFailed")); + console.error("Failed to load env config:", err); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + void loadEnv(); + }, [loadEnv]); + + const handleSave = async () => { + try { + setSaving(true); + const env: OpenClawEnvConfig = {}; + for (const entry of entries) { + const trimmedKey = entry.key.trim(); + if (trimmedKey) { + env[trimmedKey] = entry.value; + } + } + await openclawApi.setEnv(env); + toast.success(t("openclaw.env.saveSuccess")); + // Reload to normalize + await loadEnv(); + } catch (err) { + toast.error(t("openclaw.env.saveFailed")); + console.error("Failed to save env config:", err); + } finally { + setSaving(false); + } + }; + + const addEntry = () => { + setEntries((prev) => [...prev, { 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 (loading) { + return ( +
+
+ {t("common.loading")} +
+
+ ); + } + + return ( +
+

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

+ +
+ {entries.map((entry, index) => { + const sensitive = isApiKey(entry.key); + const visible = visibleKeys.has(`${index}`); + + 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..58830296f --- /dev/null +++ b/src/components/openclaw/ToolsPanel.tsx @@ -0,0 +1,204 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { Plus, Trash2, Save } from "lucide-react"; +import { toast } from "sonner"; +import { openclawApi } from "@/lib/api/openclaw"; +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"; + +const PROFILE_OPTIONS = ["default", "strict", "permissive", "custom"]; + +const ToolsPanel: React.FC = () => { + const { t } = useTranslation(); + const [config, setConfig] = useState({}); + const [allowList, setAllowList] = useState([]); + const [denyList, setDenyList] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + const loadTools = useCallback(async () => { + try { + setLoading(true); + const tools = await openclawApi.getTools(); + setConfig(tools); + setAllowList(tools.allow ?? []); + setDenyList(tools.deny ?? []); + } catch (err) { + toast.error(t("openclaw.tools.loadFailed")); + console.error("Failed to load tools config:", err); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + void loadTools(); + }, [loadTools]); + + const handleSave = async () => { + try { + setSaving(true); + const { profile, allow, deny, ...other } = config; + const newConfig: OpenClawToolsConfig = { + ...other, + profile: config.profile, + allow: allowList.filter((s) => s.trim()), + deny: denyList.filter((s) => s.trim()), + }; + await openclawApi.setTools(newConfig); + toast.success(t("openclaw.tools.saveSuccess")); + await loadTools(); + } catch (err) { + toast.error(t("openclaw.tools.saveFailed")); + console.error("Failed to save tools config:", err); + } finally { + setSaving(false); + } + }; + + const updateListItem = ( + list: string[], + setList: React.Dispatch>, + index: number, + value: string, + ) => { + setList(list.map((item, i) => (i === index ? value : item))); + }; + + const removeListItem = ( + list: string[], + setList: React.Dispatch>, + index: number, + ) => { + setList(list.filter((_, i) => i !== index)); + }; + + if (loading) { + return ( +
+
+ {t("common.loading")} +
+
+ ); + } + + return ( +
+

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

+ + {/* Profile selector */} +
+ + +
+ + {/* Allow list */} +
+ +
+ {allowList.map((item, index) => ( +
+ + updateListItem(allowList, setAllowList, index, e.target.value) + } + placeholder={t("openclaw.tools.patternPlaceholder")} + className="font-mono text-xs" + /> + +
+ ))} + +
+
+ + {/* Deny list */} +
+ +
+ {denyList.map((item, index) => ( +
+ + updateListItem(denyList, setDenyList, index, e.target.value) + } + placeholder={t("openclaw.tools.patternPlaceholder")} + className="font-mono text-xs" + /> + +
+ ))} + +
+
+ + {/* Save button */} +
+ +
+
+ ); +}; + +export default ToolsPanel; diff --git a/src/lib/api/openclaw.ts b/src/lib/api/openclaw.ts index 251650a22..ae36d4037 100644 --- a/src/lib/api/openclaw.ts +++ b/src/lib/api/openclaw.ts @@ -1,12 +1,25 @@ import { invoke } from "@tauri-apps/api/core"; -import type { OpenClawDefaultModel, OpenClawModelCatalogEntry } from "@/types"; +import type { + OpenClawDefaultModel, + OpenClawModelCatalogEntry, + OpenClawAgentsDefaults, + OpenClawEnvConfig, + OpenClawToolsConfig, +} from "@/types"; /** - * OpenClaw agents configuration API + * OpenClaw configuration API * - * Manages agents.defaults configuration in ~/.openclaw/openclaw.json + * 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) */ @@ -40,6 +53,24 @@ export const openclawApi = { 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 }); + }, + + // ============================================================ + // Provider Import + // ============================================================ + /** * Import providers from live config (openclaw.json) to database */ @@ -53,4 +84,40 @@ export const openclawApi = { async getLiveProviderIds(): Promise { return await invoke("get_openclaw_live_provider_ids"); }, + + // ============================================================ + // 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/types.ts b/src/types.ts index 45b22d620..f76790c19 100644 --- a/src/types.ts +++ b/src/types.ts @@ -467,3 +467,23 @@ export interface OpenClawProviderConfig { 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 +} From eccb95b83ddeb052d3c29ee34f6dbedfb978fb9e Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 8 Feb 2026 00:00:22 +0800 Subject: [PATCH 37/44] fix(openclaw): enable scrolling on config panels Add overflow-y-auto to main content area so OpenClaw panels (Env, Tools, Agents) can scroll when content exceeds viewport. --- src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 626b020d3..9985431ec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1102,7 +1102,7 @@ function App() {
-
+
{renderContent()}
From bc87f9d9eb66f49e823acd65454af1cb98c26905 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 8 Feb 2026 11:14:46 +0800 Subject: [PATCH 38/44] fix(openclaw): address code review findings for robustness - Fix EnvPanel visibleKeys using entry key name instead of array index to prevent visibility state corruption after deletion - Add NaN guard in AgentsDefaultsPanel numeric field parsing - Validate provider id and models before importing from live config - Upgrade import failure log level from debug to warn for OpenCode/OpenClaw --- src-tauri/src/lib.rs | 4 ++-- src-tauri/src/services/provider/live.rs | 10 ++++++++++ .../openclaw/AgentsDefaultsPanel.tsx | 19 +++++++++++++++---- src/components/openclaw/EnvPanel.tsx | 5 +++-- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 70b05b617..92edd4aaf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -501,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 时,从本地文件导入) @@ -534,7 +534,7 @@ pub fn run() { log::info!("✓ Imported {count} OpenClaw provider(s) from live config"); } Ok(_) => log::debug!("○ No OpenClaw providers found to import"), - Err(e) => log::debug!("○ Failed to import OpenClaw providers: {e}"), + Err(e) => log::warn!("○ Failed to import OpenClaw providers: {e}"), } // 3. 导入 MCP 服务器配置(表空时触发) diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 01242fd5d..2bd279a60 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -707,6 +707,16 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result { }; } - // Optional numeric fields + // Optional fields if (workspace.trim()) updated.workspace = workspace.trim(); else delete updated.workspace; - if (timeout.trim()) updated.timeout = Number(timeout); + // 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; - if (contextTokens.trim()) updated.contextTokens = Number(contextTokens); + const ctxNum = contextTokens.trim() ? parseNum(contextTokens) : undefined; + if (ctxNum !== undefined) updated.contextTokens = ctxNum; else delete updated.contextTokens; - if (maxConcurrent.trim()) updated.maxConcurrent = Number(maxConcurrent); + const concNum = maxConcurrent.trim() + ? parseNum(maxConcurrent) + : undefined; + if (concNum !== undefined) updated.maxConcurrent = concNum; else delete updated.maxConcurrent; await openclawApi.setAgentsDefaults(updated); diff --git a/src/components/openclaw/EnvPanel.tsx b/src/components/openclaw/EnvPanel.tsx index 9a36844a0..537845064 100644 --- a/src/components/openclaw/EnvPanel.tsx +++ b/src/components/openclaw/EnvPanel.tsx @@ -112,7 +112,8 @@ const EnvPanel: React.FC = () => {
{entries.map((entry, index) => { const sensitive = isApiKey(entry.key); - const visible = visibleKeys.has(`${index}`); + const visibilityId = entry.key || `__new_${index}`; + const visible = visibleKeys.has(visibilityId); return (
@@ -138,7 +139,7 @@ const EnvPanel: React.FC = () => { variant="ghost" size="icon" className="flex-shrink-0 h-9 w-9 text-muted-foreground" - onClick={() => toggleVisibility(`${index}`)} + onClick={() => toggleVisibility(visibilityId)} > {visible ? ( From 00b424628fdaf791b99b771f1b141dca630a0764 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 8 Feb 2026 15:35:09 +0800 Subject: [PATCH 39/44] refactor(openclaw): migrate config panels to TanStack Query hooks Centralize query keys and extract reusable hooks (useOpenClaw.ts), replacing manual useState/useEffect load/save patterns in Env, Tools, and AgentsDefaults panels for consistency with MCP/Skills modules. --- src/App.tsx | 3 +- .../openclaw/AgentsDefaultsPanel.tsx | 78 +++++------ src/components/openclaw/EnvPanel.tsx | 53 +++----- src/components/openclaw/ToolsPanel.tsx | 56 ++++---- src/components/providers/ProviderList.tsx | 22 +-- src/hooks/useOpenClaw.ts | 128 ++++++++++++++++++ src/hooks/useProviderActions.ts | 3 +- src/lib/query/mutations.ts | 5 +- 8 files changed, 228 insertions(+), 120 deletions(-) create mode 100644 src/hooks/useOpenClaw.ts diff --git a/src/App.tsx b/src/App.tsx index 9985431ec..384eaa661 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,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"; @@ -458,7 +459,7 @@ function App() { }); } else if (activeApp === "openclaw") { await queryClient.invalidateQueries({ - queryKey: ["openclawLiveProviderIds"], + queryKey: openclawKeys.liveProviderIds, }); } toast.success( diff --git a/src/components/openclaw/AgentsDefaultsPanel.tsx b/src/components/openclaw/AgentsDefaultsPanel.tsx index 481b91b0d..03d5af3f9 100644 --- a/src/components/openclaw/AgentsDefaultsPanel.tsx +++ b/src/components/openclaw/AgentsDefaultsPanel.tsx @@ -1,8 +1,12 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Save } from "lucide-react"; import { toast } from "sonner"; -import { openclawApi } from "@/lib/api/openclaw"; +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"; @@ -10,11 +14,11 @@ 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(""); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); // Extra known fields from agents.defaults const [workspace, setWorkspace] = useState(""); @@ -22,38 +26,25 @@ const AgentsDefaultsPanel: React.FC = () => { const [contextTokens, setContextTokens] = useState(""); const [maxConcurrent, setMaxConcurrent] = useState(""); - const loadDefaults = useCallback(async () => { - try { - setLoading(true); - const data = await openclawApi.getAgentsDefaults(); - setDefaults(data); - - if (data) { - setPrimaryModel(data.model?.primary ?? ""); - setFallbacks((data.model?.fallbacks ?? []).join(", ")); - - // Extract known extra fields - setWorkspace(String(data.workspace ?? "")); - setTimeout_(String(data.timeout ?? "")); - setContextTokens(String(data.contextTokens ?? "")); - setMaxConcurrent(String(data.maxConcurrent ?? "")); - } - } catch (err) { - toast.error(t("openclaw.agents.loadFailed")); - console.error("Failed to load agents defaults:", err); - } finally { - setLoading(false); - } - }, [t]); - useEffect(() => { - void loadDefaults(); - }, [loadDefaults]); + // 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 { - setSaving(true); - // Preserve all unknown fields from original data const updated: OpenClawAgentsDefaults = { ...defaults }; @@ -94,18 +85,17 @@ const AgentsDefaultsPanel: React.FC = () => { if (concNum !== undefined) updated.maxConcurrent = concNum; else delete updated.maxConcurrent; - await openclawApi.setAgentsDefaults(updated); + await saveAgentsMutation.mutateAsync(updated); toast.success(t("openclaw.agents.saveSuccess")); - await loadDefaults(); - } catch (err) { - toast.error(t("openclaw.agents.saveFailed")); - console.error("Failed to save agents defaults:", err); - } finally { - setSaving(false); + } catch (error) { + const detail = extractErrorMessage(error); + toast.error(t("openclaw.agents.saveFailed"), { + description: detail || undefined, + }); } }; - if (loading) { + if (isLoading) { return (
@@ -222,9 +212,13 @@ const AgentsDefaultsPanel: React.FC = () => { {/* Save button */}
-
diff --git a/src/components/openclaw/EnvPanel.tsx b/src/components/openclaw/EnvPanel.tsx index 537845064..baba04fae 100644 --- a/src/components/openclaw/EnvPanel.tsx +++ b/src/components/openclaw/EnvPanel.tsx @@ -1,8 +1,9 @@ -import React, { useState, useEffect, useCallback } from "react"; +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 { openclawApi } from "@/lib/api/openclaw"; +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"; @@ -15,35 +16,23 @@ interface EnvEntry { const EnvPanel: React.FC = () => { const { t } = useTranslation(); + const { data: envData, isLoading } = useOpenClawEnv(); + const saveEnvMutation = useSaveOpenClawEnv(); const [entries, setEntries] = useState([]); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); const [visibleKeys, setVisibleKeys] = useState>(new Set()); - const loadEnv = useCallback(async () => { - try { - setLoading(true); - const env = await openclawApi.getEnv(); - const items: EnvEntry[] = Object.entries(env).map(([key, value]) => ({ + useEffect(() => { + if (envData) { + const items: EnvEntry[] = Object.entries(envData).map(([key, value]) => ({ key, value: String(value ?? ""), })); setEntries(items.length > 0 ? items : []); - } catch (err) { - toast.error(t("openclaw.env.loadFailed")); - console.error("Failed to load env config:", err); - } finally { - setLoading(false); } - }, [t]); - - useEffect(() => { - void loadEnv(); - }, [loadEnv]); + }, [envData]); const handleSave = async () => { try { - setSaving(true); const env: OpenClawEnvConfig = {}; for (const entry of entries) { const trimmedKey = entry.key.trim(); @@ -51,15 +40,13 @@ const EnvPanel: React.FC = () => { env[trimmedKey] = entry.value; } } - await openclawApi.setEnv(env); + await saveEnvMutation.mutateAsync(env); toast.success(t("openclaw.env.saveSuccess")); - // Reload to normalize - await loadEnv(); - } catch (err) { - toast.error(t("openclaw.env.saveFailed")); - console.error("Failed to save env config:", err); - } finally { - setSaving(false); + } catch (error) { + const detail = extractErrorMessage(error); + toast.error(t("openclaw.env.saveFailed"), { + description: detail || undefined, + }); } }; @@ -93,7 +80,7 @@ const EnvPanel: React.FC = () => { const isApiKey = (key: string) => /key|token|secret|password/i.test(key); - if (loading) { + if (isLoading) { return (
@@ -168,9 +155,13 @@ const EnvPanel: React.FC = () => { {t("openclaw.env.add")}
-
diff --git a/src/components/openclaw/ToolsPanel.tsx b/src/components/openclaw/ToolsPanel.tsx index 58830296f..b2ee74cd2 100644 --- a/src/components/openclaw/ToolsPanel.tsx +++ b/src/components/openclaw/ToolsPanel.tsx @@ -1,8 +1,9 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Plus, Trash2, Save } from "lucide-react"; import { toast } from "sonner"; -import { openclawApi } from "@/lib/api/openclaw"; +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"; @@ -19,34 +20,22 @@ 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([]); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - - const loadTools = useCallback(async () => { - try { - setLoading(true); - const tools = await openclawApi.getTools(); - setConfig(tools); - setAllowList(tools.allow ?? []); - setDenyList(tools.deny ?? []); - } catch (err) { - toast.error(t("openclaw.tools.loadFailed")); - console.error("Failed to load tools config:", err); - } finally { - setLoading(false); - } - }, [t]); useEffect(() => { - void loadTools(); - }, [loadTools]); + if (toolsData) { + setConfig(toolsData); + setAllowList(toolsData.allow ?? []); + setDenyList(toolsData.deny ?? []); + } + }, [toolsData]); const handleSave = async () => { try { - setSaving(true); const { profile, allow, deny, ...other } = config; const newConfig: OpenClawToolsConfig = { ...other, @@ -54,14 +43,13 @@ const ToolsPanel: React.FC = () => { allow: allowList.filter((s) => s.trim()), deny: denyList.filter((s) => s.trim()), }; - await openclawApi.setTools(newConfig); + await saveToolsMutation.mutateAsync(newConfig); toast.success(t("openclaw.tools.saveSuccess")); - await loadTools(); - } catch (err) { - toast.error(t("openclaw.tools.saveFailed")); - console.error("Failed to save tools config:", err); - } finally { - setSaving(false); + } catch (error) { + const detail = extractErrorMessage(error); + toast.error(t("openclaw.tools.saveFailed"), { + description: detail || undefined, + }); } }; @@ -82,7 +70,7 @@ const ToolsPanel: React.FC = () => { setList(list.filter((_, i) => i !== index)); }; - if (loading) { + if (isLoading) { return (
@@ -192,9 +180,13 @@ const ToolsPanel: React.FC = () => { {/* Save button */}
-
diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index 8c4f635cc..58111cba1 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -19,8 +19,12 @@ import { useQuery } from "@tanstack/react-query"; import type { Provider } from "@/types"; import type { AppId } from "@/lib/api"; import { providersApi } from "@/lib/api/providers"; -import { openclawApi } from "@/lib/api/openclaw"; 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 { @@ -88,11 +92,9 @@ export function ProviderList({ }); // OpenClaw: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig - const { data: openclawLiveIds } = useQuery({ - queryKey: ["openclawLiveProviderIds"], - queryFn: () => providersApi.getOpenClawLiveProviderIds(), - enabled: appId === "openclaw", - }); + const { data: openclawLiveIds } = useOpenClawLiveProviderIds( + appId === "openclaw", + ); // 判断供应商是否已添加到配置(累加模式应用:OpenCode/OpenClaw) const isProviderInConfig = useCallback( @@ -109,11 +111,9 @@ export function ProviderList({ ); // OpenClaw: query default model to determine which provider is default - const { data: openclawDefaultModel } = useQuery({ - queryKey: ["openclawDefaultModel"], - queryFn: () => openclawApi.getDefaultModel(), - enabled: appId === "openclaw", - }); + const { data: openclawDefaultModel } = useOpenClawDefaultModel( + appId === "openclaw", + ); const isProviderDefaultModel = useCallback( (providerId: string): boolean => { diff --git a/src/hooks/useOpenClaw.ts b/src/hooks/useOpenClaw.ts new file mode 100644 index 000000000..384bb0399 --- /dev/null +++ b/src/hooks/useOpenClaw.ts @@ -0,0 +1,128 @@ +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(), + }); +} + +/** + * Query tools section of openclaw.json. + */ +export function useOpenClawTools() { + return useQuery({ + queryKey: openclawKeys.tools, + queryFn: () => openclawApi.getTools(), + }); +} + +/** + * Query agents.defaults section of openclaw.json. + */ +export function useOpenClawAgentsDefaults() { + return useQuery({ + queryKey: openclawKeys.agentsDefaults, + queryFn: () => openclawApi.getAgentsDefaults(), + }); +} + +// ============================================================ +// 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 2f2083df8..42a5c4120 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -17,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) @@ -244,7 +245,7 @@ export function useProviderActions(activeApp: AppId) { try { await openclawApi.setDefaultModel(model); await queryClient.invalidateQueries({ - queryKey: ["openclawDefaultModel"], + queryKey: openclawKeys.defaultModel, }); toast.success( t("notifications.openclawDefaultModelSet", { diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index da83c204f..28efdd570 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(); @@ -185,10 +186,10 @@ export const useSwitchProviderMutation = (appId: AppId) => { } if (appId === "openclaw") { await queryClient.invalidateQueries({ - queryKey: ["openclawLiveProviderIds"], + queryKey: openclawKeys.liveProviderIds, }); await queryClient.invalidateQueries({ - queryKey: ["openclawDefaultModel"], + queryKey: openclawKeys.defaultModel, }); } From 508120617612f324f4134430ae362afdfbabd794 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 8 Feb 2026 17:35:29 +0800 Subject: [PATCH 40/44] fix(openclaw): address code review findings across P0-P3 issues - Add 25 missing i18n keys for OpenClawFormFields in all 3 locales (P0) - Replace key={index} with stable crypto.randomUUID() keys in EnvPanel, ToolsPanel, and OpenClawFormFields to prevent list state bugs (P1) - Exclude openclaw from ProxyToggle/FailoverToggle in App.tsx (P1) - Add merge_additive_config() for openclaw/opencode deep link imports (P1) - Normalize serde(flatten) field naming to `extra` + HashMap (P2) - Add directory existence check in remove_openclaw_provider_from_live (P2) - Remove dead code in import_default_config and openclaw API methods (P2) - Add duplicate key validation in EnvPanel before save (P2) - Add openclawConfigDir to Settings type (P2) - Add staleTime to OpenClaw query hooks (P3) - Fix type-unsafe delete via destructuring in mutations.ts (P3) --- src-tauri/src/deeplink/provider.rs | 45 ++++++++++++ src-tauri/src/openclaw_config.rs | 33 ++++----- src-tauri/src/services/provider/live.rs | 43 +++--------- src/App.tsx | 2 +- src/components/openclaw/EnvPanel.tsx | 15 +++- src/components/openclaw/ToolsPanel.tsx | 69 +++++++++++++------ .../providers/AddProviderDialog.tsx | 1 + .../providers/forms/OpenClawFormFields.tsx | 21 +++++- src/hooks/useOpenClaw.ts | 3 + src/i18n/locales/en.json | 29 +++++++- src/i18n/locales/ja.json | 29 +++++++- src/i18n/locales/zh.json | 29 +++++++- src/lib/api/openclaw.ts | 18 ----- src/lib/query/mutations.ts | 4 +- src/types.ts | 2 + 15 files changed, 241 insertions(+), 102 deletions(-) diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 0528a2e49..cdb0f3e39 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -482,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); @@ -653,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/openclaw_config.rs b/src-tauri/src/openclaw_config.rs index 30a51ef3f..20b18ccf9 100644 --- a/src-tauri/src/openclaw_config.rs +++ b/src-tauri/src/openclaw_config.rs @@ -95,9 +95,9 @@ pub struct OpenClawProviderConfig { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub models: Vec, - /// 其他自定义字段(保留原始配置) + /// Other custom fields (preserve unknown fields) #[serde(flatten)] - pub extra: Map, + pub extra: HashMap, } /// OpenClaw 模型条目 @@ -123,9 +123,9 @@ pub struct OpenClawModelEntry { #[serde(skip_serializing_if = "Option::is_none")] pub context_window: Option, - /// 其他自定义字段(保留未知字段) + /// Other custom fields (preserve unknown fields) #[serde(flatten)] - pub extra: Map, + pub extra: HashMap, } /// OpenClaw 模型成本配置 @@ -137,9 +137,9 @@ pub struct OpenClawModelCost { /// 输出价格(每百万 token) pub output: f64, - /// 其他自定义字段(保留未知字段) + /// Other custom fields (preserve unknown fields) #[serde(flatten)] - pub other: HashMap, + pub extra: HashMap, } /// OpenClaw 默认模型配置(agents.defaults.model) @@ -152,9 +152,9 @@ pub struct OpenClawDefaultModel { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub fallbacks: Vec, - /// 其他自定义字段(保留未知字段) + /// Other custom fields (preserve unknown fields) #[serde(flatten)] - pub other: HashMap, + pub extra: HashMap, } /// OpenClaw 模型目录条目(agents.defaults.models 中的值) @@ -164,9 +164,9 @@ pub struct OpenClawModelCatalogEntry { #[serde(skip_serializing_if = "Option::is_none")] pub alias: Option, - /// 其他自定义字段(保留未知字段) + /// Other custom fields (preserve unknown fields) #[serde(flatten)] - pub other: HashMap, + pub extra: HashMap, } /// OpenClaw agents.defaults 配置 @@ -180,21 +180,22 @@ pub struct OpenClawAgentsDefaults { #[serde(skip_serializing_if = "Option::is_none")] pub models: Option>, - /// 其他自定义字段(保留未知字段) + /// Other custom fields (preserve unknown fields) #[serde(flatten)] - pub other: HashMap, + 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 other: HashMap, + pub extra: HashMap, } // ============================================================================ @@ -513,7 +514,7 @@ pub struct OpenClawToolsConfig { /// Other custom fields (preserve unknown fields) #[serde(flatten)] - pub other: HashMap, + pub extra: HashMap, } /// Read the tools config section @@ -525,7 +526,7 @@ pub fn get_tools_config() -> Result { profile: None, allow: Vec::new(), deny: Vec::new(), - other: HashMap::new(), + extra: HashMap::new(), }); }; diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 2bd279a60..da4e15fa6 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -478,40 +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()? - } - AppType::OpenClaw => { - // OpenClaw uses additive mode - import from live is not the same pattern - use crate::openclaw_config::{get_openclaw_config_path, read_openclaw_config}; - - let config_path = get_openclaw_config_path(); - if !config_path.exists() { - return Err(AppError::localized( - "openclaw.live.missing", - "OpenClaw 配置文件不存在", - "OpenClaw configuration file is missing", - )); - } - - // For OpenClaw, we return the full config - but note that OpenClaw - // uses additive mode, so importing defaults works differently - read_openclaw_config()? + // 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") } }; @@ -762,6 +731,12 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result 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"); diff --git a/src/App.tsx b/src/App.tsx index 384eaa661..1eedef4da 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -970,7 +970,7 @@ function App() { )} {currentView === "providers" && ( <> - {activeApp !== "opencode" && ( + {activeApp !== "opencode" && activeApp !== "openclaw" && ( <>
{ useEffect(() => { if (envData) { const items: EnvEntry[] = Object.entries(envData).map(([key, value]) => ({ + id: crypto.randomUUID(), key, value: String(value ?? ""), })); @@ -34,9 +36,15 @@ const EnvPanel: React.FC = () => { 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; } } @@ -51,7 +59,10 @@ const EnvPanel: React.FC = () => { }; const addEntry = () => { - setEntries((prev) => [...prev, { key: "", value: "", isNew: true }]); + setEntries((prev) => [ + ...prev, + { id: crypto.randomUUID(), key: "", value: "", isNew: true }, + ]); }; const removeEntry = (index: number) => { @@ -103,7 +114,7 @@ const EnvPanel: React.FC = () => { const visible = visibleKeys.has(visibilityId); return ( -
+
{ @@ -23,14 +28,24 @@ const ToolsPanel: React.FC = () => { const { data: toolsData, isLoading } = useOpenClawTools(); const saveToolsMutation = useSaveOpenClawTools(); const [config, setConfig] = useState({}); - const [allowList, setAllowList] = useState([]); - const [denyList, setDenyList] = useState([]); + const [allowList, setAllowList] = useState([]); + const [denyList, setDenyList] = useState([]); useEffect(() => { if (toolsData) { setConfig(toolsData); - setAllowList(toolsData.allow ?? []); - setDenyList(toolsData.deny ?? []); + setAllowList( + (toolsData.allow ?? []).map((v) => ({ + id: crypto.randomUUID(), + value: v, + })), + ); + setDenyList( + (toolsData.deny ?? []).map((v) => ({ + id: crypto.randomUUID(), + value: v, + })), + ); } }, [toolsData]); @@ -40,8 +55,8 @@ const ToolsPanel: React.FC = () => { const newConfig: OpenClawToolsConfig = { ...other, profile: config.profile, - allow: allowList.filter((s) => s.trim()), - deny: denyList.filter((s) => s.trim()), + 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")); @@ -54,20 +69,20 @@ const ToolsPanel: React.FC = () => { }; const updateListItem = ( - list: string[], - setList: React.Dispatch>, + setList: React.Dispatch>, index: number, value: string, ) => { - setList(list.map((item, i) => (i === index ? value : item))); + setList((prev) => + prev.map((item, i) => (i === index ? { ...item, value } : item)), + ); }; const removeListItem = ( - list: string[], - setList: React.Dispatch>, + setList: React.Dispatch>, index: number, ) => { - setList(list.filter((_, i) => i !== index)); + setList((prev) => prev.filter((_, i) => i !== index)); }; if (isLoading) { @@ -113,11 +128,11 @@ const ToolsPanel: React.FC = () => {
{allowList.map((item, index) => ( -
+
- updateListItem(allowList, setAllowList, index, e.target.value) + updateListItem(setAllowList, index, e.target.value) } placeholder={t("openclaw.tools.patternPlaceholder")} className="font-mono text-xs" @@ -126,7 +141,7 @@ const ToolsPanel: React.FC = () => { variant="ghost" size="icon" className="flex-shrink-0 h-9 w-9 text-muted-foreground hover:text-destructive" - onClick={() => removeListItem(allowList, setAllowList, index)} + onClick={() => removeListItem(setAllowList, index)} > @@ -135,7 +150,12 @@ const ToolsPanel: React.FC = () => { @@ -170,7 +190,12 @@ const ToolsPanel: React.FC = () => {
- {/* 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: "关闭", - })} - -
-
- {/* Spacer for alignment with delete button */} -
-
- - {/* Basic Cost row */} -
-
- - - handleCostChange(index, "input", e.target.value) - } - placeholder="3" - /> -
-
- - - handleCostChange(index, "output", e.target.value) - } - placeholder="15" - /> -
- {/* Spacer for alignment */} -
-
-
- {/* Advanced Options (Collapsible) */} - + + {/* 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 */}
- {/* Spacer for alignment */}
-
-

+

{t("openclaw.cacheCostHint", { defaultValue: "缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。", From f974d203a8218b53a1bee65d184d04a924245de8 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 8 Feb 2026 22:26:55 +0800 Subject: [PATCH 42/44] fix(openclaw): remove /v1 from anthropic-messages provider baseUrls Anthropic SDK auto-appends /v1 to baseUrl, so including it in the preset causes double-path (/v1/v1/messages). Affects AiHubMix, DMXAPI, PackyCode, Cubence, and AIGoCode. --- src/config/openclawProviderPresets.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts index 5c0761135..c9fad48dc 100644 --- a/src/config/openclawProviderPresets.ts +++ b/src/config/openclawProviderPresets.ts @@ -263,7 +263,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ websiteUrl: "https://aihubmix.com", apiKeyUrl: "https://aihubmix.com", settingsConfig: { - baseUrl: "https://aihubmix.com/v1", + baseUrl: "https://aihubmix.com", apiKey: "", api: "anthropic-messages", models: [ @@ -307,7 +307,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ websiteUrl: "https://www.dmxapi.cn", apiKeyUrl: "https://www.dmxapi.cn", settingsConfig: { - baseUrl: "https://www.dmxapi.cn/v1", + baseUrl: "https://www.dmxapi.cn", apiKey: "", api: "anthropic-messages", models: [ @@ -435,7 +435,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ websiteUrl: "https://www.packyapi.com", apiKeyUrl: "https://www.packyapi.com/register?aff=cc-switch", settingsConfig: { - baseUrl: "https://www.packyapi.com/v1", + baseUrl: "https://www.packyapi.com", apiKey: "", api: "anthropic-messages", models: [ @@ -480,7 +480,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ websiteUrl: "https://cubence.com", apiKeyUrl: "https://cubence.com/signup?code=CCSWITCH&source=ccs", settingsConfig: { - baseUrl: "https://api.cubence.com/v1", + baseUrl: "https://api.cubence.com", apiKey: "", api: "anthropic-messages", models: [ @@ -526,7 +526,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ websiteUrl: "https://aigocode.com", apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH", settingsConfig: { - baseUrl: "https://api.aigocode.com/v1", + baseUrl: "https://api.aigocode.com", apiKey: "", api: "anthropic-messages", models: [ From dca12b9f7dcaa3b43dbf63001e62716c35dcb00a Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 8 Feb 2026 22:53:57 +0800 Subject: [PATCH 43/44] feat(openclaw): add 13 new provider presets Migrate missing providers from Claude/Codex/Gemini/OpenCode presets: Tier 1 Partners: - RightCode (anthropic-messages) - AICodeMirror (anthropic-messages) - Zhipu GLM en (openai-completions, international) - MiniMax en (openai-completions, international) Tier 2 Chinese Officials: - Kimi For Coding, KAT-Coder, Longcat, DouBaoSeed, BaiLing, Xiaomi MiMo Tier 3 Aggregators: - SiliconFlow, SiliconFlow en, Nvidia --- src/config/openclawProviderPresets.ts | 486 ++++++++++++++++++++++++++ 1 file changed, 486 insertions(+) diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts index c9fad48dc..6eb615797 100644 --- a/src/config/openclawProviderPresets.ts +++ b/src/config/openclawProviderPresets.ts @@ -142,6 +142,46 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ 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", @@ -218,6 +258,44 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ 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", @@ -256,6 +334,222 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ 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 ========== { @@ -428,6 +722,106 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ 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 ========== { @@ -567,6 +961,98 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, }, }, + { + 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 ========== { From 5a17a67b8b7bfc44f7f3298310e8cfce95282743 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 13 Feb 2026 14:40:34 +0800 Subject: [PATCH 44/44] fix: deduplicate OpenCodeModel import after rebase --- src/components/providers/forms/ProviderForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 2cfb91af8..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, @@ -41,7 +42,6 @@ import { } from "@/config/openclawProviderPresets"; import { OpenCodeFormFields } from "./OpenCodeFormFields"; import { OpenClawFormFields } from "./OpenClawFormFields"; -import type { OpenCodeModel, OpenClawModel } from "@/types"; import type { UniversalProviderPreset } from "@/config/universalProviderPresets"; import { applyTemplateValues } from "@/utils/providerConfigUtils"; import { mergeProviderMeta } from "@/utils/providerMetaUtils";