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
)}
+ ) : 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" ? (
+ ) : (
+ <>
+
+
+
+
+ >
)}
-
-