Compare commits

..

2 Commits

Author SHA1 Message Date
YoVinchen 07d00e726e fix(proxy): thinking rectifiers 2026-02-12 22:17:52 +08:00
YoVinchen 579df5ad99 feat(proxy): align thinking rectifiers and resolve clippy warnings
- add thinking budget rectifier flow with single retry on anthropic budget errors

- align thinking signature rectification behavior with adaptive-safe handling

- expose requestThinkingBudget in settings/ui/i18n and default rectifier config to disabled

- fix clippy warnings in model_mapper format args and RectifierConfig default derive
2026-02-12 00:46:42 +08:00
106 changed files with 705 additions and 11465 deletions
+3 -8
View File
@@ -15,11 +15,11 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANG
## ❤️Sponsor
[![MiniMax](assets/partners/banners/minimax-en.jpeg)](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
[![MiniMax](assets/partners/banners/minimax-en.jpg)](https://bit.ly/3Nue8mA)
MiniMax-M2.5 is a SOTA large language model designed for real-world productivity. Trained in a diverse range of complex real-world digital working environments, M2.5 builds upon the coding expertise of M2.1 to extend into general office work, reaching fluency in generating and operating Word, Excel, and Powerpoint files, context switching between diverse software environments, and working across different agent and human teams. Scoring 80.2% on SWE-Bench Verified, 51.3% on Multi-SWE-Bench, and 76.3% on BrowseComp, M2.5 is also more token efficient than previous generations, having been trained to optimize its actions and output through planning.
MiniMax M2.1 is an open-source, SOTA model built for real-world development and agentic workflows. It delivers top-tier performance on major coding benchmarks such as SWE, VIBE, and Multi-SWE. Powered by a 10B active / 230B total MoE architecture, M2.1 enables faster inference, easier deployment, and even local execution. It excels at coding, navigating digital environments, and handling long, multi-step tasks at scale.
[Click](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link) to get an exclusive 12% off the MiniMax Coding Plan!
[Click](https://bit.ly/3Nue8mA) to get an exclusive 12% off the MiniMax Coding Plan!
---
@@ -60,11 +60,6 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
<td>Thanks to AICoding.sh for sponsoring this project! AICoding.sh — Global AI Model API Relay Service at Unbeatable Prices! Claude Code at 19% of original price, GPT at just 1%! Trusted by hundreds of enterprises for cost-effective AI services. Supports Claude Code, GPT, Gemini and major domestic models, with enterprise-grade high concurrency, fast invoicing, and 24/7 dedicated technical support. CC Switch users who register via <a href="https://aicoding.sh/i/CCSWITCH">this link</a> get 10% off their first top-up!</td>
</tr>
<tr>
<td width="180"><a href="https://crazyrouter.com/register?ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
<td>Thanks to Crazyrouter for sponsoring this project! Crazyrouter is a high-performance AI API aggregation platform — one API key for 300+ models including Claude Code, Codex, Gemini CLI, GPT, and more. All through a single OpenAI-compatible endpoint with zero code changes. Features include auto-failover, smart routing, unlimited concurrency, and global low-latency access. <a href="https://crazyrouter.com/register?ref=cc-switch">Register here</a> to get started.</td>
</tr>
</table>
## Screenshots
+3 -8
View File
@@ -15,11 +15,11 @@
## ❤️スポンサー
[![MiniMax](assets/partners/banners/minimax-en.jpeg)](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
[![MiniMax](assets/partners/banners/minimax-en.jpg)](https://bit.ly/3Nue8mA)
MiniMax-M2.5 は、実際の生産性向上のために設計された最先端の大規模言語モデルです。多様で複雑な実環境のデジタルワークスペースでトレーニングされた M2.5 は、M2.1 のコーディング能力をベースに一般的なオフィス業務へと拡張し、Word・Excel・PowerPoint ファイルの生成と操作、多様なソフトウェア環境間のコンテキスト切り替え、異なるエージェントや人間チーム間での協働を流暢にこなします。SWE-Bench Verified で 80.2%、Multi-SWE-Bench で 51.3%、BrowseComp で 76.3% を達成し、計画的な行動と出力の最適化トレーニングにより、前世代よりもトークン効率に優れています。
MiniMax M2.1 は、実務開発とエージェントワークフロー向けに構築されたオープンソースの最先端モデルです。100 億のアクティブパラメータ / 2,300 億の総パラメータを持つ MoE アーキテクチャにより、高速な推論、簡単なデプロイ、ローカル実行にも対応します。SWE、VIBE、Multi-SWE などの主要コーディングベンチマークでトップクラスの性能を発揮し、コーディング、デジタル環境のナビゲーション、大規模な多段階タスクの処理に優れています。
[こちら](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)から MiniMax Coding Plan の限定 12% オフを入手!
[こちら](https://bit.ly/3Nue8mA)から MiniMax Coding Plan の限定 12% オフを入手!
---
@@ -60,11 +60,6 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
<td>AICoding.sh のご支援に感謝します!AICoding.sh —— グローバル AI モデル API 超お得な中継サービス!Claude Code 81% オフ、GPT 99% オフ!数百社の企業に高コストパフォーマンスの AI サービスを提供。Claude Code、GPT、Gemini および国内主要モデルに対応、エンタープライズ級の高同時接続、迅速な請求書発行、24 時間年中無休の専属テクニカルサポート。<a href="https://aicoding.sh/i/CCSWITCH">こちらのリンク</a>から登録した CC Switch ユーザーは、初回チャージ 10% オフ!</td>
</tr>
<tr>
<td width="180"><a href="https://crazyrouter.com/register?ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
<td>Crazyrouter のご支援に感謝します!Crazyrouter は高性能 AI API アグリゲーションプラットフォームです。1 つの API キーで Claude Code、Codex、Gemini CLI、GPT など 300 以上のモデルにアクセス可能。OpenAI 互換エンドポイントでコード変更不要。自動フェイルオーバー、スマートルーティング、無制限同時接続、グローバル低遅延アクセスに対応。<a href="https://crazyrouter.com/register?ref=cc-switch">こちらから登録</a>してすぐにご利用いただけます。</td>
</tr>
</table>
## スクリーンショット
+1 -6
View File
@@ -17,7 +17,7 @@
[![MiniMax](assets/partners/banners/minimax-zh.jpeg)](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)
MiniMax M2.5 在编程、工具调用与搜索、办公等核心生产力场景均达到或刷新行业 SOTA,拥有架构师级代码能力与高效任务拆解能力,推理速度较上一代提升 37%、token 消耗更优;100 token/s 连续工作一小时仅需 1 美金,让复杂 Agent 规模化部署经济可行,已在企业多职能场景深度落地,加速全民 Agent 时代到来
MiniMax M2.x 系列模型是面向实际开发与智能体工作流打造的编码模型,M2.1 基于 100 亿激活 / 2300 亿总参的混合专家架构打造,推理更快、部署更便捷且支持本地运行,在 SWE、VIBE、Multi-SWE 等主流代码评测基准中均表现顶尖,擅长代码开发、数字环境适配及规模化处理长链路多步骤任务
[点击](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)即可领取 MiniMax Coding Plan 专属 88 折优惠!
@@ -61,11 +61,6 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
<td>感谢 AICoding.sh 赞助了本项目!AICoding.sh —— 全球大模型 API 超值中转服务!Claude Code 1.9 折,GPT 0.1 折,已为数百家企业提供高性价比 AI 服务。支持 Claude Code、GPT、Gemini 及国内主流模型,企业级高并发、极速开票、7×24 专属技术支持,通过<a href="https://aicoding.sh/i/CCSWITCH">此链接</a> 注册的 CC Switch 用户,首充可享受九折优惠!</td>
</tr>
<tr>
<td width="180"><a href="https://crazyrouter.com/register?ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
<td>感谢 Crazyrouter 赞助了本项目!Crazyrouter 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 300+ 模型,包括 Claude Code、Codex、Gemini CLI、GPT 等。通过单一 OpenAI 兼容端点实现零代码改动接入。支持自动故障转移、智能路由、无限并发和全球低延迟访问。<a href="https://crazyrouter.com/register?ref=cc-switch">点击这里注册</a>即可开始使用!</td>
</tr>
</table>
## 界面预览
Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

-2
View File
@@ -54,11 +54,9 @@
"@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",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
-42
View File
@@ -50,9 +50,6 @@ 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)
@@ -62,9 +59,6 @@ importers:
'@radix-ui/react-label':
specifier: ^2.1.7
version: 2.1.7(@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-popover':
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)
'@radix-ui/react-scroll-area':
specifier: ^1.2.10
version: 1.2.10(@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)
@@ -1054,19 +1048,6 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-popover@1.1.15':
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
@@ -3909,29 +3890,6 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-popover@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)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@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-focus-guards': 1.1.3(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-focus-scope': 1.1.7(@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-id': 1.1.1(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-popper': 1.2.8(@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-portal': 1.1.9(@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-presence': 1.1.5(@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-primitive': 2.1.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-slot': 1.2.3(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1)
aria-hidden: 1.2.6
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-remove-scroll: 2.7.1(@types/react@18.3.23)(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-popper@1.2.8(@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)':
dependencies:
'@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
-63
View File
@@ -714,7 +714,6 @@ dependencies = [
"futures",
"hyper",
"indexmap 2.11.4",
"json5",
"log",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
@@ -728,7 +727,6 @@ dependencies = [
"serde_json",
"serde_yaml",
"serial_test",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
@@ -748,7 +746,6 @@ dependencies = [
"tower-http 0.5.2",
"url",
"uuid",
"webkit2gtk",
"winreg 0.52.0",
"zip 2.4.2",
]
@@ -2522,17 +2519,6 @@ 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"
@@ -3417,49 +3403,6 @@ 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"
@@ -5943,12 +5886,6 @@ 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"
-5
View File
@@ -61,15 +61,10 @@ rusqlite = { version = "0.31", features = ["bundled", "backup"] }
indexmap = { version = "2", features = ["serde"] }
rust_decimal = "1.33"
uuid = { version = "1.11", features = ["v4"] }
sha2 = "0.10"
json5 = "0.4"
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"
[target.'cfg(target_os = "linux")'.dependencies]
webkit2gtk = { version = "2.0.1", features = ["v2_16"] }
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.52"
+5 -53
View File
@@ -25,7 +25,6 @@ impl McpApps {
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
AppType::OpenClaw => false, // OpenClaw doesn't support MCP
}
}
@@ -36,7 +35,6 @@ impl McpApps {
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore
}
}
@@ -85,7 +83,6 @@ impl SkillApps {
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
AppType::OpenClaw => false, // OpenClaw doesn't support Skills
}
}
@@ -96,7 +93,6 @@ impl SkillApps {
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore
}
}
@@ -129,20 +125,6 @@ impl SkillApps {
apps.set_enabled_for(app, true);
apps
}
/// 从来源标签列表构建启用状态
///
/// 标签与 AppType::as_str() 一致时启用对应应用,
/// 其他标签(如 "agents", "cc-switch")忽略。
pub fn from_labels(labels: &[String]) -> Self {
let mut apps = Self::default();
for label in labels {
if let Ok(app) = label.parse::<AppType>() {
apps.set_enabled_for(&app, true);
}
}
apps
}
}
/// 已安装的 Skillv3.10.0+ 统一结构)
@@ -189,8 +171,6 @@ pub struct UnmanagedSkill {
pub description: Option<String>,
/// 在哪些应用目录中发现(如 ["claude", "codex"]
pub found_in: Vec<String>,
/// 发现路径(首个匹配的完整路径)
pub path: String,
}
/// MCP 服务器定义(v3.7.0 统一结构)
@@ -242,9 +222,6 @@ 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 {
@@ -257,7 +234,6 @@ impl Default for McpRoot {
codex: McpConfig::default(),
gemini: McpConfig::default(),
opencode: McpConfig::default(),
openclaw: McpConfig::default(),
}
}
}
@@ -280,8 +256,6 @@ 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};
@@ -297,7 +271,6 @@ pub enum AppType {
Codex,
Gemini,
OpenCode,
OpenClaw,
}
impl AppType {
@@ -307,16 +280,15 @@ 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, OpenClaw)
/// - Additive mode (true): All providers are written to live config (OpenCode)
pub fn is_additive_mode(&self) -> bool {
matches!(self, AppType::OpenCode | AppType::OpenClaw)
matches!(self, AppType::OpenCode)
}
/// Return an iterator over all app types
@@ -326,7 +298,6 @@ impl AppType {
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
AppType::OpenClaw,
]
.into_iter()
}
@@ -342,11 +313,10 @@ 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, openclaw"),
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw."),
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode。"),
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode."),
)),
}
}
@@ -366,9 +336,6 @@ pub struct CommonConfigSnippets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openclaw: Option<String>,
}
impl CommonConfigSnippets {
@@ -379,7 +346,6 @@ 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(),
}
}
@@ -390,7 +356,6 @@ impl CommonConfigSnippets {
AppType::Codex => self.codex = snippet,
AppType::Gemini => self.gemini = snippet,
AppType::OpenCode => self.opencode = snippet,
AppType::OpenClaw => self.openclaw = snippet,
}
}
}
@@ -431,7 +396,6 @@ 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,
@@ -591,7 +555,6 @@ impl MultiAppConfig {
AppType::Codex => &self.mcp.codex,
AppType::Gemini => &self.mcp.gemini,
AppType::OpenCode => &self.mcp.opencode,
AppType::OpenClaw => &self.mcp.openclaw,
}
}
@@ -602,7 +565,6 @@ 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,
}
}
@@ -617,7 +579,6 @@ 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)
}
@@ -638,7 +599,6 @@ 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);
}
@@ -651,7 +611,6 @@ impl MultiAppConfig {
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
AppType::OpenClaw,
] {
// 复用已有的单应用导入逻辑
if Self::auto_import_prompt_if_exists(self, app)? {
@@ -722,7 +681,6 @@ 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);
@@ -751,18 +709,12 @@ impl MultiAppConfig {
let mut conflicts = Vec::new();
// 收集所有应用的 MCP
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
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 {
-11
View File
@@ -59,15 +59,6 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
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 })
}
}
}
@@ -83,7 +74,6 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
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())
@@ -96,7 +86,6 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
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(),
};
if !config_dir.exists() {
+17 -12
View File
@@ -5,15 +5,10 @@ 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(
@@ -42,15 +37,27 @@ pub async fn import_config_from_file(
state: State<'_, AppState>,
) -> Result<Value, String> {
let db = state.db.clone();
let db_for_sync = db.clone();
let db_for_state = db.clone();
tauri::async_runtime::spawn_blocking(move || {
let path_buf = PathBuf::from(&filePath);
let backup_id = db.import_sql(&path_buf)?;
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}");
// 导入后同步当前供应商到各自的 live 配置
let app_state = AppState::new(db_for_state);
if let Err(err) = ProviderService::sync_current_to_live(&app_state) {
log::warn!("导入后同步 live 配置失败: {err}");
}
Ok::<_, AppError>(success_payload_with_warning(backup_id, warning))
// 重新加载设置到内存缓存,确保导入的设置生效
if let Err(err) = crate::settings::reload_settings() {
log::warn!("导入后重载设置失败: {err}");
}
Ok::<_, AppError>(json!({
"success": true,
"message": "SQL imported successfully",
"backupId": backup_id
}))
})
.await
.map_err(|e| format!("导入配置失败: {e}"))?
@@ -73,8 +80,6 @@ pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<V
.map_err(|e: AppError| e.to_string())
}
// ─── File dialogs ────────────────────────────────────────────
/// 保存文件对话框
#[tauri::command]
pub async fn save_file_dialog<R: tauri::Runtime>(
+35 -183
View File
@@ -313,79 +313,6 @@ fn try_get_version_wsl(_tool: &str, _distro: &str) -> (Option<String>, Option<St
)
}
fn push_unique_path(paths: &mut Vec<std::path::PathBuf>, path: std::path::PathBuf) {
if path.as_os_str().is_empty() {
return;
}
if !paths.iter().any(|existing| existing == &path) {
paths.push(path);
}
}
fn push_env_single_dir(paths: &mut Vec<std::path::PathBuf>, value: Option<std::ffi::OsString>) {
if let Some(raw) = value {
push_unique_path(paths, std::path::PathBuf::from(raw));
}
}
fn extend_from_path_list(
paths: &mut Vec<std::path::PathBuf>,
value: Option<std::ffi::OsString>,
suffix: Option<&str>,
) {
if let Some(raw) = value {
for p in std::env::split_paths(&raw) {
let dir = match suffix {
Some(s) => p.join(s),
None => p,
};
push_unique_path(paths, dir);
}
}
}
/// OpenCode install.sh 路径优先级(见 https://github.com/anomalyco/opencode README:
/// $OPENCODE_INSTALL_DIR > $XDG_BIN_DIR > $HOME/bin > $HOME/.opencode/bin
/// 额外扫描 Go 安装路径(~/go/bin、$GOPATH/*/bin)。
fn opencode_extra_search_paths(
home: &Path,
opencode_install_dir: Option<std::ffi::OsString>,
xdg_bin_dir: Option<std::ffi::OsString>,
gopath: Option<std::ffi::OsString>,
) -> Vec<std::path::PathBuf> {
let mut paths = Vec::new();
push_env_single_dir(&mut paths, opencode_install_dir);
push_env_single_dir(&mut paths, xdg_bin_dir);
if !home.as_os_str().is_empty() {
push_unique_path(&mut paths, home.join("bin"));
push_unique_path(&mut paths, home.join(".opencode").join("bin"));
push_unique_path(&mut paths, home.join("go").join("bin"));
}
extend_from_path_list(&mut paths, gopath, Some("bin"));
paths
}
fn tool_executable_candidates(tool: &str, dir: &Path) -> Vec<std::path::PathBuf> {
#[cfg(target_os = "windows")]
{
vec![
dir.join(format!("{tool}.cmd")),
dir.join(format!("{tool}.exe")),
dir.join(tool),
]
}
#[cfg(not(target_os = "windows"))]
{
vec![dir.join(tool)]
}
}
/// 扫描常见路径查找 CLI
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
use std::process::Command;
@@ -393,99 +320,88 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
let home = dirs::home_dir().unwrap_or_default();
// 常见的安装路径(原生安装优先)
let mut search_paths: Vec<std::path::PathBuf> = Vec::new();
if !home.as_os_str().is_empty() {
push_unique_path(&mut search_paths, home.join(".local/bin"));
push_unique_path(&mut search_paths, home.join(".npm-global/bin"));
push_unique_path(&mut search_paths, home.join("n/bin"));
push_unique_path(&mut search_paths, home.join(".volta/bin"));
}
let mut search_paths: Vec<std::path::PathBuf> = vec![
home.join(".local/bin"), // Native install (official recommended)
home.join(".npm-global/bin"),
home.join("n/bin"), // n version manager
home.join(".volta/bin"), // Volta package manager
];
#[cfg(target_os = "macos")]
{
push_unique_path(
&mut search_paths,
std::path::PathBuf::from("/opt/homebrew/bin"),
);
push_unique_path(
&mut search_paths,
std::path::PathBuf::from("/usr/local/bin"),
);
search_paths.push(std::path::PathBuf::from("/opt/homebrew/bin"));
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
}
#[cfg(target_os = "linux")]
{
push_unique_path(
&mut search_paths,
std::path::PathBuf::from("/usr/local/bin"),
);
push_unique_path(&mut search_paths, std::path::PathBuf::from("/usr/bin"));
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
search_paths.push(std::path::PathBuf::from("/usr/bin"));
}
#[cfg(target_os = "windows")]
{
if let Some(appdata) = dirs::data_dir() {
push_unique_path(&mut search_paths, appdata.join("npm"));
search_paths.push(appdata.join("npm"));
}
push_unique_path(
&mut search_paths,
std::path::PathBuf::from("C:\\Program Files\\nodejs"),
);
search_paths.push(std::path::PathBuf::from("C:\\Program Files\\nodejs"));
}
// 添加 fnm 路径支持
let fnm_base = home.join(".local/state/fnm_multishells");
if fnm_base.exists() {
if let Ok(entries) = std::fs::read_dir(&fnm_base) {
for entry in entries.flatten() {
let bin_path = entry.path().join("bin");
if bin_path.exists() {
push_unique_path(&mut search_paths, bin_path);
search_paths.push(bin_path);
}
}
}
}
// 扫描 nvm 目录下的所有 node 版本
let nvm_base = home.join(".nvm/versions/node");
if nvm_base.exists() {
if let Ok(entries) = std::fs::read_dir(&nvm_base) {
for entry in entries.flatten() {
let bin_path = entry.path().join("bin");
if bin_path.exists() {
push_unique_path(&mut search_paths, bin_path);
search_paths.push(bin_path);
}
}
}
}
// 添加 Go 路径支持 (opencode 使用 go install 安装)
if tool == "opencode" {
let extra_paths = opencode_extra_search_paths(
&home,
std::env::var_os("OPENCODE_INSTALL_DIR"),
std::env::var_os("XDG_BIN_DIR"),
std::env::var_os("GOPATH"),
);
for path in extra_paths {
push_unique_path(&mut search_paths, path);
search_paths.push(home.join("go/bin")); // go install 默认路径
if let Ok(gopath) = std::env::var("GOPATH") {
search_paths.push(std::path::PathBuf::from(gopath).join("bin"));
}
}
let current_path = std::env::var("PATH").unwrap_or_default();
// 在每个路径中查找工具
for path in &search_paths {
#[cfg(target_os = "windows")]
let new_path = format!("{};{}", path.display(), current_path);
let tool_path = if cfg!(target_os = "windows") {
path.join(format!("{tool}.cmd"))
} else {
path.join(tool)
};
#[cfg(not(target_os = "windows"))]
let new_path = format!("{}:{}", path.display(), current_path);
if tool_path.exists() {
// 构建 PATH 环境变量,确保 node 可被找到
let current_path = std::env::var("PATH").unwrap_or_default();
for tool_path in tool_executable_candidates(tool, path) {
if !tool_path.exists() {
continue;
}
#[cfg(target_os = "windows")]
let new_path = format!("{};{}", path.display(), current_path);
#[cfg(not(target_os = "windows"))]
let new_path = format!("{}:{}", path.display(), current_path);
#[cfg(target_os = "windows")]
let output = {
// 使用 cmd /C 包装执行,确保子进程也在隐藏的控制台中运行
Command::new("cmd")
.args(["/C", &format!("\"{}\" --version", tool_path.display())])
.env("PATH", &new_path)
@@ -1055,67 +971,3 @@ pub async fn set_window_theme(window: tauri::Window, theme: String) -> Result<()
window.set_theme(tauri_theme).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn opencode_extra_search_paths_includes_install_and_fallback_dirs() {
let home = PathBuf::from("/home/tester");
let install_dir = Some(std::ffi::OsString::from("/custom/opencode/bin"));
let xdg_bin_dir = Some(std::ffi::OsString::from("/xdg/bin"));
let gopath =
std::env::join_paths([PathBuf::from("/go/path1"), PathBuf::from("/go/path2")]).ok();
let paths = opencode_extra_search_paths(&home, install_dir, xdg_bin_dir, gopath);
assert_eq!(paths[0], PathBuf::from("/custom/opencode/bin"));
assert_eq!(paths[1], PathBuf::from("/xdg/bin"));
assert!(paths.contains(&PathBuf::from("/home/tester/bin")));
assert!(paths.contains(&PathBuf::from("/home/tester/.opencode/bin")));
assert!(paths.contains(&PathBuf::from("/home/tester/go/bin")));
assert!(paths.contains(&PathBuf::from("/go/path1/bin")));
assert!(paths.contains(&PathBuf::from("/go/path2/bin")));
}
#[test]
fn opencode_extra_search_paths_deduplicates_repeated_entries() {
let home = PathBuf::from("/home/tester");
let same_dir = Some(std::ffi::OsString::from("/same/path"));
let paths = opencode_extra_search_paths(&home, same_dir.clone(), same_dir.clone(), None);
let count = paths
.iter()
.filter(|path| **path == PathBuf::from("/same/path"))
.count();
assert_eq!(count, 1);
}
#[cfg(not(target_os = "windows"))]
#[test]
fn tool_executable_candidates_non_windows_uses_plain_binary_name() {
let dir = PathBuf::from("/usr/local/bin");
let candidates = tool_executable_candidates("opencode", &dir);
assert_eq!(candidates, vec![PathBuf::from("/usr/local/bin/opencode")]);
}
#[cfg(target_os = "windows")]
#[test]
fn tool_executable_candidates_windows_includes_cmd_exe_and_plain_name() {
let dir = PathBuf::from("C:\\tools");
let candidates = tool_executable_candidates("opencode", &dir);
assert_eq!(
candidates,
vec![
PathBuf::from("C:\\tools\\opencode.cmd"),
PathBuf::from("C:\\tools\\opencode.exe"),
PathBuf::from("C:\\tools\\opencode"),
]
);
}
}
-7
View File
@@ -9,7 +9,6 @@ mod import_export;
mod mcp;
mod misc;
mod omo;
mod openclaw;
mod plugin;
mod prompt;
mod provider;
@@ -18,10 +17,7 @@ mod session_manager;
mod settings;
pub mod skill;
mod stream_check;
mod sync_support;
mod usage;
mod webdav_sync;
mod workspace;
pub use config::*;
pub use deeplink::*;
@@ -32,7 +28,6 @@ pub use import_export::*;
pub use mcp::*;
pub use misc::*;
pub use omo::*;
pub use openclaw::*;
pub use plugin::*;
pub use prompt::*;
pub use provider::*;
@@ -42,5 +37,3 @@ pub use settings::*;
pub use skill::*;
pub use stream_check::*;
pub use usage::*;
pub use webdav_sync::*;
pub use workspace::*;
-108
View File
@@ -1,108 +0,0 @@
use std::collections::HashMap;
use tauri::State;
use crate::openclaw_config;
use crate::store::AppState;
// ============================================================================
// OpenClaw Provider Commands (migrated from provider.rs)
// ============================================================================
/// Import providers from OpenClaw live config to database.
///
/// OpenClaw uses additive mode — users may already have providers
/// configured in openclaw.json.
#[tauri::command]
pub fn import_openclaw_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
crate::services::provider::import_openclaw_providers_from_live(state.inner())
.map_err(|e| e.to_string())
}
/// Get provider IDs in the OpenClaw live config.
#[tauri::command]
pub fn get_openclaw_live_provider_ids() -> Result<Vec<String>, String> {
openclaw_config::get_providers()
.map(|providers| providers.keys().cloned().collect())
.map_err(|e| e.to_string())
}
// ============================================================================
// Agents Configuration Commands
// ============================================================================
/// Get OpenClaw default model config (agents.defaults.model)
#[tauri::command]
pub fn get_openclaw_default_model() -> Result<Option<openclaw_config::OpenClawDefaultModel>, String>
{
openclaw_config::get_default_model().map_err(|e| e.to_string())
}
/// Set OpenClaw default model config (agents.defaults.model)
#[tauri::command]
pub fn set_openclaw_default_model(
model: openclaw_config::OpenClawDefaultModel,
) -> Result<(), String> {
openclaw_config::set_default_model(&model).map_err(|e| e.to_string())
}
/// Get OpenClaw model catalog/allowlist (agents.defaults.models)
#[tauri::command]
pub fn get_openclaw_model_catalog(
) -> Result<Option<HashMap<String, openclaw_config::OpenClawModelCatalogEntry>>, String> {
openclaw_config::get_model_catalog().map_err(|e| e.to_string())
}
/// Set OpenClaw model catalog/allowlist (agents.defaults.models)
#[tauri::command]
pub fn set_openclaw_model_catalog(
catalog: HashMap<String, openclaw_config::OpenClawModelCatalogEntry>,
) -> Result<(), String> {
openclaw_config::set_model_catalog(&catalog).map_err(|e| e.to_string())
}
/// Get full agents.defaults config (all fields)
#[tauri::command]
pub fn get_openclaw_agents_defaults(
) -> Result<Option<openclaw_config::OpenClawAgentsDefaults>, String> {
openclaw_config::get_agents_defaults().map_err(|e| e.to_string())
}
/// Set full agents.defaults config (all fields)
#[tauri::command]
pub fn set_openclaw_agents_defaults(
defaults: openclaw_config::OpenClawAgentsDefaults,
) -> Result<(), String> {
openclaw_config::set_agents_defaults(&defaults).map_err(|e| e.to_string())
}
// ============================================================================
// Env Configuration Commands
// ============================================================================
/// Get OpenClaw env config (env section of openclaw.json)
#[tauri::command]
pub fn get_openclaw_env() -> Result<openclaw_config::OpenClawEnvConfig, String> {
openclaw_config::get_env_config().map_err(|e| e.to_string())
}
/// Set OpenClaw env config (env section of openclaw.json)
#[tauri::command]
pub fn set_openclaw_env(env: openclaw_config::OpenClawEnvConfig) -> Result<(), String> {
openclaw_config::set_env_config(&env).map_err(|e| e.to_string())
}
// ============================================================================
// Tools Configuration Commands
// ============================================================================
/// Get OpenClaw tools config (tools section of openclaw.json)
#[tauri::command]
pub fn get_openclaw_tools() -> Result<openclaw_config::OpenClawToolsConfig, String> {
openclaw_config::get_tools_config().map_err(|e| e.to_string())
}
/// Set OpenClaw tools config (tools section of openclaw.json)
#[tauri::command]
pub fn set_openclaw_tools(tools: openclaw_config::OpenClawToolsConfig) -> Result<(), String> {
openclaw_config::set_tools_config(&tools).map_err(|e| e.to_string())
}
-4
View File
@@ -318,7 +318,3 @@ pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
.map(|providers| providers.keys().cloned().collect())
.map_err(|e| e.to_string())
}
// ============================================================================
// OpenClaw 专属命令 → 已迁移至 commands/openclaw.rs
// ============================================================================
+2 -66
View File
@@ -2,28 +2,16 @@
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<crate::settings::AppSettings, String> {
Ok(crate::settings::get_settings_for_frontend())
Ok(crate::settings::get_settings())
}
/// 保存设置
#[tauri::command]
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, 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())?;
crate::settings::update_settings(settings).map_err(|e| e.to_string())?;
Ok(true)
}
@@ -66,58 +54,6 @@ pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
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<bool, String> {
-97
View File
@@ -1,97 +0,0 @@
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<Database>) -> Result<(), AppError> {
let app_state = AppState::new(db);
ProviderService::sync_current_to_live(&app_state)?;
settings::reload_settings()?;
Ok(())
}
fn post_sync_warning<E: std::fmt::Display>(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<Result<(), AppError>, String>,
) -> Option<String> {
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<String>) -> 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<String>) -> 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")
);
}
}
-357
View File
@@ -1,357 +0,0 @@
#![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<WebDavSyncSettings, String> {
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<WebDavSyncSettings>,
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<tokio::sync::Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
}
async fn run_with_webdav_lock<T, Fut>(operation: Fut) -> Result<T, AppError>
where
Fut: Future<Output = Result<T, AppError>>,
{
let result = {
let _guard = webdav_sync_mutex().lock().await;
operation.await
};
result
}
fn map_sync_result<T, F>(result: Result<T, AppError>, on_error: F) -> Result<T, String>
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<bool>,
) -> Result<Value, String> {
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<Value, String> {
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<Value, String> {
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<bool>,
) -> Result<Value, String> {
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<Value, String> {
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/");
}
}
-60
View File
@@ -1,60 +0,0 @@
use crate::config::write_text_file;
use crate::openclaw_config::get_openclaw_dir;
/// Allowed workspace filenames (whitelist for security)
const ALLOWED_FILES: &[&str] = &[
"AGENTS.md",
"SOUL.md",
"USER.md",
"IDENTITY.md",
"TOOLS.md",
"MEMORY.md",
"HEARTBEAT.md",
"BOOTSTRAP.md",
"BOOT.md",
];
fn validate_filename(filename: &str) -> Result<(), String> {
if !ALLOWED_FILES.contains(&filename) {
return Err(format!(
"Invalid workspace filename: {filename}. Allowed: {}",
ALLOWED_FILES.join(", ")
));
}
Ok(())
}
/// Read an OpenClaw workspace file content.
/// Returns None if the file does not exist.
#[tauri::command]
pub async fn read_workspace_file(filename: String) -> Result<Option<String>, 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}"))
}
+2 -13
View File
@@ -16,15 +16,10 @@ use tempfile::NamedTempFile;
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
impl Database {
/// 导出为 SQLite 兼容的 SQL 文本(内存字符串)
pub fn export_sql_string(&self) -> Result<String, AppError> {
let snapshot = self.snapshot_to_memory()?;
Self::dump_sql(&snapshot)
}
/// 导出为 SQLite 兼容的 SQL 文本
pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
let dump = self.export_sql_string()?;
let snapshot = self.snapshot_to_memory()?;
let dump = Self::dump_sql(&snapshot)?;
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
@@ -43,12 +38,6 @@ 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<String, AppError> {
let sql_content = sql_raw.trim_start_matches('\u{feff}');
Self::validate_cc_switch_sql_export(sql_content)?;
-9
View File
@@ -297,15 +297,6 @@ 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");
-4
View File
@@ -175,10 +175,6 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
"codex" => apps.codex = true,
"gemini" => apps.gemini = true,
"opencode" => apps.opencode = true,
"openclaw" => {
// OpenClaw doesn't support MCP, ignore silently
log::debug!("OpenClaw doesn't support MCP, ignoring in apps parameter");
}
other => {
return Err(AppError::InvalidInput(format!(
"Invalid app in 'apps': {other}"
-75
View File
@@ -146,7 +146,6 @@ 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
@@ -392,35 +391,6 @@ 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
// =============================================================================
@@ -482,10 +452,6 @@ 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);
@@ -657,47 +623,6 @@ 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<String> {
// Try to find base_url in model_providers section
+1 -51
View File
@@ -13,7 +13,6 @@ mod gemini_config;
mod gemini_mcp;
mod init_status;
mod mcp;
mod openclaw_config;
mod opencode_config;
mod panic_hook;
mod prompt;
@@ -501,7 +500,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::warn!("○ Failed to import OpenCode providers: {e}"),
Err(e) => log::debug!("○ Failed to import OpenCode providers: {e}"),
}
// 2.2 OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入)
@@ -526,17 +525,6 @@ pub fn run() {
}
}
// 2.3 OpenClaw 供应商导入(累加式模式,需特殊处理)
// OpenClaw 与 OpenCode 类似:配置文件中可同时存在多个供应商
// 需要遍历 models.providers 字段下的每个供应商并导入
match crate::services::provider::import_openclaw_providers_from_live(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} OpenClaw provider(s) from live config");
}
Ok(_) => log::debug!("○ No OpenClaw providers found to import"),
Err(e) => log::warn!("○ Failed to import OpenClaw providers: {e}"),
}
// 3. 导入 MCP 服务器配置(表空时触发)
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
log::info!("MCP table empty, importing from live configurations...");
@@ -582,8 +570,6 @@ 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,
@@ -782,21 +768,6 @@ pub fn run() {
restore_proxy_state_on_startup(&state).await;
});
// Linux: 禁用 WebKitGTK 硬件加速,防止 EGL 初始化失败导致白屏
#[cfg(target_os = "linux")]
{
if let Some(window) = app.get_webview_window("main") {
let _ = window.with_webview(|webview| {
use webkit2gtk::{WebViewExt, SettingsExt, HardwareAccelerationPolicy};
let wk_webview = webview.inner();
if let Some(settings) = WebViewExt::settings(&wk_webview) {
SettingsExt::set_hardware_acceleration_policy(&settings, HardwareAccelerationPolicy::Never);
log::info!("已禁用 WebKitGTK 硬件加速");
}
});
}
}
// 静默启动:根据设置决定是否显示主窗口
let settings = crate::settings::get_settings();
if let Some(window) = app.get_webview_window("main") {
@@ -900,11 +871,6 @@ 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,
@@ -1006,19 +972,6 @@ pub fn run() {
// OpenCode specific
commands::import_opencode_providers_from_live,
commands::get_opencode_live_provider_ids,
// OpenClaw specific
commands::import_openclaw_providers_from_live,
commands::get_openclaw_live_provider_ids,
commands::get_openclaw_default_model,
commands::set_openclaw_default_model,
commands::get_openclaw_model_catalog,
commands::set_openclaw_model_catalog,
commands::get_openclaw_agents_defaults,
commands::set_openclaw_agents_defaults,
commands::get_openclaw_env,
commands::set_openclaw_env,
commands::get_openclaw_tools,
commands::set_openclaw_tools,
// Global upstream proxy
commands::get_global_proxy_url,
commands::set_global_proxy_url,
@@ -1031,9 +984,6 @@ 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
-546
View File
@@ -1,546 +0,0 @@
//! OpenClaw 配置文件读写模块
//!
//! 处理 `~/.openclaw/openclaw.json` 配置文件的读写操作(JSON5 格式)。
//! OpenClaw 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。
//!
//! ## 配置文件格式
//!
//! ```json5
//! {
//! // 模型供应商配置(映射为 CC Switch 的"供应商"
//! models: {
//! mode: "merge",
//! providers: {
//! "custom-provider": {
//! baseUrl: "https://api.example.com/v1",
//! apiKey: "${API_KEY}",
//! api: "openai-completions",
//! models: [{ id: "model-id", name: "Model Name" }]
//! }
//! }
//! },
//! // 环境变量配置
//! env: {
//! ANTHROPIC_API_KEY: "sk-...",
//! vars: { ... }
//! },
//! // Agent 默认模型配置
//! agents: {
//! defaults: {
//! model: {
//! primary: "provider/model",
//! fallbacks: ["provider2/model2"]
//! }
//! }
//! }
//! }
//! ```
use crate::config::write_json_file;
use crate::error::AppError;
use crate::settings::get_openclaw_override_dir;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::collections::HashMap;
use std::path::PathBuf;
// ============================================================================
// Path Functions
// ============================================================================
/// 获取 OpenClaw 配置目录
///
/// 默认路径: `~/.openclaw/`
/// 可通过 settings.openclaw_config_dir 覆盖
pub fn get_openclaw_dir() -> PathBuf {
if let Some(override_dir) = get_openclaw_override_dir() {
return override_dir;
}
// 所有平台统一使用 ~/.openclaw
dirs::home_dir()
.map(|h| h.join(".openclaw"))
.unwrap_or_else(|| PathBuf::from(".openclaw"))
}
/// 获取 OpenClaw 配置文件路径
///
/// 返回 `~/.openclaw/openclaw.json`
pub fn get_openclaw_config_path() -> PathBuf {
get_openclaw_dir().join("openclaw.json")
}
// ============================================================================
// Type Definitions
// ============================================================================
/// OpenClaw 供应商配置(对应 models.providers 中的条目)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenClawProviderConfig {
/// API 基础 URL
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
/// API Key(支持环境变量引用 ${VAR_NAME}
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
/// API 类型(如 "openai-completions", "anthropic" 等)
#[serde(skip_serializing_if = "Option::is_none")]
pub api: Option<String>,
/// 支持的模型列表
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<OpenClawModelEntry>,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
/// OpenClaw 模型条目
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenClawModelEntry {
/// 模型 ID
pub id: String,
/// 模型显示名称
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// 模型别名(用于快捷引用)
#[serde(skip_serializing_if = "Option::is_none")]
pub alias: Option<String>,
/// 模型成本(输入/输出价格)
#[serde(skip_serializing_if = "Option::is_none")]
pub cost: Option<OpenClawModelCost>,
/// 上下文窗口大小
#[serde(skip_serializing_if = "Option::is_none")]
pub context_window: Option<u32>,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
/// OpenClaw 模型成本配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawModelCost {
/// 输入价格(每百万 token)
pub input: f64,
/// 输出价格(每百万 token)
pub output: f64,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
/// 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<String>,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
/// OpenClaw 模型目录条目(agents.defaults.models 中的值)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawModelCatalogEntry {
/// 模型别名(用于 UI 显示)
#[serde(skip_serializing_if = "Option::is_none")]
pub alias: Option<String>,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
/// OpenClaw agents.defaults 配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawAgentsDefaults {
/// 默认模型配置
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<OpenClawDefaultModel>,
/// 模型目录/允许列表(键为 provider/model 格式)
#[serde(skip_serializing_if = "Option::is_none")]
pub models: Option<HashMap<String, OpenClawModelCatalogEntry>>,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
/// OpenClaw agents 顶层配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct OpenClawAgents {
/// 默认配置
#[serde(skip_serializing_if = "Option::is_none")]
pub defaults: Option<OpenClawAgentsDefaults>,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
// ============================================================================
// Core Read/Write Functions
// ============================================================================
/// 读取 OpenClaw 配置文件
///
/// 支持 JSON5 格式,返回完整的配置 JSON 对象
pub fn read_openclaw_config() -> Result<Value, AppError> {
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<Map<String, Value>, 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<IndexMap<String, OpenClawProviderConfig>, AppError> {
let providers = get_providers()?;
let mut result = IndexMap::new();
for (id, value) in providers {
match serde_json::from_value::<OpenClawProviderConfig>(value.clone()) {
Ok(config) => {
result.insert(id, config);
}
Err(e) => {
log::warn!("Failed to parse OpenClaw provider '{id}': {e}");
// Skip invalid providers but continue
}
}
}
Ok(result)
}
/// 设置供应商配置(类型化)
pub fn set_typed_provider(id: &str, config: &OpenClawProviderConfig) -> Result<(), AppError> {
let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?;
set_provider(id, value)
}
// ============================================================================
// Agents Configuration Functions
// ============================================================================
/// 读取默认模型配置(agents.defaults.model
pub fn get_default_model() -> Result<Option<OpenClawDefaultModel>, 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<Option<HashMap<String, OpenClawModelCatalogEntry>>, 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<String, OpenClawModelCatalogEntry>,
) -> Result<(), AppError> {
let mut config = read_openclaw_config()?;
// Ensure agents.defaults path exists, preserving unknown fields
ensure_agents_defaults_path(&mut config);
let catalog_value =
serde_json::to_value(catalog).map_err(|e| AppError::JsonSerialize { source: e })?;
config["agents"]["defaults"]["models"] = catalog_value;
write_openclaw_config(&config)
}
/// Ensure the `agents.defaults` path exists in the config,
/// preserving any existing unknown fields.
fn ensure_agents_defaults_path(config: &mut Value) {
if config.get("agents").is_none() {
config["agents"] = json!({});
}
if config["agents"].get("defaults").is_none() {
config["agents"]["defaults"] = json!({});
}
}
// ============================================================================
// Full Agents Defaults Functions
// ============================================================================
/// Read the full agents.defaults config
pub fn get_agents_defaults() -> Result<Option<OpenClawAgentsDefaults>, AppError> {
let config = read_openclaw_config()?;
let Some(defaults_value) = config.get("agents").and_then(|a| a.get("defaults")) else {
return Ok(None);
};
let defaults = serde_json::from_value(defaults_value.clone())
.map_err(|e| AppError::Config(format!("Failed to parse agents.defaults: {e}")))?;
Ok(Some(defaults))
}
/// Write the full agents.defaults config
pub fn set_agents_defaults(defaults: &OpenClawAgentsDefaults) -> Result<(), AppError> {
let mut config = read_openclaw_config()?;
if config.get("agents").is_none() {
config["agents"] = json!({});
}
let value =
serde_json::to_value(defaults).map_err(|e| AppError::JsonSerialize { source: e })?;
config["agents"]["defaults"] = value;
write_openclaw_config(&config)
}
// ============================================================================
// Env Configuration
// ============================================================================
/// OpenClaw env configuration (env section of openclaw.json)
///
/// Stores environment variables like API keys and custom vars.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawEnvConfig {
/// All environment variable key-value pairs
#[serde(flatten)]
pub vars: HashMap<String, Value>,
}
/// Read the env config section
pub fn get_env_config() -> Result<OpenClawEnvConfig, AppError> {
let config = read_openclaw_config()?;
let Some(env_value) = config.get("env") else {
return Ok(OpenClawEnvConfig {
vars: HashMap::new(),
});
};
serde_json::from_value(env_value.clone())
.map_err(|e| AppError::Config(format!("Failed to parse env config: {e}")))
}
/// Write the env config section
pub fn set_env_config(env: &OpenClawEnvConfig) -> Result<(), AppError> {
let mut config = read_openclaw_config()?;
let value = serde_json::to_value(env).map_err(|e| AppError::JsonSerialize { source: e })?;
config["env"] = value;
write_openclaw_config(&config)
}
// ============================================================================
// Tools Configuration
// ============================================================================
/// OpenClaw tools configuration (tools section of openclaw.json)
///
/// Controls tool permissions with profile-based allow/deny lists.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawToolsConfig {
/// Active permission profile (e.g. "default", "strict", "permissive")
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
/// Allowed tool patterns
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allow: Vec<String>,
/// Denied tool patterns
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deny: Vec<String>,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
/// Read the tools config section
pub fn get_tools_config() -> Result<OpenClawToolsConfig, AppError> {
let config = read_openclaw_config()?;
let Some(tools_value) = config.get("tools") else {
return Ok(OpenClawToolsConfig {
profile: None,
allow: Vec::new(),
deny: Vec::new(),
extra: HashMap::new(),
});
};
serde_json::from_value(tools_value.clone())
.map_err(|e| AppError::Config(format!("Failed to parse tools config: {e}")))
}
/// Write the tools config section
pub fn set_tools_config(tools: &OpenClawToolsConfig) -> Result<(), AppError> {
let mut config = read_openclaw_config()?;
let value = serde_json::to_value(tools).map_err(|e| AppError::JsonSerialize { source: e })?;
config["tools"] = value;
write_openclaw_config(&config)
}
-3
View File
@@ -5,7 +5,6 @@ use crate::codex_config::get_codex_auth_path;
use crate::config::get_claude_settings_path;
use crate::error::AppError;
use crate::gemini_config::get_gemini_dir;
use crate::openclaw_config::get_openclaw_dir;
use crate::opencode_config::get_opencode_dir;
/// 返回指定应用所使用的提示词文件路径。
@@ -15,7 +14,6 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
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 {
@@ -23,7 +21,6 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
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))
-8
View File
@@ -136,10 +136,6 @@ 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
}
}
}
@@ -188,10 +184,6 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
// 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())
}
}
}
-2
View File
@@ -113,8 +113,6 @@ pub struct ProxyTakeoverStatus {
pub claude: bool,
pub codex: bool,
pub gemini: bool,
pub opencode: bool,
pub openclaw: bool,
}
/// API 格式类型(预留,当前不需要格式转换)
-4
View File
@@ -126,10 +126,6 @@ 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(())
-9
View File
@@ -123,11 +123,6 @@ 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(())
}
@@ -153,10 +148,6 @@ 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(())
}
-2
View File
@@ -10,8 +10,6 @@ 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;
+8 -78
View File
@@ -286,22 +286,10 @@ impl OmoService {
let obj = Self::read_jsonc_object(&actual_path)?;
Ok(Self::build_local_file_data_from_obj(
&obj,
actual_path.to_string_lossy().to_string(),
last_modified,
))
}
fn build_local_file_data_from_obj(
obj: &Map<String, Value>,
file_path: String,
last_modified: Option<String>,
) -> OmoLocalFileData {
let agents = obj.get("agents").cloned();
let categories = obj.get("categories").cloned();
let other = Self::extract_other_fields(obj);
let other = Self::extract_other_fields(&obj);
let other_fields = if other.is_empty() {
None
} else {
@@ -309,17 +297,16 @@ impl OmoService {
};
let mut global = OmoGlobalConfig::default();
Self::merge_global_from_obj(obj, &mut global);
global.other_fields = other_fields.clone();
Self::merge_global_from_obj(&obj, &mut global);
OmoLocalFileData {
Ok(OmoLocalFileData {
agents,
categories,
other_fields,
global,
file_path,
file_path: actual_path.to_string_lossy().to_string(),
last_modified,
}
})
}
fn strip_jsonc_comments(input: &str) -> String {
@@ -413,7 +400,7 @@ mod tests {
..Default::default()
};
let agents = Some(serde_json::json!({
"sisyphus": { "model": "claude-opus-4-5" }
"Sisyphus": { "model": "claude-opus-4-5" }
}));
let categories = None;
let other_fields = None;
@@ -424,7 +411,7 @@ mod tests {
assert_eq!(obj["$schema"], "https://example.com/schema.json");
assert_eq!(obj["disabled_agents"], serde_json::json!(["explore"]));
assert!(obj.contains_key("agents"));
assert_eq!(obj["agents"]["sisyphus"]["model"], "claude-opus-4-5");
assert_eq!(obj["agents"]["Sisyphus"]["model"], "claude-opus-4-5");
}
#[test]
@@ -435,7 +422,7 @@ mod tests {
..Default::default()
};
let agents = Some(serde_json::json!({
"sisyphus": { "model": "claude-opus-4-5" }
"Sisyphus": { "model": "claude-opus-4-5" }
}));
let categories = None;
let other_fields = None;
@@ -447,61 +434,4 @@ mod tests {
assert!(!obj.contains_key("disabled_agents"));
assert!(obj.contains_key("agents"));
}
#[test]
fn test_build_local_file_data_keeps_unknown_top_level_fields_in_global() {
let obj = serde_json::json!({
"$schema": "https://example.com/schema.json",
"disabled_agents": ["oracle"],
"agents": {
"sisyphus": { "model": "claude-opus-4-6" }
},
"categories": {
"code": { "model": "gpt-5.3" }
},
"custom_top_level": {
"enabled": true
}
});
let obj_map = obj.as_object().unwrap().clone();
let data = OmoService::build_local_file_data_from_obj(
&obj_map,
"/tmp/oh-my-opencode.jsonc".to_string(),
None,
);
assert_eq!(
data.global.schema_url.as_deref(),
Some("https://example.com/schema.json")
);
assert_eq!(data.global.disabled_agents, vec!["oracle".to_string()]);
assert_eq!(
data.other_fields,
Some(serde_json::json!({
"custom_top_level": { "enabled": true }
}))
);
assert_eq!(data.global.other_fields, data.other_fields);
}
#[test]
fn test_merge_config_ignores_non_object_other_fields() {
let global = OmoGlobalConfig {
other_fields: Some(serde_json::json!(["global_non_object"])),
..Default::default()
};
let agents = None;
let categories = None;
let other_fields = Some(serde_json::json!("profile_non_object"));
let profile_data = (agents, categories, other_fields, true);
let merged = OmoService::merge_config(&global, Some(&profile_data));
let obj = merged.as_object().unwrap();
assert!(!obj.contains_key("0"));
assert!(!obj.contains_key("global_non_object"));
assert!(!obj.contains_key("profile_non_object"));
}
}
+17 -150
View File
@@ -191,48 +191,6 @@ 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::<OpenClawProviderConfig>(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(())
}
@@ -382,21 +340,6 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
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)
}
}
}
@@ -405,12 +348,6 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
/// 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<bool, AppError> {
// 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() {
@@ -478,9 +415,23 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
"config": config_obj
})
}
// 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")
AppType::OpenCode => {
// 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()?
}
};
@@ -658,87 +609,3 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
Ok(imported)
}
/// Import all providers from OpenClaw live config to database
///
/// This imports existing providers from ~/.openclaw/openclaw.json
/// into the CC Switch database. Each provider found will be added to the
/// database with is_current set to false.
pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, AppError> {
use crate::openclaw_config;
let providers = openclaw_config::get_typed_providers()?;
if providers.is_empty() {
return Ok(0);
}
let mut imported = 0;
let existing = state.db.get_all_providers("openclaw")?;
for (id, config) in providers {
// Validate: skip entries with empty id or no models
if id.trim().is_empty() {
log::warn!("Skipping OpenClaw provider with empty id");
continue;
}
if config.models.is_empty() {
log::warn!("Skipping OpenClaw provider '{id}': no models defined");
continue;
}
// Skip if already exists in database
if existing.contains_key(&id) {
log::debug!("OpenClaw provider '{id}' already exists in database, skipping");
continue;
}
// Convert to Value for settings_config
let settings_config = match serde_json::to_value(&config) {
Ok(v) => v,
Err(e) => {
log::warn!("Failed to serialize OpenClaw provider '{id}': {e}");
continue;
}
};
// Determine display name: use first model name if available, otherwise use id
let display_name = config
.models
.first()
.and_then(|m| m.name.clone())
.unwrap_or_else(|| id.clone());
// Create provider
let provider = Provider::with_id(id.clone(), display_name, settings_config, None);
// Save to database
if let Err(e) = state.db.save_provider("openclaw", &provider) {
log::warn!("Failed to import OpenClaw provider '{id}': {e}");
continue;
}
imported += 1;
log::info!("Imported OpenClaw provider '{id}' from live config");
}
Ok(imported)
}
/// Remove an OpenClaw provider from live config
///
/// This removes a specific provider from ~/.openclaw/openclaw.json
/// without affecting other providers in the file.
pub fn remove_openclaw_provider_from_live(provider_id: &str) -> Result<(), AppError> {
use crate::openclaw_config;
// Check if OpenClaw config directory exists
if !openclaw_config::get_openclaw_dir().exists() {
log::debug!("OpenClaw config directory doesn't exist, skipping removal of '{provider_id}'");
return Ok(());
}
openclaw_config::remove_provider(provider_id)?;
log::info!("OpenClaw provider '{provider_id}' removed from live config");
Ok(())
}
+54 -128
View File
@@ -21,8 +21,8 @@ use crate::store::AppState;
// Re-export sub-module functions for external access
pub use live::{
import_default_config, import_openclaw_providers_from_live,
import_opencode_providers_from_live, read_live_settings, sync_current_to_live,
import_default_config, import_opencode_providers_from_live, read_live_settings,
sync_current_to_live,
};
// Internal re-exports (pub(crate))
@@ -30,9 +30,7 @@ pub(crate) use live::sanitize_claude_settings_for_live;
pub(crate) use live::write_live_snapshot;
// Internal re-exports
use live::{
remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live,
};
use live::{remove_opencode_provider_from_live, write_gemini_live};
use usage::validate_usage_script;
/// Provider business logic service
@@ -144,10 +142,10 @@ impl ProviderService {
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
///
/// 对于累加模式应用(OpenCode, OpenClaw),不存在"当前供应商"概念,直接返回空字符串。
/// 对于 OpenCode(累加模式),不存在"当前供应商"概念,直接返回空字符串。
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
// Additive mode apps have no "current" provider concept
if app_type.is_additive_mode() {
// OpenCode uses additive mode - no "current" provider concept
if matches!(app_type, AppType::OpenCode) {
return Ok(String::new());
}
crate::settings::get_effective_current_provider(&state.db, &app_type)
@@ -164,12 +162,10 @@ impl ProviderService {
// Save to database
state.db.save_provider(app_type.as_str(), &provider)?;
// Additive mode apps (OpenCode, OpenClaw) - always write to live config
if app_type.is_additive_mode() {
// OpenCode uses additive mode - always write to live config
if matches!(app_type, AppType::OpenCode) {
// OMO providers use exclusive mode and write to dedicated config file.
if matches!(app_type, AppType::OpenCode)
&& provider.category.as_deref() == Some("omo")
{
if 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);
@@ -205,11 +201,9 @@ impl ProviderService {
// Save to database
state.db.save_provider(app_type.as_str(), &provider)?;
// Additive mode apps (OpenCode, OpenClaw) - always update in live config
if app_type.is_additive_mode() {
if matches!(app_type, AppType::OpenCode)
&& provider.category.as_deref() == Some("omo")
{
// OpenCode uses additive mode - always update in live config
if matches!(app_type, AppType::OpenCode) {
if provider.category.as_deref() == Some("omo") {
let is_omo_current = state
.db
.is_omo_provider_current(app_type.as_str(), &provider.id)?;
@@ -259,48 +253,43 @@ impl ProviderService {
/// Delete a provider
///
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
/// 对于累加模式应用(OpenCode, OpenClaw),可以随时删除任意供应商,同时从 live 配置中移除。
/// 对于 OpenCode(累加模式),可以随时删除任意供应商,同时从 live 配置中移除。
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
// Additive mode apps - no current provider concept
if app_type.is_additive_mode() {
if matches!(app_type, AppType::OpenCode) {
let is_omo = state
// 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
.db
.get_provider_by_id(id, app_type.as_str())?
.and_then(|p| p.category)
.as_deref()
== Some("omo");
.get_all_providers(app_type.as_str())?
.values()
.filter(|p| p.category.as_deref() == Some("omo"))
.count();
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();
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(());
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(());
}
// Remove from database
state.db.delete_provider(app_type.as_str(), id)?;
// Also remove from live config
match app_type {
AppType::OpenCode => remove_opencode_provider_from_live(id)?,
AppType::OpenClaw => remove_openclaw_provider_from_live(id)?,
_ => {} // Should not reach here
}
remove_opencode_provider_from_live(id)?;
return Ok(());
}
@@ -317,7 +306,7 @@ impl ProviderService {
state.db.delete_provider(app_type.as_str(), id)
}
/// Remove provider from live config only (for additive mode apps like OpenCode, OpenClaw)
/// Remove provider from live config only (for additive mode apps like OpenCode)
///
/// Does NOT delete from database - provider remains in the list.
/// This is used when user wants to "remove" a provider from active config
@@ -349,9 +338,7 @@ impl ProviderService {
remove_opencode_provider_from_live(id)?;
}
}
AppType::OpenClaw => {
remove_openclaw_provider_from_live(id)?;
}
// Future: add other additive mode apps here
_ => {
return Err(AppError::Message(format!(
"App {} does not support remove from live config",
@@ -467,25 +454,22 @@ 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)?;
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(&current_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(), &current_provider);
}
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(&current_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(), &current_provider);
}
}
}
_ => {}
}
// Additive mode apps skip setting is_current (no such concept)
if !app_type.is_additive_mode() {
// OpenCode uses additive mode - skip setting is_current (no such concept)
if !matches!(app_type, AppType::OpenCode) {
// Update local settings (device-level, takes priority)
crate::settings::set_current_provider(&app_type, Some(id))?;
@@ -531,7 +515,6 @@ 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),
}
}
@@ -545,7 +528,6 @@ 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),
}
}
@@ -702,27 +684,6 @@ 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<String, AppError> {
// 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.
@@ -900,17 +861,6 @@ 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)
@@ -1082,30 +1032,6 @@ 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))
}
}
}
}
-38
View File
@@ -210,16 +210,11 @@ 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,
})
}
@@ -377,10 +372,6 @@ 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)
@@ -597,9 +588,6 @@ impl ProxyService {
AppType::OpenCode => {
// OpenCode doesn't support proxy features, skip silently
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features, skip silently
}
}
Ok(())
@@ -782,10 +770,6 @@ 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)
@@ -998,10 +982,6 @@ 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(())
@@ -1088,9 +1068,6 @@ impl ProxyService {
AppType::OpenCode => {
// OpenCode doesn't support proxy features, skip silently
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features, skip silently
}
}
Ok(())
@@ -1126,9 +1103,6 @@ impl ProxyService {
AppType::OpenCode => {
// OpenCode doesn't support proxy features, skip silently
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features, skip silently
}
}
Ok(())
@@ -1212,10 +1186,6 @@ impl ProxyService {
// OpenCode doesn't support proxy features
Err("OpenCode 不支持代理功能".to_string())
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features
Err("OpenClaw 不支持代理功能".to_string())
}
}
}
@@ -1237,10 +1207,6 @@ impl ProxyService {
// OpenCode doesn't support proxy takeover
false
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy takeover
false
}
}
}
@@ -1284,10 +1250,6 @@ impl ProxyService {
// OpenCode doesn't support proxy features
Ok(())
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features
Ok(())
}
}
}
+157 -549
View File
@@ -10,7 +10,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::time::timeout;
@@ -159,154 +159,6 @@ pub struct SkillMetadata {
pub description: Option<String>,
}
// ========== ~/.agents/ lock 文件解析 ==========
/// `~/.agents/.skill-lock.json` 文件结构
#[derive(Deserialize)]
struct AgentsLockFile {
skills: HashMap<String, AgentsLockSkill>,
}
/// lock 文件中单个 skill 的信息
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AgentsLockSkill {
source: Option<String>,
source_type: Option<String>,
source_url: Option<String>,
skill_path: Option<String>,
branch: Option<String>,
source_branch: Option<String>,
}
#[derive(Debug, Clone)]
struct LockRepoInfo {
owner: String,
repo: String,
skill_path: Option<String>,
branch: Option<String>,
}
fn normalize_optional_branch(branch: Option<String>) -> Option<String> {
branch.and_then(|b| {
let trimmed = b.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn parse_branch_from_source_url(source_url: Option<&str>) -> Option<String> {
let source_url = source_url?;
let source_url = source_url.trim();
if source_url.is_empty() {
return None;
}
// 支持 https://github.com/owner/repo/tree/<branch>/...
if let Some((_, after_tree)) = source_url.split_once("/tree/") {
let branch = after_tree
.split('/')
.next()
.map(str::trim)
.filter(|s| !s.is_empty())?;
return Some(branch.to_string());
}
// 支持 URL fragment: ...git#branch
if let Some((_, fragment)) = source_url.split_once('#') {
let branch = fragment
.split('&')
.next()
.map(str::trim)
.filter(|s| !s.is_empty())?;
return Some(branch.to_string());
}
// 支持 query: ...?branch=xxx / ?ref=xxx
if let Some((_, query)) = source_url.split_once('?') {
for pair in query.split('&') {
let Some((key, value)) = pair.split_once('=') else {
continue;
};
if matches!(key, "branch" | "ref") {
let branch = value.trim();
if !branch.is_empty() {
return Some(branch.to_string());
}
}
}
}
None
}
/// 获取 `~/.agents/skills/` 目录(存在时返回)
fn get_agents_skills_dir() -> Option<PathBuf> {
dirs::home_dir()
.map(|h| h.join(".agents").join("skills"))
.filter(|p| p.exists())
}
/// 解析 `~/.agents/.skill-lock.json`,返回 skill_name -> 仓库信息
fn parse_agents_lock() -> HashMap<String, LockRepoInfo> {
let path = match dirs::home_dir() {
Some(h) => h.join(".agents").join(".skill-lock.json"),
None => {
log::warn!("无法获取 HOME 目录,跳过解析 agents lock 文件");
return HashMap::new();
}
};
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
log::debug!("未找到 agents lock 文件: {}", path.display());
} else {
log::warn!("读取 agents lock 文件失败 ({}): {}", path.display(), e);
}
return HashMap::new();
}
};
let lock: AgentsLockFile = match serde_json::from_str(&content) {
Ok(l) => l,
Err(e) => {
log::warn!("解析 agents lock 文件失败 ({}): {}", path.display(), e);
return HashMap::new();
}
};
let parsed: HashMap<String, LockRepoInfo> = lock
.skills
.into_iter()
.filter_map(|(name, skill)| {
let source = skill.source?;
if skill.source_type.as_deref() != Some("github") {
return None;
}
let (owner, repo) = source.split_once('/')?;
let branch = normalize_optional_branch(skill.branch)
.or_else(|| normalize_optional_branch(skill.source_branch))
.or_else(|| parse_branch_from_source_url(skill.source_url.as_deref()));
Some((
name,
LockRepoInfo {
owner: owner.to_string(),
repo: repo.to_string(),
skill_path: skill.skill_path,
branch,
},
))
})
.collect();
log::info!(
"agents lock 文件解析完成,共识别 {} 个 github skill",
parsed.len()
);
parsed
}
// ========== SkillService ==========
pub struct SkillService;
@@ -378,11 +230,6 @@ impl SkillService {
return Ok(custom.join("skills"));
}
}
AppType::OpenClaw => {
if let Some(custom) = crate::settings::get_openclaw_override_dir() {
return Ok(custom.join("skills"));
}
}
}
// 默认路径:回退到用户主目录下的标准位置
@@ -397,7 +244,6 @@ 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"),
})
}
@@ -423,25 +269,11 @@ impl SkillService {
) -> Result<InstalledSkill> {
let ssot_dir = Self::get_ssot_dir()?;
// 允许多级目录(如 a/b/c),但必须是安全的相对路径。
let source_rel = Self::sanitize_skill_source_path(&skill.directory).ok_or_else(|| {
anyhow!(format_skill_error(
"INVALID_SKILL_DIRECTORY",
&[("directory", &skill.directory)],
Some("checkZipContent"),
))
})?;
// 安装目录名始终使用最后一段,避免在 SSOT 中创建多级目录。
let install_name = source_rel
// 使用目录最后一段作为安装名
let install_name = Path::new(&skill.directory)
.file_name()
.and_then(|name| Self::sanitize_install_name(&name.to_string_lossy()))
.ok_or_else(|| {
anyhow!(format_skill_error(
"INVALID_SKILL_DIRECTORY",
&[("directory", &skill.directory)],
Some("checkZipContent"),
))
})?;
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| skill.directory.clone());
// 检查数据库中是否已有同名 directory 的 skill(来自其他仓库)
let existing_skills = db.get_all_installed_skills()?;
@@ -520,7 +352,7 @@ impl SkillService {
repo_branch = used_branch;
// 复制到 SSOT
let source = temp_dir.join(&source_rel);
let source = temp_dir.join(&skill.directory);
if !source.exists() {
let _ = fs::remove_dir_all(&temp_dir);
return Err(anyhow!(format_skill_error(
@@ -530,24 +362,7 @@ impl SkillService {
)));
}
let canonical_temp = temp_dir.canonicalize().unwrap_or_else(|_| temp_dir.clone());
let canonical_source = source.canonicalize().map_err(|_| {
anyhow!(format_skill_error(
"SKILL_DIR_NOT_FOUND",
&[("path", &source.display().to_string())],
Some("checkRepoUrl"),
))
})?;
if !canonical_source.starts_with(&canonical_temp) || !canonical_source.is_dir() {
let _ = fs::remove_dir_all(&temp_dir);
return Err(anyhow!(format_skill_error(
"INVALID_SKILL_DIRECTORY",
&[("directory", &skill.directory)],
Some("checkZipContent"),
)));
}
Self::copy_dir_recursive(&canonical_source, &dest)?;
Self::copy_dir_recursive(&source, &dest)?;
let _ = fs::remove_dir_all(&temp_dir);
// 使用实际下载成功的分支,避免 readme_url / repo_branch 与真实分支不一致。
@@ -628,7 +443,12 @@ impl SkillService {
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
// 从所有应用目录删除
for app in AppType::all() {
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
let _ = Self::remove_from_app(&skill.directory, &app);
}
@@ -685,49 +505,73 @@ impl SkillService {
.map(|s| s.directory.clone())
.collect();
// 收集所有待扫描的目录及其来源标签
let mut scan_sources: Vec<(PathBuf, String)> = Vec::new();
for app in AppType::all() {
if let Ok(d) = Self::get_app_skills_dir(&app) {
scan_sources.push((d, app.as_str().to_string()));
}
}
if let Some(agents_dir) = get_agents_skills_dir() {
scan_sources.push((agents_dir, "agents".to_string()));
}
if let Ok(ssot_dir) = Self::get_ssot_dir() {
scan_sources.push((ssot_dir, "cc-switch".to_string()));
}
let mut unmanaged: HashMap<String, UnmanagedSkill> = HashMap::new();
for (scan_dir, label) in &scan_sources {
let entries = match fs::read_dir(scan_dir) {
Ok(e) => e,
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
let app_dir = match Self::get_app_skills_dir(&app) {
Ok(d) => d,
Err(_) => continue,
};
for entry in entries.flatten() {
if !app_dir.exists() {
continue;
}
for entry in fs::read_dir(&app_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let dir_name = entry.file_name().to_string_lossy().to_string();
if dir_name.starts_with('.') || managed_dirs.contains(&dir_name) {
// 跳过隐藏目录(以 . 开头,如 .system)
if dir_name.starts_with('.') {
continue;
}
// 跳过已管理的
if managed_dirs.contains(&dir_name) {
continue;
}
// 检查是否有 SKILL.md
let skill_md = path.join("SKILL.md");
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
let (name, description) = if skill_md.exists() {
match Self::parse_skill_metadata_static(&skill_md) {
Ok(meta) => (
meta.name.unwrap_or_else(|| dir_name.clone()),
meta.description,
),
Err(_) => (dir_name.clone(), None),
}
} else {
(dir_name.clone(), None)
};
// 添加或更新
let app_str = match app {
AppType::Claude => "claude",
AppType::Codex => "codex",
AppType::Gemini => "gemini",
AppType::OpenCode => "opencode",
};
unmanaged
.entry(dir_name.clone())
.and_modify(|s| s.found_in.push(label.clone()))
.and_modify(|s| s.found_in.push(app_str.to_string()))
.or_insert(UnmanagedSkill {
directory: dir_name,
name,
description,
found_in: vec![label.clone()],
path: path.display().to_string(),
found_in: vec![app_str.to_string()],
});
}
}
@@ -743,36 +587,33 @@ impl SkillService {
directories: Vec<String>,
) -> Result<Vec<InstalledSkill>> {
let ssot_dir = Self::get_ssot_dir()?;
let agents_lock = parse_agents_lock();
let mut imported = Vec::new();
// 将 lock 文件中发现的仓库保存到 skill_repos
save_repos_from_lock(db, &agents_lock, directories.iter().map(|s| s.as_str()));
// 收集所有候选搜索目录
let mut search_sources: Vec<(PathBuf, String)> = Vec::new();
for app in AppType::all() {
if let Ok(d) = Self::get_app_skills_dir(&app) {
search_sources.push((d, app.as_str().to_string()));
}
}
if let Some(agents_dir) = get_agents_skills_dir() {
search_sources.push((agents_dir, "agents".to_string()));
}
search_sources.push((ssot_dir.clone(), "cc-switch".to_string()));
for dir_name in directories {
// 在所有候选目录中查找
// 找到源目录(从任一应用目录复制)
let mut source_path: Option<PathBuf> = None;
let mut found_in: Vec<String> = Vec::new();
for (base, label) in &search_sources {
let skill_path = base.join(&dir_name);
if skill_path.exists() {
if source_path.is_none() {
source_path = Some(skill_path);
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
if let Ok(app_dir) = Self::get_app_skills_dir(&app) {
let skill_path = app_dir.join(&dir_name);
if skill_path.exists() {
if source_path.is_none() {
source_path = Some(skill_path);
}
let app_str = match app {
AppType::Claude => "claude",
AppType::Codex => "codex",
AppType::Gemini => "gemini",
AppType::OpenCode => "opencode",
};
found_in.push(app_str.to_string());
}
found_in.push(label.clone());
}
}
@@ -789,25 +630,40 @@ impl SkillService {
// 解析元数据
let skill_md = dest.join("SKILL.md");
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
let (name, description) = if skill_md.exists() {
match Self::parse_skill_metadata_static(&skill_md) {
Ok(meta) => (
meta.name.unwrap_or_else(|| dir_name.clone()),
meta.description,
),
Err(_) => (dir_name.clone(), None),
}
} else {
(dir_name.clone(), None)
};
// 构建启用状态
let apps = SkillApps::from_labels(&found_in);
// 从 lock 文件提取仓库信息
let (id, repo_owner, repo_name, repo_branch, readme_url) =
build_repo_info_from_lock(&agents_lock, &dir_name);
let mut apps = SkillApps::default();
for app_str in &found_in {
match app_str.as_str() {
"claude" => apps.claude = true,
"codex" => apps.codex = true,
"gemini" => apps.gemini = true,
"opencode" => apps.opencode = true,
_ => {}
}
}
// 创建记录
let skill = InstalledSkill {
id,
id: format!("local:{dir_name}"),
name,
description,
directory: dir_name,
repo_owner,
repo_name,
repo_branch,
readme_url,
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
apps,
installed_at: chrono::Utc::now().timestamp(),
};
@@ -1191,79 +1047,6 @@ impl SkillService {
Ok(meta)
}
/// 从 SKILL.md 读取名称和描述,不存在则用目录名兜底
fn read_skill_name_desc(skill_md: &Path, fallback_name: &str) -> (String, Option<String>) {
if skill_md.exists() {
match Self::parse_skill_metadata_static(skill_md) {
Ok(meta) => (
meta.name.unwrap_or_else(|| fallback_name.to_string()),
meta.description,
),
Err(_) => (fallback_name.to_string(), None),
}
} else {
(fallback_name.to_string(), None)
}
}
/// 校验并规范化技能源路径(允许多级目录),拒绝路径穿越和绝对路径
fn sanitize_skill_source_path(raw: &str) -> Option<PathBuf> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let mut normalized = PathBuf::new();
let mut has_component = false;
for component in Path::new(trimmed).components() {
match component {
Component::Normal(name) => {
let segment = name.to_string_lossy().trim().to_string();
if segment.is_empty() || segment == "." || segment == ".." {
return None;
}
normalized.push(segment);
has_component = true;
}
Component::CurDir
| Component::ParentDir
| Component::RootDir
| Component::Prefix(_) => {
return None;
}
}
}
has_component.then_some(normalized)
}
/// 校验并规范化安装目录名(最终落盘目录名,仅单段)
fn sanitize_install_name(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let path = Path::new(trimmed);
let mut components = path.components();
match (components.next(), components.next()) {
(Some(Component::Normal(name)), None) => {
let normalized = name.to_string_lossy().trim().to_string();
if normalized.is_empty()
|| normalized == "."
|| normalized == ".."
|| normalized.starts_with('.')
{
None
} else {
Some(normalized)
}
}
_ => None,
}
}
/// 去重技能列表(基于完整 key,不同仓库的同名 skill 分开显示)
fn deduplicate_discoverable_skills(skills: &mut Vec<DiscoverableSkill>) {
let mut seen = HashMap::new();
@@ -1287,7 +1070,7 @@ impl SkillService {
let _ = temp_dir.keep();
let mut branches = Vec::new();
if !repo.branch.is_empty() && !repo.branch.eq_ignore_ascii_case("HEAD") {
if !repo.branch.is_empty() {
branches.push(repo.branch.as_str());
}
if !branches.contains(&"main") {
@@ -1352,12 +1135,9 @@ impl SkillService {
)));
};
// 第一遍:解压普通文件和目录,收集 symlink 条目
let mut symlinks: Vec<(PathBuf, String)> = Vec::new();
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let file_path = file.name().to_string();
let file_path = file.name();
let relative_path =
if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) {
@@ -1372,12 +1152,7 @@ impl SkillService {
let outpath = dest.join(relative_path);
if file.is_symlink() {
// 读取 symlink 目标路径
let mut target = String::new();
std::io::Read::read_to_string(&mut file, &mut target)?;
symlinks.push((outpath, target.trim().to_string()));
} else if file.is_dir() {
if file.is_dir() {
fs::create_dir_all(&outpath)?;
} else {
if let Some(parent) = outpath.parent() {
@@ -1388,9 +1163,6 @@ impl SkillService {
}
}
// 第二遍:解析 symlink,将目标内容复制到 symlink 位置
Self::resolve_symlinks_in_dir(dest, &symlinks)?;
Ok(())
}
@@ -1413,58 +1185,6 @@ impl SkillService {
Ok(())
}
/// 解析 ZIP 中的符号链接:将目标内容复制到 symlink 位置
///
/// GitHub ZIP 归档保留了 symlink 元数据,解压时可通过 `is_symlink()` 检测。
/// 此方法将 symlink 解析为实际文件/目录内容(而非创建真实 symlink),
/// 以确保跨平台兼容且 skill 内容自包含。
fn resolve_symlinks_in_dir(base_dir: &Path, symlinks: &[(PathBuf, String)]) -> Result<()> {
// 规范化 base_dirmacOS 上 /tmp → /private/tmp,需保持一致)
let canonical_base = base_dir
.canonicalize()
.unwrap_or_else(|_| base_dir.to_path_buf());
for (link_path, target) in symlinks {
// 计算 symlink 的父目录,然后拼接目标的相对路径
let parent = link_path.parent().unwrap_or(base_dir);
let resolved = parent.join(target);
// 规范化路径(解析 .. 等)
let resolved = match resolved.canonicalize() {
Ok(p) => p,
Err(_) => {
log::warn!(
"Symlink 目标不存在,跳过: {} -> {}",
link_path.display(),
target
);
continue;
}
};
// 安全检查:确保目标在 base_dir 内(防止路径穿越)
if !resolved.starts_with(&canonical_base) {
log::warn!(
"Symlink 目标超出仓库范围,跳过: {} -> {}",
link_path.display(),
resolved.display()
);
continue;
}
// 复制目标内容到 symlink 位置
if resolved.is_dir() {
Self::copy_dir_recursive(&resolved, link_path)?;
} else if resolved.is_file() {
if let Some(parent) = link_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&resolved, link_path)?;
}
}
Ok(())
}
// ========== 从 ZIP 文件安装 ==========
/// 从本地 ZIP 文件安装 Skills
@@ -1497,56 +1217,13 @@ impl SkillService {
let ssot_dir = Self::get_ssot_dir()?;
let mut installed = Vec::new();
let existing_skills = db.get_all_installed_skills()?;
let zip_stem = zip_path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string());
for skill_dir in skill_dirs {
// 解析元数据(提前解析,用于确定安装名)
let skill_md = skill_dir.join("SKILL.md");
let meta = if skill_md.exists() {
Self::parse_skill_metadata_static(&skill_md).ok()
} else {
None
};
// 获取目录名称作为安装名
// 当 SKILL.md 在 ZIP 根目录时,skill_dir == temp_dir
// file_name() 会返回临时目录名(如 .tmpDZKGpF),需要回退到其他来源
let install_name = {
let dir_name = skill_dir
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
if skill_dir == temp_dir || dir_name.is_empty() || dir_name.starts_with('.') {
// SKILL.md 在根目录:优先用元数据 name,否则用 ZIP 文件名
meta.as_ref()
.and_then(|m| m.name.as_deref())
.and_then(Self::sanitize_install_name)
.or_else(|| zip_stem.as_deref().and_then(Self::sanitize_install_name))
} else {
Self::sanitize_install_name(&dir_name)
.or_else(|| {
meta.as_ref()
.and_then(|m| m.name.as_deref())
.and_then(Self::sanitize_install_name)
})
.or_else(|| zip_stem.as_deref().and_then(Self::sanitize_install_name))
}
};
let install_name = match install_name {
Some(name) => name,
None => {
let _ = fs::remove_dir_all(&temp_dir);
return Err(anyhow!(format_skill_error(
"INVALID_SKILL_DIRECTORY",
&[("zip", &zip_path.display().to_string())],
Some("checkZipContent"),
)));
}
};
let install_name = skill_dir
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
// 检查是否已有同名 directory 的 skill
let conflict = existing_skills
@@ -1562,12 +1239,18 @@ impl SkillService {
continue;
}
let (name, description) = match meta {
Some(m) => (
m.name.unwrap_or_else(|| install_name.clone()),
m.description,
),
None => (install_name.clone(), None),
// 解析元数据
let skill_md = skill_dir.join("SKILL.md");
let (name, description) = if skill_md.exists() {
match Self::parse_skill_metadata_static(&skill_md) {
Ok(meta) => (
meta.name.unwrap_or_else(|| install_name.clone()),
meta.description,
),
Err(_) => (install_name.clone(), None),
}
} else {
(install_name.clone(), None)
};
// 复制到 SSOT
@@ -1631,8 +1314,6 @@ impl SkillService {
let temp_path = temp_dir.path().to_path_buf();
let _ = temp_dir.keep(); // Keep the directory, we'll clean up later
let mut symlinks: Vec<(PathBuf, String)> = Vec::new();
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let file_path = match file.enclosed_name() {
@@ -1642,11 +1323,7 @@ impl SkillService {
let outpath = temp_path.join(&file_path);
if file.is_symlink() {
let mut target = String::new();
std::io::Read::read_to_string(&mut file, &mut target)?;
symlinks.push((outpath, target.trim().to_string()));
} else if file.is_dir() {
if file.is_dir() {
fs::create_dir_all(&outpath)?;
} else {
if let Some(parent) = outpath.parent() {
@@ -1657,9 +1334,6 @@ impl SkillService {
}
}
// 解析 symlink
Self::resolve_symlinks_in_dir(&temp_path, &symlinks)?;
Ok(temp_path)
}
@@ -1732,109 +1406,38 @@ impl SkillService {
// ========== 迁移支持 ==========
/// 从 lock 文件信息构建 skill 的 ID、仓库字段和 readme URL
///
/// 返回 (id, repo_owner, repo_name, repo_branch, readme_url)
fn build_repo_info_from_lock(
lock: &HashMap<String, LockRepoInfo>,
dir_name: &str,
) -> (
String,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
) {
match lock.get(dir_name) {
Some(info) => {
let branch = info.branch.clone();
let url_branch = branch.clone().unwrap_or_else(|| "HEAD".to_string());
// 优先使用 lock 文件中的 skillPath,否则回退到 dir_name/SKILL.md
let fallback = format!("{dir_name}/SKILL.md");
let doc_path = info.skill_path.as_deref().unwrap_or(&fallback);
let url = Some(SkillService::build_skill_doc_url(
&info.owner,
&info.repo,
&url_branch,
doc_path,
));
(
format!("{}/{}:{dir_name}", info.owner, info.repo),
Some(info.owner.clone()),
Some(info.repo.clone()),
branch,
url,
)
}
None => (format!("local:{dir_name}"), None, None, None, None),
}
}
/// 将 lock 文件中发现的仓库保存到 skill_repos(去重)
fn save_repos_from_lock(
db: &Arc<Database>,
lock: &HashMap<String, LockRepoInfo>,
directories: impl Iterator<Item = impl AsRef<str>>,
) {
let existing_repos: HashSet<(String, String)> = db
.get_skill_repos()
.unwrap_or_default()
.into_iter()
.map(|r| (r.owner, r.name))
.collect();
let mut added = HashSet::new();
for dir_name in directories {
if let Some(info) = lock.get(dir_name.as_ref()) {
let key = (info.owner.clone(), info.repo.clone());
if !existing_repos.contains(&key) && added.insert(key) {
let skill_repo = SkillRepo {
owner: info.owner.clone(),
name: info.repo.clone(),
// 未知分支时使用 HEAD 语义,后续下载会回退到 main/master。
branch: info.branch.clone().unwrap_or_else(|| "HEAD".to_string()),
enabled: true,
};
if let Err(e) = db.save_skill_repo(&skill_repo) {
log::warn!("保存 skill 仓库 {}/{} 失败: {}", info.owner, info.repo, e);
} else {
log::info!(
"从 agents lock 文件发现并添加仓库: {}/{} ({})",
info.owner,
info.repo,
skill_repo.branch
);
}
}
}
}
}
/// 首次启动迁移:扫描应用目录,重建数据库
pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
let ssot_dir = SkillService::get_ssot_dir()?;
let agents_lock = parse_agents_lock();
let mut discovered: HashMap<String, SkillApps> = HashMap::new();
// 扫描各应用目录
for app in AppType::all() {
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
let app_dir = match SkillService::get_app_skills_dir(&app) {
Ok(d) => d,
Err(_) => continue,
};
let entries = match fs::read_dir(&app_dir) {
Ok(e) => e,
Err(_) => continue,
};
if !app_dir.exists() {
continue;
}
for entry in entries.flatten() {
for entry in fs::read_dir(&app_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let dir_name = entry.file_name().to_string_lossy().to_string();
// 跳过隐藏目录(以 . 开头,如 .system)
if dir_name.starts_with('.') {
continue;
}
@@ -1845,6 +1448,7 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
SkillService::copy_dir_recursive(&path, &ssot_path)?;
}
// 记录启用状态
discovered
.entry(dir_name)
.or_default()
@@ -1855,28 +1459,32 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
// 重建数据库
db.clear_skills()?;
// 将 lock 文件中发现的仓库保存到 skill_repos
save_repos_from_lock(db, &agents_lock, discovered.keys());
let mut count = 0;
for (directory, apps) in discovered {
let ssot_path = ssot_dir.join(&directory);
let skill_md = ssot_path.join("SKILL.md");
let (name, description) = SkillService::read_skill_name_desc(&skill_md, &directory);
let (id, repo_owner, repo_name, repo_branch, readme_url) =
build_repo_info_from_lock(&agents_lock, &directory);
let (name, description) = if skill_md.exists() {
match SkillService::parse_skill_metadata_static(&skill_md) {
Ok(meta) => (
meta.name.unwrap_or_else(|| directory.clone()),
meta.description,
),
Err(_) => (directory.clone(), None),
}
} else {
(directory.clone(), None)
};
let skill = InstalledSkill {
id,
id: format!("local:{directory}"),
name,
description,
directory,
repo_owner,
repo_name,
repo_branch,
readme_url,
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
apps,
installed_at: chrono::Utc::now().timestamp(),
};
-28
View File
@@ -240,14 +240,6 @@ 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;
@@ -575,11 +567,6 @@ 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())
}
}
}
@@ -593,21 +580,6 @@ impl StreamCheckService {
models.keys().next().map(|s| s.to_string())
}
fn extract_openclaw_model(provider: &Provider) -> Option<String> {
// 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<String> {
provider
.settings_config
-507
View File
@@ -1,507 +0,0 @@
//! 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<String>)>;
// ─── 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<Url, AppError> {
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<String, AppError> {
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<Item = &str> {
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<u8>,
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<(Vec<u8>, Option<String>)>, 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<Option<String>, 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<bool, AppError> {
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<String> = 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"));
}
}
-649
View File
@@ -1,649 +0,0 @@
//! 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<String>, en: impl Into<String>) -> AppError {
AppError::localized(key, zh, en)
}
fn io_context_localized(
_key: &'static str,
zh: impl Into<String>,
en: impl Into<String>,
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<String, ArtifactMeta>,
snapshot_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ArtifactMeta {
sha256: String,
size: u64,
}
struct LocalSnapshot {
db_sql: Vec<u8>,
skills_zip: Vec<u8>,
manifest_bytes: Vec<u8>,
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<Value, AppError> {
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<Value, AppError> {
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<Option<Value>, 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::<Vec<_>>(),
});
Ok(Some(payload))
}
// ─── Sync status persistence (I3: deduplicated) ─────────────
fn persist_sync_success(
settings: &mut WebDavSyncSettings,
manifest_hash: String,
etag: Option<String>,
) -> 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<F>(
settings: &mut WebDavSyncSettings,
manifest_hash: String,
etag: Option<String>,
persist_fn: F,
) -> bool
where
F: FnOnce(&mut WebDavSyncSettings, String, Option<String>) -> 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<LocalSnapshot, AppError> {
// 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, ArtifactMeta>) -> String {
let parts: Vec<String> = 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<String> {
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<String> {
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::<Vec<_>>().join(" ");
let trimmed = normalized.trim();
if trimmed.is_empty() {
return None;
}
let limited = trimmed.chars().take(MAX_DEVICE_NAME_LEN).collect::<String>();
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<String, ArtifactMeta>,
) -> Result<Vec<u8>, 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<String> {
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<String, AppError> {
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"
);
}
}
@@ -1,346 +0,0 @@
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<SkillsBackup, AppError> {
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<fs::File>,
options: SimpleFileOptions,
visited: &mut HashSet<PathBuf>,
) -> Result<(), AppError> {
let mut entries: Vec<_> = fs::read_dir(current)
.map_err(|e| AppError::io(current, e))?
.collect::<Result<Vec<_>, _>>()
.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<PathBuf>,
) -> 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<PathBuf>) -> Result<bool, AppError> {
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"));
}
}
+1 -210
View File
@@ -1,6 +1,5 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
@@ -34,8 +33,6 @@ pub struct VisibleApps {
pub gemini: bool,
#[serde(default = "default_true")]
pub opencode: bool,
#[serde(default = "default_true")]
pub openclaw: bool,
}
impl Default for VisibleApps {
@@ -45,7 +42,6 @@ impl Default for VisibleApps {
codex: true,
gemini: true,
opencode: true,
openclaw: true,
}
}
}
@@ -58,106 +54,10 @@ impl VisibleApps {
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
AppType::OpenClaw => self.openclaw,
}
}
}
/// WebDAV 同步状态(持久化同步进度信息)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct WebDavSyncStatus {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_sync_at: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_remote_etag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_local_manifest_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_remote_manifest_hash: Option<String>,
}
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`,不随数据库同步。
@@ -198,8 +98,6 @@ pub struct AppSettings {
pub gemini_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openclaw_config_dir: Option<String>,
// ===== 当前供应商 ID(设备级)=====
/// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current
@@ -214,23 +112,12 @@ pub struct AppSettings {
/// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_opencode: Option<String>,
/// 当前 OpenClaw 供应商 ID(本地存储,对 OpenClaw 可能无意义,但保持结构一致)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_openclaw: Option<String>,
// ===== Skill 同步设置 =====
/// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
#[serde(default)]
pub skill_sync_method: SyncMethod,
// ===== WebDAV 同步设置 =====
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webdav_sync: Option<WebDavSyncSettings>,
// ===== WebDAV 备份设置(旧版,保留向后兼容)=====
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webdav_backup: Option<serde_json::Value>,
// ===== 终端设置 =====
/// 首选终端应用(可选,默认使用系统默认终端)
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
@@ -263,15 +150,11 @@ impl Default for AppSettings {
codex_config_dir: None,
gemini_config_dir: None,
opencode_config_dir: None,
openclaw_config_dir: None,
current_provider_claude: None,
current_provider_codex: None,
current_provider_gemini: None,
current_provider_opencode: None,
current_provider_openclaw: None,
skill_sync_method: SyncMethod::default(),
webdav_sync: None,
webdav_backup: None,
preferred_terminal: None,
}
}
@@ -316,26 +199,12 @@ impl AppSettings {
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.openclaw_config_dir = self
.openclaw_config_dir
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.language = self
.language
.as_ref()
.map(|s| s.trim())
.filter(|s| matches!(*s, "en" | "zh" | "ja"))
.map(|s| s.to_string());
if let Some(sync) = &mut self.webdav_sync {
sync.normalize();
if sync.is_empty() {
self.webdav_sync = None;
}
}
}
fn load_from_file() -> Self {
@@ -376,27 +245,7 @@ fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
let json = serde_json::to_string_pretty(&normalized)
.map_err(|e| AppError::JsonSerialize { source: 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))?;
}
fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;
Ok(())
}
@@ -434,15 +283,6 @@ 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)?;
@@ -455,22 +295,6 @@ pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
Ok(())
}
fn mutate_settings<F>(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> {
@@ -515,14 +339,6 @@ pub fn get_opencode_override_dir() -> Option<PathBuf> {
.map(|p| resolve_override_path(p))
}
pub fn get_openclaw_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
.openclaw_config_dir
.as_ref()
.map(|p| resolve_override_path(p))
}
// ===== 当前供应商管理函数 =====
/// 获取指定应用类型的当前供应商 ID(从本地 settings 读取)
@@ -536,7 +352,6 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
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(),
}
}
@@ -552,7 +367,6 @@ 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)
@@ -619,26 +433,3 @@ pub fn get_preferred_terminal() -> Option<String> {
.preferred_terminal
.clone()
}
// ===== WebDAV 同步设置管理函数 =====
/// 获取 WebDAV 同步设置
pub fn get_webdav_sync_settings() -> Option<WebDavSyncSettings> {
settings_store().read().ok()?.webdav_sync.clone()
}
/// 保存 WebDAV 同步设置
pub fn set_webdav_sync_settings(settings: Option<WebDavSyncSettings>) -> 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;
}
})
}
+57 -155
View File
@@ -16,10 +16,6 @@ import {
Download,
FolderArchive,
Search,
FolderOpen,
KeyRound,
Shield,
Cpu,
} from "lucide-react";
import type { Provider, VisibleApps } from "@/types";
import type { EnvConflict } from "@/types/env";
@@ -32,7 +28,6 @@ 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";
@@ -61,10 +56,6 @@ import { McpIcon } from "@/components/BrandIcons";
import { Button } from "@/components/ui/button";
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
import { useDisableCurrentOmo } from "@/lib/query/omo";
import WorkspaceFilesPanel from "@/components/workspace/WorkspaceFilesPanel";
import EnvPanel from "@/components/openclaw/EnvPanel";
import ToolsPanel from "@/components/openclaw/ToolsPanel";
import AgentsDefaultsPanel from "@/components/openclaw/AgentsDefaultsPanel";
type View =
| "providers"
@@ -75,24 +66,14 @@ type View =
| "mcp"
| "agents"
| "universal"
| "sessions"
| "workspace"
| "openclawEnv"
| "openclawTools"
| "openclawAgents";
| "sessions";
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",
"openclaw",
];
const VALID_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"];
const getInitialApp = (): AppId => {
const saved = localStorage.getItem(STORAGE_KEY) as AppId | null;
@@ -113,10 +94,6 @@ const VALID_VIEWS: View[] = [
"agents",
"universal",
"sessions",
"workspace",
"openclawEnv",
"openclawTools",
"openclawAgents",
];
const getInitialView = (): View => {
@@ -146,7 +123,6 @@ function App() {
codex: true,
gemini: true,
opencode: true,
openclaw: true,
};
const getFirstVisibleApp = (): AppId => {
@@ -154,7 +130,6 @@ 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
};
@@ -221,7 +196,6 @@ function App() {
switchProvider,
deleteProvider,
saveUsageScript,
setAsDefaultModel,
} = useProviderActions(activeApp);
const disableOmoMutation = useDisableCurrentOmo();
@@ -449,19 +423,10 @@ 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);
// Invalidate queries to refresh the isInConfig state
if (activeApp === "opencode") {
await queryClient.invalidateQueries({
queryKey: ["opencodeLiveProviderIds"],
});
} else if (activeApp === "openclaw") {
await queryClient.invalidateQueries({
queryKey: openclawKeys.liveProviderIds,
});
}
await queryClient.invalidateQueries({
queryKey: ["opencodeLiveProviderIds"],
});
toast.success(
t("notifications.removeFromConfigSuccess", {
defaultValue: "已从配置移除",
@@ -621,11 +586,7 @@ function App() {
return (
<SkillsPage
ref={skillsPageRef}
initialApp={
activeApp === "opencode" || activeApp === "openclaw"
? "claude"
: activeApp
}
initialApp={activeApp === "opencode" ? "claude" : activeApp}
/>
);
case "mcp":
@@ -648,14 +609,6 @@ function App() {
case "sessions":
return <SessionManagerPage />;
case "workspace":
return <WorkspaceFilesPanel />;
case "openclawEnv":
return <EnvPanel />;
case "openclawTools":
return <ToolsPanel />;
case "openclawAgents":
return <AgentsDefaultsPanel />;
default:
return (
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
@@ -687,7 +640,7 @@ function App() {
setConfirmAction({ provider, action: "delete" })
}
onRemoveFromConfig={
activeApp === "opencode" || activeApp === "openclaw"
activeApp === "opencode"
? (provider) =>
setConfirmAction({ provider, action: "remove" })
: undefined
@@ -702,9 +655,6 @@ function App() {
activeApp === "claude" ? handleOpenTerminal : undefined
}
onCreate={() => setIsAddOpen(true)}
onSetAsDefault={
activeApp === "openclaw" ? setAsDefaultModel : undefined
}
/>
</motion.div>
</AnimatePresence>
@@ -814,11 +764,6 @@ function App() {
defaultValue: "统一供应商",
})}
{currentView === "sessions" && t("sessionManager.title")}
{currentView === "workspace" && t("workspace.title")}
{currentView === "openclawEnv" && t("openclaw.env.title")}
{currentView === "openclawTools" && t("openclaw.tools.title")}
{currentView === "openclawAgents" &&
t("openclaw.agents.title")}
</h1>
</div>
) : (
@@ -970,7 +915,7 @@ function App() {
)}
{currentView === "providers" && (
<>
{activeApp !== "opencode" && activeApp !== "openclaw" && (
{activeApp !== "opencode" && (
<>
<ProxyToggle activeApp={activeApp} />
<div
@@ -997,97 +942,54 @@ function App() {
/>
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
{activeApp === "openclaw" ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("workspace")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("workspace.manage")}
>
<FolderOpen className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("openclawEnv")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("openclaw.env.title")}
>
<KeyRound className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("openclawTools")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("openclaw.tools.title")}
>
<Shield className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("openclawAgents")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("openclaw.agents.title")}
>
<Cpu className="w-4 h-4" />
</Button>
</>
) : (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skills")}
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
"transition-all duration-200 ease-in-out overflow-hidden",
hasSkillsSupport
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
)}
title={t("skills.manage")}
>
<Wrench className="flex-shrink-0 w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("prompts")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("prompts.manage")}
>
<Book className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("sessions")}
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
"transition-all duration-200 ease-in-out overflow-hidden",
hasSessionSupport
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
)}
title={t("sessionManager.title")}
>
<History className="flex-shrink-0 w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("mcp")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("mcp.title")}
>
<McpIcon size={16} />
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skills")}
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
"transition-all duration-200 ease-in-out overflow-hidden",
hasSkillsSupport
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
)}
title={t("skills.manage")}
>
<Wrench className="flex-shrink-0 w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("prompts")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("prompts.manage")}
>
<Book className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("sessions")}
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
"transition-all duration-200 ease-in-out overflow-hidden",
hasSessionSupport
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
)}
title={t("sessionManager.title")}
>
<History className="flex-shrink-0 w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("mcp")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("mcp.title")}
>
<McpIcon size={16} />
</Button>
</div>
<Button
@@ -1103,7 +1005,7 @@ function App() {
</div>
</header>
<main className="flex-1 min-h-0 flex flex-col overflow-y-auto animate-fade-in">
<main className="flex-1 min-h-0 flex flex-col animate-fade-in">
{renderContent()}
</main>
+1 -3
View File
@@ -9,7 +9,7 @@ interface AppSwitcherProps {
compact?: boolean;
}
const ALL_APPS: AppId[] = ["claude", "codex", "gemini", "opencode", "openclaw"];
const ALL_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"];
const STORAGE_KEY = "cc-switch-last-app";
export function AppSwitcher({
@@ -29,14 +29,12 @@ export function AppSwitcher({
codex: "openai",
gemini: "gemini",
opencode: "opencode",
openclaw: "openclaw",
};
const appDisplayName: Record<AppId, string> = {
claude: "Claude",
codex: "Codex",
gemini: "Gemini",
opencode: "OpenCode",
openclaw: "OpenClaw",
};
// Filter apps based on visibility settings (default all visible)
-14
View File
@@ -7,7 +7,6 @@ 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 (
@@ -48,19 +47,6 @@ export function GeminiIcon({ size = 16, className = "" }: IconProps) {
);
}
export function OpenClawIcon({ size = 16, className = "" }: IconProps) {
return (
<img
src={OpenClawSvg}
width={size}
height={size}
className={className}
alt="OpenClaw"
loading="lazy"
/>
);
}
// MCP icon uses inline SVG to support currentColor for hover effects
export function McpIcon({ size = 16, className = "" }: IconProps) {
return (
-18
View File
@@ -66,7 +66,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
codex: boolean;
gemini: boolean;
opencode: boolean;
openclaw: boolean;
}>(() => {
if (initialData?.apps) {
return { ...initialData.apps };
@@ -76,7 +75,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
codex: defaultEnabledApps.includes("codex"),
gemini: defaultEnabledApps.includes("gemini"),
opencode: defaultEnabledApps.includes("opencode"),
openclaw: defaultEnabledApps.includes("openclaw"),
};
});
@@ -563,22 +561,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
{t("mcp.unifiedPanel.apps.gemini")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="enable-opencode"
checked={enabledApps.opencode}
onCheckedChange={(checked: boolean) =>
setEnabledApps({ ...enabledApps, opencode: checked })
}
/>
<label
htmlFor="enable-opencode"
className="text-sm text-foreground cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.opencode")}
</label>
</div>
</div>
</div>
+1 -1
View File
@@ -56,7 +56,7 @@ const UnifiedMcpPanel = React.forwardRef<
}, [serversMap]);
const enabledCounts = useMemo(() => {
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 };
serverEntries.forEach(([_, server]) => {
for (const app of APP_IDS) {
if (server.apps[app]) counts[app]++;
@@ -1,228 +0,0 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Save } from "lucide-react";
import { toast } from "sonner";
import {
useOpenClawAgentsDefaults,
useSaveOpenClawAgentsDefaults,
} from "@/hooks/useOpenClaw";
import { extractErrorMessage } from "@/utils/errorUtils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { OpenClawAgentsDefaults } from "@/types";
const AgentsDefaultsPanel: React.FC = () => {
const { t } = useTranslation();
const { data: agentsData, isLoading } = useOpenClawAgentsDefaults();
const saveAgentsMutation = useSaveOpenClawAgentsDefaults();
const [defaults, setDefaults] = useState<OpenClawAgentsDefaults | null>(null);
const [primaryModel, setPrimaryModel] = useState("");
const [fallbacks, setFallbacks] = useState("");
// Extra known fields from agents.defaults
const [workspace, setWorkspace] = useState("");
const [timeout, setTimeout_] = useState("");
const [contextTokens, setContextTokens] = useState("");
const [maxConcurrent, setMaxConcurrent] = useState("");
useEffect(() => {
// agentsData is undefined while loading, null when config section is absent
if (agentsData === undefined) return;
setDefaults(agentsData);
if (agentsData) {
setPrimaryModel(agentsData.model?.primary ?? "");
setFallbacks((agentsData.model?.fallbacks ?? []).join(", "));
// Extract known extra fields
setWorkspace(String(agentsData.workspace ?? ""));
setTimeout_(String(agentsData.timeout ?? ""));
setContextTokens(String(agentsData.contextTokens ?? ""));
setMaxConcurrent(String(agentsData.maxConcurrent ?? ""));
}
}, [agentsData]);
const handleSave = async () => {
try {
// Preserve all unknown fields from original data
const updated: OpenClawAgentsDefaults = { ...defaults };
// Model configuration
const fallbackList = fallbacks
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (primaryModel.trim()) {
updated.model = {
primary: primaryModel.trim(),
...(fallbackList.length > 0 ? { fallbacks: fallbackList } : {}),
};
}
// Optional fields
if (workspace.trim()) updated.workspace = workspace.trim();
else delete updated.workspace;
// Numeric fields: validate before saving to avoid NaN
const parseNum = (v: string) => {
const n = Number(v);
return !isNaN(n) && isFinite(n) ? n : undefined;
};
const timeoutNum = timeout.trim() ? parseNum(timeout) : undefined;
if (timeoutNum !== undefined) updated.timeout = timeoutNum;
else delete updated.timeout;
const ctxNum = contextTokens.trim() ? parseNum(contextTokens) : undefined;
if (ctxNum !== undefined) updated.contextTokens = ctxNum;
else delete updated.contextTokens;
const concNum = maxConcurrent.trim()
? parseNum(maxConcurrent)
: undefined;
if (concNum !== undefined) updated.maxConcurrent = concNum;
else delete updated.maxConcurrent;
await saveAgentsMutation.mutateAsync(updated);
toast.success(t("openclaw.agents.saveSuccess"));
} catch (error) {
const detail = extractErrorMessage(error);
toast.error(t("openclaw.agents.saveFailed"), {
description: detail || undefined,
});
}
};
if (isLoading) {
return (
<div className="px-6 pt-4 pb-8 flex items-center justify-center min-h-[200px]">
<div className="text-sm text-muted-foreground">
{t("common.loading")}
</div>
</div>
);
}
return (
<div className="px-6 pt-4 pb-8">
<p className="text-sm text-muted-foreground mb-6">
{t("openclaw.agents.description")}
</p>
{/* Model Configuration Card */}
<div className="rounded-xl border border-border bg-card p-5 mb-4">
<h3 className="text-sm font-medium mb-4">
{t("openclaw.agents.modelSection")}
</h3>
<div className="space-y-4">
<div>
<Label className="mb-1.5 block">
{t("openclaw.agents.primaryModel")}
</Label>
<Input
value={primaryModel}
onChange={(e) => setPrimaryModel(e.target.value)}
placeholder="provider/model-id"
className="font-mono text-xs"
/>
<p className="text-xs text-muted-foreground mt-1">
{t("openclaw.agents.primaryModelHint")}
</p>
</div>
<div>
<Label className="mb-1.5 block">
{t("openclaw.agents.fallbackModels")}
</Label>
<Input
value={fallbacks}
onChange={(e) => setFallbacks(e.target.value)}
placeholder="provider/model-a, provider/model-b"
className="font-mono text-xs"
/>
<p className="text-xs text-muted-foreground mt-1">
{t("openclaw.agents.fallbackModelsHint")}
</p>
</div>
</div>
</div>
{/* Runtime Parameters Card */}
<div className="rounded-xl border border-border bg-card p-5 mb-4">
<h3 className="text-sm font-medium mb-4">
{t("openclaw.agents.runtimeSection")}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label className="mb-1.5 block">
{t("openclaw.agents.workspace")}
</Label>
<Input
value={workspace}
onChange={(e) => setWorkspace(e.target.value)}
placeholder="~/projects"
className="font-mono text-xs"
/>
</div>
<div>
<Label className="mb-1.5 block">
{t("openclaw.agents.timeout")}
</Label>
<Input
type="number"
value={timeout}
onChange={(e) => setTimeout_(e.target.value)}
placeholder="300"
className="font-mono text-xs"
/>
</div>
<div>
<Label className="mb-1.5 block">
{t("openclaw.agents.contextTokens")}
</Label>
<Input
type="number"
value={contextTokens}
onChange={(e) => setContextTokens(e.target.value)}
placeholder="200000"
className="font-mono text-xs"
/>
</div>
<div>
<Label className="mb-1.5 block">
{t("openclaw.agents.maxConcurrent")}
</Label>
<Input
type="number"
value={maxConcurrent}
onChange={(e) => setMaxConcurrent(e.target.value)}
placeholder="4"
className="font-mono text-xs"
/>
</div>
</div>
</div>
{/* Save button */}
<div className="flex justify-end">
<Button
size="sm"
onClick={handleSave}
disabled={saveAgentsMutation.isPending}
>
<Save className="w-4 h-4 mr-1" />
{saveAgentsMutation.isPending ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
);
};
export default AgentsDefaultsPanel;
-182
View File
@@ -1,182 +0,0 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Trash2, Save, Eye, EyeOff } from "lucide-react";
import { toast } from "sonner";
import { useOpenClawEnv, useSaveOpenClawEnv } from "@/hooks/useOpenClaw";
import { extractErrorMessage } from "@/utils/errorUtils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { OpenClawEnvConfig } from "@/types";
interface EnvEntry {
id: string;
key: string;
value: string;
isNew?: boolean;
}
const EnvPanel: React.FC = () => {
const { t } = useTranslation();
const { data: envData, isLoading } = useOpenClawEnv();
const saveEnvMutation = useSaveOpenClawEnv();
const [entries, setEntries] = useState<EnvEntry[]>([]);
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(new Set());
useEffect(() => {
if (envData) {
const items: EnvEntry[] = Object.entries(envData).map(([key, value]) => ({
id: crypto.randomUUID(),
key,
value: String(value ?? ""),
}));
setEntries(items.length > 0 ? items : []);
}
}, [envData]);
const handleSave = async () => {
try {
const env: OpenClawEnvConfig = {};
const seen = new Set<string>();
for (const entry of entries) {
const trimmedKey = entry.key.trim();
if (trimmedKey) {
if (seen.has(trimmedKey)) {
toast.error(t("openclaw.env.duplicateKey", { key: trimmedKey }));
return;
}
seen.add(trimmedKey);
env[trimmedKey] = entry.value;
}
}
await saveEnvMutation.mutateAsync(env);
toast.success(t("openclaw.env.saveSuccess"));
} catch (error) {
const detail = extractErrorMessage(error);
toast.error(t("openclaw.env.saveFailed"), {
description: detail || undefined,
});
}
};
const addEntry = () => {
setEntries((prev) => [
...prev,
{ id: crypto.randomUUID(), key: "", value: "", isNew: true },
]);
};
const removeEntry = (index: number) => {
setEntries((prev) => prev.filter((_, i) => i !== index));
};
const updateEntry = (index: number, field: "key" | "value", val: string) => {
setEntries((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, [field]: val } : entry,
),
);
};
const toggleVisibility = (key: string) => {
setVisibleKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
const isApiKey = (key: string) => /key|token|secret|password/i.test(key);
if (isLoading) {
return (
<div className="px-6 pt-4 pb-8 flex items-center justify-center min-h-[200px]">
<div className="text-sm text-muted-foreground">
{t("common.loading")}
</div>
</div>
);
}
return (
<div className="px-6 pt-4 pb-8">
<p className="text-sm text-muted-foreground mb-4">
{t("openclaw.env.description")}
</p>
<div className="space-y-3">
{entries.map((entry, index) => {
const sensitive = isApiKey(entry.key);
const visibilityId = entry.key || `__new_${index}`;
const visible = visibleKeys.has(visibilityId);
return (
<div key={entry.id} className="flex items-center gap-2">
<div className="w-[200px] flex-shrink-0">
<Input
value={entry.key}
onChange={(e) => updateEntry(index, "key", e.target.value)}
placeholder={t("openclaw.env.keyPlaceholder")}
className="font-mono text-xs"
autoFocus={entry.isNew}
/>
</div>
<div className="flex-1 flex items-center gap-1">
<Input
type={sensitive && !visible ? "password" : "text"}
value={entry.value}
onChange={(e) => updateEntry(index, "value", e.target.value)}
placeholder={t("openclaw.env.valuePlaceholder")}
className="font-mono text-xs"
/>
{sensitive && (
<Button
variant="ghost"
size="icon"
className="flex-shrink-0 h-9 w-9 text-muted-foreground"
onClick={() => toggleVisibility(visibilityId)}
>
{visible ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</Button>
)}
</div>
<Button
variant="ghost"
size="icon"
className="flex-shrink-0 h-9 w-9 text-muted-foreground hover:text-destructive"
onClick={() => removeEntry(index)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
);
})}
</div>
<div className="flex items-center gap-2 mt-4">
<Button variant="outline" size="sm" onClick={addEntry}>
<Plus className="w-4 h-4 mr-1" />
{t("openclaw.env.add")}
</Button>
<div className="flex-1" />
<Button
size="sm"
onClick={handleSave}
disabled={saveEnvMutation.isPending}
>
<Save className="w-4 h-4 mr-1" />
{saveEnvMutation.isPending ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
);
};
export default EnvPanel;
-221
View File
@@ -1,221 +0,0 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Trash2, Save } from "lucide-react";
import { toast } from "sonner";
import { useOpenClawTools, useSaveOpenClawTools } from "@/hooks/useOpenClaw";
import { extractErrorMessage } from "@/utils/errorUtils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { OpenClawToolsConfig } from "@/types";
interface ListItem {
id: string;
value: string;
}
const PROFILE_OPTIONS = ["default", "strict", "permissive", "custom"];
const ToolsPanel: React.FC = () => {
const { t } = useTranslation();
const { data: toolsData, isLoading } = useOpenClawTools();
const saveToolsMutation = useSaveOpenClawTools();
const [config, setConfig] = useState<OpenClawToolsConfig>({});
const [allowList, setAllowList] = useState<ListItem[]>([]);
const [denyList, setDenyList] = useState<ListItem[]>([]);
useEffect(() => {
if (toolsData) {
setConfig(toolsData);
setAllowList(
(toolsData.allow ?? []).map((v) => ({
id: crypto.randomUUID(),
value: v,
})),
);
setDenyList(
(toolsData.deny ?? []).map((v) => ({
id: crypto.randomUUID(),
value: v,
})),
);
}
}, [toolsData]);
const handleSave = async () => {
try {
const { profile, allow, deny, ...other } = config;
const newConfig: OpenClawToolsConfig = {
...other,
profile: config.profile,
allow: allowList.map((item) => item.value).filter((s) => s.trim()),
deny: denyList.map((item) => item.value).filter((s) => s.trim()),
};
await saveToolsMutation.mutateAsync(newConfig);
toast.success(t("openclaw.tools.saveSuccess"));
} catch (error) {
const detail = extractErrorMessage(error);
toast.error(t("openclaw.tools.saveFailed"), {
description: detail || undefined,
});
}
};
const updateListItem = (
setList: React.Dispatch<React.SetStateAction<ListItem[]>>,
index: number,
value: string,
) => {
setList((prev) =>
prev.map((item, i) => (i === index ? { ...item, value } : item)),
);
};
const removeListItem = (
setList: React.Dispatch<React.SetStateAction<ListItem[]>>,
index: number,
) => {
setList((prev) => prev.filter((_, i) => i !== index));
};
if (isLoading) {
return (
<div className="px-6 pt-4 pb-8 flex items-center justify-center min-h-[200px]">
<div className="text-sm text-muted-foreground">
{t("common.loading")}
</div>
</div>
);
}
return (
<div className="px-6 pt-4 pb-8">
<p className="text-sm text-muted-foreground mb-6">
{t("openclaw.tools.description")}
</p>
{/* Profile selector */}
<div className="mb-6">
<Label className="mb-2 block">{t("openclaw.tools.profile")}</Label>
<Select
value={config.profile ?? "default"}
onValueChange={(val) =>
setConfig((prev) => ({ ...prev, profile: val }))
}
>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROFILE_OPTIONS.map((opt) => (
<SelectItem key={opt} value={opt}>
{t(`openclaw.tools.profiles.${opt}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Allow list */}
<div className="mb-6">
<Label className="mb-2 block">{t("openclaw.tools.allowList")}</Label>
<div className="space-y-2">
{allowList.map((item, index) => (
<div key={item.id} className="flex items-center gap-2">
<Input
value={item.value}
onChange={(e) =>
updateListItem(setAllowList, index, e.target.value)
}
placeholder={t("openclaw.tools.patternPlaceholder")}
className="font-mono text-xs"
/>
<Button
variant="ghost"
size="icon"
className="flex-shrink-0 h-9 w-9 text-muted-foreground hover:text-destructive"
onClick={() => removeListItem(setAllowList, index)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() =>
setAllowList((prev) => [
...prev,
{ id: crypto.randomUUID(), value: "" },
])
}
>
<Plus className="w-4 h-4 mr-1" />
{t("openclaw.tools.addAllow")}
</Button>
</div>
</div>
{/* Deny list */}
<div className="mb-6">
<Label className="mb-2 block">{t("openclaw.tools.denyList")}</Label>
<div className="space-y-2">
{denyList.map((item, index) => (
<div key={item.id} className="flex items-center gap-2">
<Input
value={item.value}
onChange={(e) =>
updateListItem(setDenyList, index, e.target.value)
}
placeholder={t("openclaw.tools.patternPlaceholder")}
className="font-mono text-xs"
/>
<Button
variant="ghost"
size="icon"
className="flex-shrink-0 h-9 w-9 text-muted-foreground hover:text-destructive"
onClick={() => removeListItem(setDenyList, index)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() =>
setDenyList((prev) => [
...prev,
{ id: crypto.randomUUID(), value: "" },
])
}
>
<Plus className="w-4 h-4 mr-1" />
{t("openclaw.tools.addDeny")}
</Button>
</div>
</div>
{/* Save button */}
<div className="flex justify-end">
<Button
size="sm"
onClick={handleSave}
disabled={saveToolsMutation.isPending}
>
<Save className="w-4 h-4 mr-1" />
{saveToolsMutation.isPending ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
);
};
export default ToolsPanel;
+2 -2
View File
@@ -30,13 +30,13 @@ const PromptFormModal: React.FC<PromptFormModalProps> = ({
}) => {
const { t } = useTranslation();
const appName = t(`apps.${appId}`);
const filenameMap: Record<Exclude<AppId, "openclaw">, string> = {
const filenameMap: Record<AppId, string> = {
claude: "CLAUDE.md",
codex: "AGENTS.md",
gemini: "GEMINI.md",
opencode: "AGENTS.md",
};
const filename = filenameMap[appId as Exclude<AppId, "openclaw">];
const filename = filenameMap[appId];
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [content, setContent] = useState("");
@@ -29,7 +29,6 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
codex: "AGENTS.md",
gemini: "GEMINI.md",
opencode: "AGENTS.md",
openclaw: "AGENTS.md",
};
const filename = filenameMap[appId];
const [name, setName] = useState("");
+4 -28
View File
@@ -17,7 +17,6 @@ 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 {
@@ -25,10 +24,7 @@ interface AddProviderDialogProps {
onOpenChange: (open: boolean) => void;
appId: AppId;
onSubmit: (
provider: Omit<Provider, "id"> & {
providerKey?: string;
suggestedDefaults?: OpenClawSuggestedDefaults;
},
provider: Omit<Provider, "id"> & { providerKey?: string },
) => Promise<void> | void;
}
@@ -39,8 +35,7 @@ export function AddProviderDialog({
onSubmit,
}: AddProviderDialogProps) {
const { t } = useTranslation();
// OpenCode and OpenClaw don't support universal providers
const showUniversalTab = appId !== "opencode" && appId !== "openclaw";
const showUniversalTab = appId !== "opencode";
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
"app-specific",
);
@@ -87,11 +82,7 @@ export function AddProviderDialog({
unknown
>;
// 构造基础提交数据
const providerData: Omit<Provider, "id"> & {
providerKey?: string;
suggestedDefaults?: OpenClawSuggestedDefaults;
} = {
const providerData: Omit<Provider, "id"> & { providerKey?: string } = {
name: values.name.trim(),
notes: values.notes?.trim() || undefined,
websiteUrl: values.websiteUrl?.trim() || undefined,
@@ -102,11 +93,7 @@ export function AddProviderDialog({
...(values.meta ? { meta: values.meta } : {}),
};
// OpenCode/OpenClaw: pass providerKey for ID generation
if (
(appId === "opencode" || appId === "openclaw") &&
values.providerKey
) {
if (appId === "opencode" && values.providerKey) {
providerData.providerKey = values.providerKey;
}
@@ -198,11 +185,6 @@ 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);
@@ -224,11 +206,6 @@ export function AddProviderDialog({
}
}
// OpenClaw: pass suggestedDefaults for model registration
if (appId === "openclaw" && values.suggestedDefaults) {
providerData.suggestedDefaults = values.suggestedDefaults;
}
await onSubmit(providerData);
onOpenChange(false);
},
@@ -309,7 +286,6 @@ export function AddProviderDialog({
</TabsContent>
</Tabs>
) : (
// OpenCode/OpenClaw: directly show form without tabs
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
+7 -41
View File
@@ -10,7 +10,6 @@ import {
Terminal,
TestTube2,
Trash2,
Zap,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -37,9 +36,6 @@ interface ProviderActionsProps {
isAutoFailoverEnabled?: boolean;
isInFailoverQueue?: boolean;
onToggleFailover?: (enabled: boolean) => void;
// OpenClaw: default model
isDefaultModel?: boolean;
onSetAsDefault?: () => void;
}
export function ProviderActions({
@@ -62,20 +58,14 @@ 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";
// 累加模式应用(OpenCode 非 OMO 和 OpenClaw
const isAdditiveMode =
(appId === "opencode" && !isOmo) || appId === "openclaw";
const isOpenCodeMode = appId === "opencode" && !isOmo;
// 故障转移模式下的按钮逻辑(累加模式和 OMO 应用不支持故障转移)
const isFailoverMode =
!isAdditiveMode && !isOmo && isAutoFailoverEnabled && onToggleFailover;
!isOpenCodeMode && !isOmo && isAutoFailoverEnabled && onToggleFailover;
const handleMainButtonClick = () => {
if (isOmo) {
@@ -84,8 +74,7 @@ export function ProviderActions({
} else {
onSwitch();
}
} else if (isAdditiveMode) {
// 累加模式:切换配置状态(添加/移除)
} else if (isOpenCodeMode) {
if (isInConfig) {
if (onRemoveFromConfig) {
onRemoveFromConfig();
@@ -123,16 +112,13 @@ export function ProviderActions({
};
}
// 累加模式(OpenCode 非 OMO / OpenClaw
if (isAdditiveMode) {
if (isOpenCodeMode) {
if (isInConfig) {
return {
disabled: isDefaultModel === true,
disabled: false,
variant: "secondary" as const,
className: cn(
className:
"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: <Minus className="h-4 w-4" />,
text: t("provider.removeFromConfig", { defaultValue: "移除" }),
};
@@ -194,32 +180,12 @@ export function ProviderActions({
const canDelete = isOmo
? !(isLastOmo && isCurrent)
: isAdditiveMode
: isOpenCodeMode
? true
: !isCurrent;
return (
<div className="flex items-center gap-1.5">
{appId === "openclaw" && isInConfig && onSetAsDefault && (
<Button
size="sm"
variant={isDefaultModel ? "secondary" : "default"}
onClick={isDefaultModel ? undefined : onSetAsDefault}
disabled={isDefaultModel}
className={cn(
"w-[4.5rem] px-2.5",
isDefaultModel
? "bg-gray-200 text-muted-foreground dark:bg-gray-700 opacity-60 cursor-not-allowed"
: "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
)}
>
<Zap className="h-4 w-4" />
{isDefaultModel
? t("provider.isDefault", { defaultValue: "默认" })
: t("provider.setAsDefault", { defaultValue: "启用" })}
</Button>
)}
<Button
size="sm"
variant={buttonState.variant}
+2 -19
View File
@@ -48,9 +48,6 @@ interface ProviderCardProps {
isInFailoverQueue?: boolean; // 是否在故障转移队列中
onToggleFailover?: (enabled: boolean) => void; // 切换故障转移队列
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
// OpenClaw: default model
isDefaultModel?: boolean;
onSetAsDefault?: () => void;
}
const extractApiUrl = (provider: Provider, fallbackText: string) => {
@@ -111,9 +108,6 @@ export function ProviderCard({
isInFailoverQueue = false,
onToggleFailover,
activeProviderId,
// OpenClaw: default model
isDefaultModel,
onSetAsDefault,
}: ProviderCardProps) {
const { t } = useTranslation();
@@ -139,10 +133,7 @@ export function ProviderCard({
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
// 获取用量数据以判断是否有多套餐
// 累加模式应用(OpenCode/OpenClaw):使用 isInConfig 代替 isCurrent
const shouldAutoQuery =
appId === "opencode" || appId === "openclaw" ? isInConfig : isCurrent;
const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent;
const autoQueryInterval = shouldAutoQuery
? provider.meta?.usage_script?.autoQueryInterval || 0
: 0;
@@ -185,14 +176,9 @@ export function ProviderCard({
onOpenWebsite(displayUrl);
};
// 判断是否是"当前使用中"的供应商
// - OMO 供应商:使用 isCurrent
// - 累加模式应用(OpenCode 非 OMO / OpenClaw):不存在"当前"概念,始终返回 false
// - 故障转移模式:代理实际使用的供应商(activeProviderId
// - 普通模式:isCurrent
const isActiveProvider = isOmo
? isCurrent
: appId === "opencode" || appId === "openclaw"
: appId === "opencode"
? false
: isAutoFailoverEnabled
? activeProviderId === provider.id
@@ -392,9 +378,6 @@ export function ProviderCard({
isAutoFailoverEnabled={isAutoFailoverEnabled}
isInFailoverQueue={isInFailoverQueue}
onToggleFailover={onToggleFailover}
// OpenClaw: default model
isDefaultModel={isDefaultModel}
onSetAsDefault={onSetAsDefault}
/>
</div>
</div>
+3 -48
View File
@@ -20,11 +20,6 @@ import type { Provider } from "@/types";
import type { AppId } from "@/lib/api";
import { providersApi } from "@/lib/api/providers";
import { useDragSort } from "@/hooks/useDragSort";
import {
useOpenClawLiveProviderIds,
useOpenClawDefaultModel,
} from "@/hooks/useOpenClaw";
// import { useStreamCheck } from "@/hooks/useStreamCheck"; // 测试功能已隐藏
import { ProviderCard } from "@/components/providers/ProviderCard";
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
import {
@@ -56,7 +51,6 @@ interface ProviderListProps {
isProxyRunning?: boolean; // 代理服务运行状态
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管)
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
onSetAsDefault?: (provider: Provider) => void; // OpenClaw: set as default model
}
export function ProviderList({
@@ -77,7 +71,6 @@ export function ProviderList({
isProxyRunning = false,
isProxyTakeover = false,
activeProviderId,
onSetAsDefault,
}: ProviderListProps) {
const { t } = useTranslation();
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
@@ -91,39 +84,14 @@ export function ProviderList({
enabled: appId === "opencode",
});
// OpenClaw: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig
const { data: openclawLiveIds } = useOpenClawLiveProviderIds(
appId === "openclaw",
);
// 判断供应商是否已添加到配置(累加模式应用:OpenCode/OpenClaw
const isProviderInConfig = useCallback(
(providerId: string): boolean => {
if (appId === "opencode") {
return opencodeLiveIds?.includes(providerId) ?? false;
}
if (appId === "openclaw") {
return openclawLiveIds?.includes(providerId) ?? false;
}
return true; // 其他应用始终返回 true
if (appId !== "opencode") return true; // 非 OpenCode 应用始终返回 true
return opencodeLiveIds?.includes(providerId) ?? false;
},
[appId, opencodeLiveIds, openclawLiveIds],
[appId, opencodeLiveIds],
);
// OpenClaw: query default model to determine which provider is default
const { data: openclawDefaultModel } = useOpenClawDefaultModel(
appId === "openclaw",
);
const isProviderDefaultModel = useCallback(
(providerId: string): boolean => {
if (appId !== "openclaw" || !openclawDefaultModel?.primary) return false;
return openclawDefaultModel.primary.startsWith(providerId + "/");
},
[appId, openclawDefaultModel],
);
// 故障转移相关
const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId);
const { data: failoverQueue } = useFailoverQueue(appId);
const addToQueue = useAddToFailoverQueue();
@@ -272,11 +240,6 @@ export function ProviderList({
handleToggleFailover(provider.id, enabled)
}
activeProviderId={activeProviderId}
// OpenClaw: default model
isDefaultModel={isProviderDefaultModel(provider.id)}
onSetAsDefault={
onSetAsDefault ? () => onSetAsDefault(provider) : undefined
}
/>
);
})}
@@ -389,9 +352,6 @@ interface SortableProviderCardProps {
isInFailoverQueue: boolean;
onToggleFailover: (enabled: boolean) => void;
activeProviderId?: string;
// OpenClaw: default model
isDefaultModel?: boolean;
onSetAsDefault?: () => void;
}
function SortableProviderCard({
@@ -419,8 +379,6 @@ function SortableProviderCard({
isInFailoverQueue,
onToggleFailover,
activeProviderId,
isDefaultModel,
onSetAsDefault,
}: SortableProviderCardProps) {
const {
setNodeRef,
@@ -470,9 +428,6 @@ function SortableProviderCard({
isInFailoverQueue={isInFailoverQueue}
onToggleFailover={onToggleFailover}
activeProviderId={activeProviderId}
// OpenClaw: default model
isDefaultModel={isDefaultModel}
onSetAsDefault={onSetAsDefault}
/>
</div>
);
@@ -14,7 +14,6 @@ const ENDPOINT_TIMEOUT_SECS: Record<AppId, number> = {
claude: 8,
gemini: 8,
opencode: 8,
openclaw: 8,
};
interface TestResult {
+89 -267
View File
@@ -12,19 +12,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Plus,
Trash2,
@@ -34,10 +21,6 @@ import {
Settings,
FolderInput,
Loader2,
HelpCircle,
Check,
ChevronsUpDown,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
@@ -60,13 +43,6 @@ const ADVANCED_PLACEHOLDER = `{
interface OmoFormFieldsProps {
modelOptions: Array<{ value: string; label: string }>;
modelVariantsMap?: Record<string, string[]>;
presetMetaMap?: Record<
string,
{
options?: Record<string, unknown>;
limit?: { context?: number; output?: number };
}
>;
agents: Record<string, Record<string, unknown>>;
onAgentsChange: (agents: Record<string, Record<string, unknown>>) => void;
categories: Record<string, Record<string, unknown>>;
@@ -77,149 +53,19 @@ interface OmoFormFieldsProps {
onOtherFieldsStrChange: (value: string) => void;
}
export type CustomModelItem = {
key: string;
model: string;
sourceKey?: string;
};
type CustomModelItem = { key: string; model: string };
type BuiltinModelDef = Pick<
OmoAgentDef | OmoCategoryDef,
"key" | "display" | "descKey" | "recommended" | "tooltipKey"
"key" | "display" | "descZh" | "descEn" | "recommended"
>;
type ModelOption = { value: string; label: string };
function DeferredKeyInput({
value,
onCommit,
placeholder,
className,
}: {
value: string;
onCommit: (value: string) => void;
placeholder?: string;
className?: string;
}) {
const [draft, setDraft] = useState(value);
useEffect(() => {
setDraft(value);
}, [value]);
return (
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => {
if (draft !== value) {
onCommit(draft);
}
}}
placeholder={placeholder}
className={className}
/>
);
}
const BUILTIN_AGENT_KEYS = new Set(OMO_BUILTIN_AGENTS.map((a) => a.key));
const BUILTIN_CATEGORY_KEYS = new Set(OMO_BUILTIN_CATEGORIES.map((c) => c.key));
const EMPTY_MODEL_VALUE = "__cc_switch_omo_model_empty__";
const UNAVAILABLE_MODEL_VALUE = "__cc_switch_omo_model_unavailable__";
const EMPTY_VARIANT_VALUE = "__cc_switch_omo_variant_empty__";
function ModelCombobox({
value,
options,
recommended,
onChange,
}: {
value: string;
options: ModelOption[];
recommended?: string;
onChange: (value: string) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const selectedLabel = options.find((o) => o.value === value)?.label;
const selectModelText = t("omo.selectModel", {
defaultValue: "Select configured model",
});
const placeholderText = recommended
? `${selectModelText} (${t("omo.recommendedHint", { model: recommended, defaultValue: "Recommended: {{model}}" })})`
: selectModelText;
return (
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={open}
className="flex flex-1 h-8 items-center justify-between whitespace-nowrap rounded-md border border-border-default bg-background px-3 py-1 text-sm shadow-sm ring-offset-background focus:outline-none focus-visible:outline-none focus:border-border-default focus-visible:border-border-default focus:ring-0 focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className={cn("truncate", !value && "text-muted-foreground")}>
{selectedLabel || placeholderText}
</span>
<span className="flex items-center shrink-0 ml-1 gap-0.5">
{value && (
<X
className="h-3.5 w-3.5 opacity-50 hover:opacity-100 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onChange("");
}}
/>
)}
<ChevronsUpDown className="h-3.5 w-3.5 opacity-50" />
</span>
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
sideOffset={6}
avoidCollisions={true}
collisionPadding={8}
className="z-[1000] w-[var(--radix-popover-trigger-width)] p-0 border-border-default"
>
<Command>
<CommandInput
placeholder={t("omo.searchModel", {
defaultValue: "Search model...",
})}
/>
<CommandList>
<CommandEmpty>
{t("omo.noEnabledModels", {
defaultValue: "No configured models",
})}
</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
keywords={[option.label]}
onSelect={() => {
onChange(option.value);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0",
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
const UNAVAILABLE_VARIANT_VALUE = "__cc_switch_omo_variant_unavailable__";
function getAdvancedStr(config: Record<string, unknown> | undefined): string {
if (!config) return "";
@@ -240,58 +86,24 @@ function collectCustomModels(
customs.push({
key: k,
model: ((v as Record<string, unknown>).model as string) || "",
sourceKey: k,
});
}
}
return customs;
}
export function mergeCustomModelsIntoStore(
function mergeCustomModelsIntoStore(
store: Record<string, Record<string, unknown>>,
builtinKeys: Set<string>,
customs: CustomModelItem[],
modelVariantsMap: Record<string, string[]>,
): Record<string, Record<string, unknown>> {
const updated: Record<string, Record<string, unknown>> = {};
for (const [key, value] of Object.entries(store)) {
if (builtinKeys.has(key)) {
updated[key] = { ...value };
}
const updated = { ...store };
for (const key of Object.keys(updated)) {
if (!builtinKeys.has(key)) delete updated[key];
}
for (const custom of customs) {
const targetKey = custom.key.trim();
if (!targetKey) continue;
const sourceKey = (custom.sourceKey || targetKey).trim();
const sourceEntry = store[sourceKey] ?? store[targetKey];
const nextEntry = {
...(updated[targetKey] || {}),
...(sourceEntry || {}),
};
if (custom.model.trim()) {
nextEntry.model = custom.model;
const currentVariant =
typeof nextEntry.variant === "string" ? nextEntry.variant : "";
if (currentVariant) {
const validVariants = modelVariantsMap[custom.model] || [];
if (!validVariants.includes(currentVariant)) {
delete nextEntry.variant;
}
}
updated[targetKey] = nextEntry;
continue;
}
delete nextEntry.model;
delete nextEntry.variant;
if (Object.keys(nextEntry).length > 0) {
updated[targetKey] = nextEntry;
} else {
delete updated[targetKey];
if (custom.key.trim()) {
updated[custom.key] = { ...updated[custom.key], model: custom.model };
}
}
return updated;
@@ -300,7 +112,6 @@ export function mergeCustomModelsIntoStore(
export function OmoFormFields({
modelOptions,
modelVariantsMap = {},
presetMetaMap: _presetMetaMap = {},
agents,
onAgentsChange,
categories,
@@ -308,7 +119,8 @@ export function OmoFormFields({
otherFieldsStr,
onOtherFieldsStrChange,
}: OmoFormFieldsProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const isZh = i18n.language?.startsWith("zh");
const [mainAgentsOpen, setMainAgentsOpen] = useState(true);
const [subAgentsOpen, setSubAgentsOpen] = useState(true);
@@ -347,29 +159,19 @@ export function OmoFormFields({
const syncCustomAgents = useCallback(
(customs: CustomModelItem[]) => {
onAgentsChange(
mergeCustomModelsIntoStore(
agents,
BUILTIN_AGENT_KEYS,
customs,
modelVariantsMap,
),
mergeCustomModelsIntoStore(agents, BUILTIN_AGENT_KEYS, customs),
);
},
[agents, onAgentsChange, modelVariantsMap],
[agents, onAgentsChange],
);
const syncCustomCategories = useCallback(
(customs: CustomModelItem[]) => {
onCategoriesChange(
mergeCustomModelsIntoStore(
categories,
BUILTIN_CATEGORY_KEYS,
customs,
modelVariantsMap,
),
mergeCustomModelsIntoStore(categories, BUILTIN_CATEGORY_KEYS, customs),
);
},
[categories, onCategoriesChange, modelVariantsMap],
[categories, onCategoriesChange],
);
const buildEffectiveModelOptions = useCallback(
@@ -410,16 +212,43 @@ export function OmoFormFields({
const renderModelSelect = (
currentModel: string,
onChange: (value: string) => void,
recommended?: string,
placeholder?: string,
) => {
const options = buildEffectiveModelOptions(currentModel);
return (
<ModelCombobox
value={currentModel}
options={options}
recommended={recommended}
onChange={onChange}
/>
<Select
value={currentModel || EMPTY_MODEL_VALUE}
onValueChange={(value) =>
onChange(value === EMPTY_MODEL_VALUE ? "" : value)
}
>
<SelectTrigger className="flex-1 h-8 text-sm">
<SelectValue
placeholder={
placeholder ||
t("omo.selectEnabledModel", {
defaultValue: "Select enabled model",
})
}
/>
</SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value={EMPTY_MODEL_VALUE}>
{t("omo.clearWrapped", { defaultValue: "(Clear)" })}
</SelectItem>
{options.length === 0 ? (
<SelectItem value={UNAVAILABLE_MODEL_VALUE} disabled>
{t("omo.noEnabledModels", { defaultValue: "No enabled models" })}
</SelectItem>
) : (
options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
};
@@ -439,21 +268,11 @@ export function OmoFormFields({
currentVariant: string,
onChange: (value: string) => void,
) => {
const hasModel = Boolean(currentModel);
const modelVariantKeys = hasModel
? modelVariantsMap[currentModel] || []
: [];
const hasVariants = modelVariantKeys.length > 0;
const shouldShow = hasModel && (hasVariants || Boolean(currentVariant));
if (!shouldShow) {
return null;
}
const variantOptions = buildEffectiveVariantOptions(
currentModel,
currentVariant,
);
const hasModel = Boolean(currentModel);
const firstIsUnavailable =
Boolean(currentVariant) &&
!(modelVariantsMap[currentModel] || []).includes(currentVariant);
@@ -464,8 +283,9 @@ export function OmoFormFields({
onValueChange={(value) =>
onChange(value === EMPTY_VARIANT_VALUE ? "" : value)
}
disabled={!hasModel}
>
<SelectTrigger className="w-28 h-8 text-xs shrink-0">
<SelectTrigger className="w-32 h-8 text-xs shrink-0">
<SelectValue
placeholder={t("omo.variantPlaceholder", {
defaultValue: "variant",
@@ -476,16 +296,30 @@ export function OmoFormFields({
<SelectItem value={EMPTY_VARIANT_VALUE}>
{t("omo.defaultWrapped", { defaultValue: "(Default)" })}
</SelectItem>
{variantOptions.map((variant, index) => (
<SelectItem key={`${variant}-${index}`} value={variant}>
{firstIsUnavailable && index === 0
? t("omo.currentValueUnavailable", {
value: variant,
defaultValue: "{{value}} (current value, unavailable)",
})
: variant}
{!hasModel ? (
<SelectItem value={UNAVAILABLE_VARIANT_VALUE} disabled>
{t("omo.selectModelFirst", {
defaultValue: "Select model first",
})}
</SelectItem>
))}
) : variantOptions.length === 0 ? (
<SelectItem value={UNAVAILABLE_VARIANT_VALUE} disabled>
{t("omo.noVariantsForModel", {
defaultValue: "No variants for model",
})}
</SelectItem>
) : (
variantOptions.map((variant, index) => (
<SelectItem key={`${variant}-${index}`} value={variant}>
{firstIsUnavailable && index === 0
? t("omo.currentValueUnavailable", {
value: variant,
defaultValue: "{{value}} (current value, unavailable)",
})
: variant}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
@@ -702,7 +536,7 @@ export function OmoFormFields({
toast.warning(
t("omo.noEnabledModelsWarning", {
defaultValue:
"No configured models available. Configure OpenCode models first.",
"No enabled models available. Configure and enable OpenCode models first.",
}),
);
return;
@@ -807,17 +641,9 @@ export function OmoFormFields({
<div key={key} className="border-b border-border/30 last:border-b-0">
<div className="flex items-center gap-2 py-1.5">
<div className="w-32 shrink-0">
<div className="flex items-center gap-1 text-sm font-medium">
{def.display}
<span className="relative inline-flex group/tip">
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground/60 hover:text-muted-foreground cursor-help shrink-0" />
<span className="invisible opacity-0 group-hover/tip:visible group-hover/tip:opacity-100 transition-opacity duration-150 absolute left-0 top-full mt-1 z-50 w-[260px] rounded-md bg-popover text-popover-foreground border border-border shadow-md px-3 py-2 text-xs leading-relaxed font-normal pointer-events-none">
{t(def.tooltipKey)}
</span>
</span>
</div>
<div className="text-sm font-medium">{def.display}</div>
<div className="text-xs text-muted-foreground truncate">
{t(def.descKey)}
{isZh ? def.descZh : def.descEn}
</div>
</div>
{renderModelSelect(
@@ -901,14 +727,16 @@ export function OmoFormFields({
className="border-b border-border/30 last:border-b-0"
>
<div className="flex items-center gap-2 py-1.5">
<DeferredKeyInput
<Input
value={item.key}
onCommit={(value) => updateCustom({ key: value })}
onChange={(e) => updateCustom({ key: e.target.value })}
placeholder={keyPlaceholder}
className="w-32 shrink-0 h-8 text-sm text-primary"
/>
{renderModelSelect(item.model, (value) =>
updateCustom({ model: value }),
{renderModelSelect(
item.model,
(value) => updateCustom({ model: value }),
t("omo.modelNamePlaceholder", { defaultValue: "model-name" }),
)}
{renderVariantSelect(item.model, currentVariant, (value) => {
if (!item.key) return;
@@ -1049,17 +877,11 @@ export function OmoFormFields({
const addCustomModel = (scope: AdvancedScope) => {
if (scope === "agent") {
setCustomAgents((prev) => [
...prev,
{ key: "", model: "", sourceKey: "" },
]);
setCustomAgents((prev) => [...prev, { key: "", model: "" }]);
setSubAgentsOpen(true);
return;
}
setCustomCategories((prev) => [
...prev,
{ key: "", model: "", sourceKey: "" },
]);
setCustomCategories((prev) => [...prev, { key: "", model: "" }]);
setCategoriesOpen(true);
};
@@ -1109,7 +931,7 @@ export function OmoFormFields({
·{" "}
{t("omo.enabledModelsCount", {
count: modelOptions.length,
defaultValue: "{{count}} configured models available",
defaultValue: "{{count}} enabled models available",
})}
</span>
{localFilePath && (
@@ -1,472 +0,0 @@
import { useTranslation } from "react-i18next";
import { useState, useRef, useCallback } from "react";
import { FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
import { ApiKeySection } from "./shared";
import { openclawApiProtocols } from "@/config/openclawProviderPresets";
import type { ProviderCategory, OpenClawModel } from "@/types";
interface OpenClawFormFieldsProps {
// Base URL
baseUrl: string;
onBaseUrlChange: (value: string) => void;
// API Key
apiKey: string;
onApiKeyChange: (value: string) => void;
category?: ProviderCategory;
shouldShowApiKeyLink: boolean;
websiteUrl: string;
isPartner?: boolean;
partnerPromotionKey?: string;
// API Protocol
api: string;
onApiChange: (value: string) => void;
// Models
models: OpenClawModel[];
onModelsChange: (models: OpenClawModel[]) => void;
}
export function OpenClawFormFields({
baseUrl,
onBaseUrlChange,
apiKey,
onApiKeyChange,
category,
shouldShowApiKeyLink,
websiteUrl,
isPartner,
partnerPromotionKey,
api,
onApiChange,
models,
onModelsChange,
}: OpenClawFormFieldsProps) {
const { t } = useTranslation();
const [expandedModels, setExpandedModels] = useState<Record<number, boolean>>(
{},
);
// Stable key tracking for models list
const modelKeysRef = useRef<string[]>([]);
const getModelKeys = useCallback(() => {
// Grow keys array if models were added externally
while (modelKeysRef.current.length < models.length) {
modelKeysRef.current.push(crypto.randomUUID());
}
// Shrink if models were removed externally
if (modelKeysRef.current.length > models.length) {
modelKeysRef.current.length = models.length;
}
return modelKeysRef.current;
}, [models.length]);
const modelKeys = getModelKeys();
// Toggle advanced section for a model
const toggleModelAdvanced = (index: number) => {
setExpandedModels((prev) => ({ ...prev, [index]: !prev[index] }));
};
// Add a new model entry
const handleAddModel = () => {
modelKeysRef.current.push(crypto.randomUUID());
onModelsChange([
...models,
{
id: "",
name: "",
contextWindow: undefined,
maxTokens: undefined,
cost: undefined,
},
]);
};
// Remove a model entry
const handleRemoveModel = (index: number) => {
modelKeysRef.current.splice(index, 1);
const newModels = [...models];
newModels.splice(index, 1);
onModelsChange(newModels);
// Clean up expanded state
setExpandedModels((prev) => {
const updated = { ...prev };
delete updated[index];
return updated;
});
};
// Update model field
const handleModelChange = (
index: number,
field: keyof OpenClawModel,
value: unknown,
) => {
const newModels = [...models];
newModels[index] = { ...newModels[index], [field]: value };
onModelsChange(newModels);
};
// Update model cost
const handleCostChange = (
index: number,
costField: "input" | "output" | "cacheRead" | "cacheWrite",
value: string,
) => {
const newModels = [...models];
const numValue = parseFloat(value);
const currentCost = newModels[index].cost || { input: 0, output: 0 };
newModels[index] = {
...newModels[index],
cost: {
...currentCost,
[costField]: isNaN(numValue) ? undefined : numValue,
},
};
onModelsChange(newModels);
};
return (
<>
{/* API Protocol Selector */}
<div className="space-y-2">
<FormLabel htmlFor="openclaw-api">
{t("openclaw.apiProtocol", {
defaultValue: "API 协议",
})}
</FormLabel>
<Select value={api} onValueChange={onApiChange}>
<SelectTrigger id="openclaw-api">
<SelectValue
placeholder={t("openclaw.selectProtocol", {
defaultValue: "选择 API 协议",
})}
/>
</SelectTrigger>
<SelectContent>
{openclawApiProtocols.map((protocol) => (
<SelectItem key={protocol.value} value={protocol.value}>
{protocol.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("openclaw.apiProtocolHint", {
defaultValue:
"选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。",
})}
</p>
</div>
{/* Base URL */}
<div className="space-y-2">
<FormLabel htmlFor="openclaw-baseurl">
{t("openclaw.baseUrl", { defaultValue: "API 端点" })}
</FormLabel>
<Input
id="openclaw-baseurl"
value={baseUrl}
onChange={(e) => onBaseUrlChange(e.target.value)}
placeholder="https://api.example.com/v1"
/>
<p className="text-xs text-muted-foreground">
{t("openclaw.baseUrlHint", {
defaultValue: "供应商的 API 端点地址。",
})}
</p>
</div>
{/* API Key */}
<ApiKeySection
value={apiKey}
onChange={onApiKeyChange}
category={category}
shouldShowLink={shouldShowApiKeyLink}
websiteUrl={websiteUrl}
isPartner={isPartner}
partnerPromotionKey={partnerPromotionKey}
/>
{/* Models Editor */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<FormLabel>
{t("openclaw.models", { defaultValue: "模型列表" })}
</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddModel}
className="h-7 gap-1"
>
<Plus className="h-3.5 w-3.5" />
{t("openclaw.addModel", { defaultValue: "添加模型" })}
</Button>
</div>
{models.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
{t("openclaw.noModels", {
defaultValue: "暂无模型配置。点击添加模型来配置可用模型。",
})}
</p>
) : (
<div className="space-y-4">
{models.map((model, index) => (
<div
key={modelKeys[index]}
className="p-3 border border-border/50 rounded-lg space-y-3"
>
{/* Model ID and Name row */}
<div className="flex items-center gap-2">
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.modelId", { defaultValue: "模型 ID" })}
</label>
<Input
value={model.id}
onChange={(e) =>
handleModelChange(index, "id", e.target.value)
}
placeholder={t("openclaw.modelIdPlaceholder", {
defaultValue: "claude-3-sonnet",
})}
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.modelName", { defaultValue: "显示名称" })}
</label>
<Input
value={model.name}
onChange={(e) =>
handleModelChange(index, "name", e.target.value)
}
placeholder={t("openclaw.modelNamePlaceholder", {
defaultValue: "Claude 3 Sonnet",
})}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveModel(index)}
className="h-9 w-9 mt-5 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Advanced Options (Collapsible) */}
<Collapsible
open={expandedModels[index] ?? false}
onOpenChange={() => toggleModelAdvanced(index)}
>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{expandedModels[index] ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
{t("openclaw.advancedOptions", {
defaultValue: "高级选项",
})}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-2">
{/* Context Window, Max Tokens and Reasoning row */}
<div className="flex items-center gap-2">
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.contextWindow", {
defaultValue: "上下文窗口",
})}
</label>
<Input
type="number"
value={model.contextWindow ?? ""}
onChange={(e) =>
handleModelChange(
index,
"contextWindow",
e.target.value
? parseInt(e.target.value)
: undefined,
)
}
placeholder="200000"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.maxTokens", {
defaultValue: "最大输出 Tokens",
})}
</label>
<Input
type="number"
value={model.maxTokens ?? ""}
onChange={(e) =>
handleModelChange(
index,
"maxTokens",
e.target.value
? parseInt(e.target.value)
: undefined,
)
}
placeholder="32000"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.reasoning", {
defaultValue: "推理模式",
})}
</label>
<div className="flex items-center h-9 gap-2">
<Switch
checked={model.reasoning ?? false}
onCheckedChange={(checked) =>
handleModelChange(index, "reasoning", checked)
}
/>
<span className="text-xs text-muted-foreground">
{model.reasoning
? t("openclaw.reasoningOn", {
defaultValue: "启用",
})
: t("openclaw.reasoningOff", {
defaultValue: "关闭",
})}
</span>
</div>
</div>
</div>
{/* Cost row */}
<div className="flex items-center gap-2">
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.inputCost", {
defaultValue: "输入价格 ($/M tokens)",
})}
</label>
<Input
type="number"
step="0.001"
value={model.cost?.input ?? ""}
onChange={(e) =>
handleCostChange(index, "input", e.target.value)
}
placeholder="3"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.outputCost", {
defaultValue: "输出价格 ($/M tokens)",
})}
</label>
<Input
type="number"
step="0.001"
value={model.cost?.output ?? ""}
onChange={(e) =>
handleCostChange(index, "output", e.target.value)
}
placeholder="15"
/>
</div>
<div className="flex-1" />
</div>
{/* Cache Cost row */}
<div className="flex items-center gap-2">
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.cacheReadCost", {
defaultValue: "缓存读取价格 ($/M tokens)",
})}
</label>
<Input
type="number"
step="0.001"
value={model.cost?.cacheRead ?? ""}
onChange={(e) =>
handleCostChange(index, "cacheRead", e.target.value)
}
placeholder="0.3"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.cacheWriteCost", {
defaultValue: "缓存写入价格 ($/M tokens)",
})}
</label>
<Input
type="number"
step="0.001"
value={model.cost?.cacheWrite ?? ""}
onChange={(e) =>
handleCostChange(
index,
"cacheWrite",
e.target.value,
)
}
placeholder="3.75"
/>
</div>
<div className="flex-1" />
</div>
<p className="text-xs text-muted-foreground">
{t("openclaw.cacheCostHint", {
defaultValue:
"缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。",
})}
</p>
</CollapsibleContent>
</Collapsible>
</div>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
{t("openclaw.modelsHint", {
defaultValue:
"配置该供应商支持的模型。模型 ID 用于 API 调用,显示名称用于界面展示。",
})}
</p>
</div>
</>
);
}
+76 -561
View File
@@ -16,7 +16,6 @@ import type {
ClaudeApiFormat,
OpenCodeModel,
OpenCodeProviderConfig,
OpenClawModel,
} from "@/types";
import {
providerPresets,
@@ -32,16 +31,9 @@ import {
} from "@/config/geminiProviderPresets";
import {
opencodeProviderPresets,
OPENCODE_PRESET_MODEL_VARIANTS,
type OpenCodeProviderPreset,
} from "@/config/opencodeProviderPresets";
import {
openclawProviderPresets,
type OpenClawProviderPreset,
type OpenClawSuggestedDefaults,
} from "@/config/openclawProviderPresets";
import { OpenCodeFormFields } from "./OpenCodeFormFields";
import { OpenClawFormFields } from "./OpenClawFormFields";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
import { applyTemplateValues } from "@/utils/providerConfigUtils";
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
@@ -61,7 +53,7 @@ import { type OmoGlobalConfigFieldsRef } from "./OmoGlobalConfigFields";
import { OmoCommonConfigEditor } from "./OmoCommonConfigEditor";
import * as configApi from "@/lib/api/config";
import type { OmoGlobalConfig } from "@/types/omo";
import { mergeOmoConfigPreview, parseOmoOtherFieldsObject } from "@/types/omo";
import { mergeOmoConfigPreview } from "@/types/omo";
import {
ProviderAdvancedConfig,
type PricingModelSourceOption,
@@ -120,25 +112,21 @@ const isKnownOpencodeOptionKey = (key: string) =>
function parseOpencodeConfig(
settingsConfig?: Record<string, unknown>,
): OpenCodeProviderConfig {
const normalize = (
parsed: Partial<OpenCodeProviderConfig>,
): OpenCodeProviderConfig => ({
npm: parsed.npm || OPENCODE_DEFAULT_NPM,
options:
parsed.options && typeof parsed.options === "object"
? (parsed.options as OpenCodeProviderConfig["options"])
: {},
models:
parsed.models && typeof parsed.models === "object"
? (parsed.models as Record<string, OpenCodeModel>)
: {},
});
try {
const parsed = JSON.parse(
settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG,
) as Partial<OpenCodeProviderConfig>;
return normalize(parsed);
return {
npm: parsed.npm || OPENCODE_DEFAULT_NPM,
options:
parsed.options && typeof parsed.options === "object"
? (parsed.options as OpenCodeProviderConfig["options"])
: {},
models:
parsed.models && typeof parsed.models === "object"
? (parsed.models as Record<string, OpenCodeModel>)
: {},
};
} catch {
return {
npm: OPENCODE_DEFAULT_NPM,
@@ -148,25 +136,6 @@ function parseOpencodeConfig(
}
}
function parseOpencodeConfigStrict(
settingsConfig?: Record<string, unknown>,
): OpenCodeProviderConfig {
const parsed = JSON.parse(
settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG,
) as Partial<OpenCodeProviderConfig>;
return {
npm: parsed.npm || OPENCODE_DEFAULT_NPM,
options:
parsed.options && typeof parsed.options === "object"
? (parsed.options as OpenCodeProviderConfig["options"])
: {},
models:
parsed.models && typeof parsed.models === "object"
? (parsed.models as Record<string, OpenCodeModel>)
: {},
};
}
function toOpencodeExtraOptions(
options: OpenCodeProviderConfig["options"],
): Record<string, string> {
@@ -179,25 +148,13 @@ 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
| OpenClawProviderPreset;
| OpenCodeProviderPreset;
};
interface ProviderFormProps {
@@ -238,10 +195,8 @@ function buildOmoProfilePreview(
}
if (otherFieldsStr.trim()) {
try {
const other = parseOmoOtherFieldsObject(otherFieldsStr);
if (other) {
Object.assign(profileOnly, other);
}
const other = JSON.parse(otherFieldsStr);
Object.assign(profileOnly, other);
} catch {}
}
return profileOnly;
@@ -278,7 +233,6 @@ export function ProviderForm({
category?: ProviderCategory;
isPartner?: boolean;
partnerPromotionKey?: string;
suggestedDefaults?: OpenClawSuggestedDefaults;
} | null>(null);
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] =
@@ -357,9 +311,7 @@ export function ProviderForm({
? GEMINI_DEFAULT_CONFIG
: appId === "opencode"
? OPENCODE_DEFAULT_CONFIG
: appId === "openclaw"
? OPENCLAW_DEFAULT_CONFIG
: CLAUDE_DEFAULT_CONFIG,
: CLAUDE_DEFAULT_CONFIG,
icon: initialData?.icon ?? "",
iconColor: initialData?.iconColor ?? "",
}),
@@ -486,11 +438,6 @@ export function ProviderForm({
id: `opencode-${index}`,
preset,
}));
} else if (appId === "openclaw") {
return openclawProviderPresets.map<PresetEntry>((preset, index) => ({
id: `openclaw-${index}`,
preset,
}));
}
return providerPresets.map<PresetEntry>((preset, index) => ({
id: `claude-${index}`,
@@ -633,24 +580,19 @@ export function ProviderForm({
);
}, [opencodeProvidersData?.providers, providerId]);
const [enabledOpencodeProviderIds, setEnabledOpencodeProviderIds] = useState<
string[] | null
>(null);
const [omoLiveIdsLoadFailed, setOmoLiveIdsLoadFailed] = useState(false);
const lastOmoModelSourceWarningRef = useRef<string>("");
string[]
>([]);
useEffect(() => {
let active = true;
if (!isOmoCategory) {
setEnabledOpencodeProviderIds(null);
setOmoLiveIdsLoadFailed(false);
setEnabledOpencodeProviderIds([]);
return () => {
active = false;
};
}
setEnabledOpencodeProviderIds(null);
setOmoLiveIdsLoadFailed(false);
(async () => {
try {
const ids = await providersApi.getOpenCodeLiveProviderIds();
@@ -658,13 +600,9 @@ export function ProviderForm({
setEnabledOpencodeProviderIds(ids);
}
} catch (error) {
console.warn(
"[OMO_MODEL_SOURCE_LIVE_IDS_FAILED] failed to load live provider ids",
error,
);
console.error("Failed to load OpenCode live provider ids:", error);
if (active) {
setOmoLiveIdsLoadFailed(true);
setEnabledOpencodeProviderIds(null);
setEnabledOpencodeProviderIds([]);
}
}
})();
@@ -674,71 +612,23 @@ export function ProviderForm({
};
}, [isOmoCategory]);
const omoModelBuild = useMemo(() => {
const empty = {
options: [] as Array<{ value: string; label: string }>,
variantsMap: {} as Record<string, string[]>,
presetMetaMap: {} as Record<
string,
{
options?: Record<string, unknown>;
limit?: { context?: number; output?: number };
}
>,
parseFailedProviders: [] as string[],
usedFallbackSource: false,
};
if (!isOmoCategory) {
return empty;
}
const omoModelOptions = useMemo(() => {
if (!isOmoCategory) return [];
const allProviders = opencodeProvidersData?.providers;
if (!allProviders) {
return empty;
}
if (!allProviders) return [];
const shouldFilterByLive = !omoLiveIdsLoadFailed;
if (shouldFilterByLive && enabledOpencodeProviderIds === null) {
return empty;
}
const liveSet =
shouldFilterByLive && enabledOpencodeProviderIds
? new Set(enabledOpencodeProviderIds)
: null;
const enabledSet = new Set(enabledOpencodeProviderIds);
if (enabledSet.size === 0) return [];
const dedupedOptions = new Map<string, string>();
const variantsMap: Record<string, string[]> = {};
const presetMetaMap: Record<
string,
{
options?: Record<string, unknown>;
limit?: { context?: number; output?: number };
}
> = {};
const parseFailedProviders: string[] = [];
for (const [providerKey, provider] of Object.entries(allProviders)) {
if (provider.category === "omo") {
continue;
}
if (liveSet && !liveSet.has(providerKey)) {
if (provider.category === "omo" || !enabledSet.has(providerKey)) {
continue;
}
let parsedConfig: OpenCodeProviderConfig;
try {
parsedConfig = parseOpencodeConfigStrict(provider.settingsConfig);
} catch (error) {
parseFailedProviders.push(providerKey);
console.warn(
"[OMO_MODEL_SOURCE_PARSE_FAILED] failed to parse provider settings",
{
providerKey,
error,
},
);
continue;
}
const parsedConfig = parseOpencodeConfig(provider.settingsConfig);
for (const [modelId, model] of Object.entries(
parsedConfig.models || {},
)) {
@@ -755,107 +645,63 @@ export function ProviderForm({
if (!dedupedOptions.has(value)) {
dedupedOptions.set(value, label);
}
const rawVariants = model.variants;
if (
rawVariants &&
typeof rawVariants === "object" &&
!Array.isArray(rawVariants)
) {
const variantKeys = Object.keys(rawVariants).filter(Boolean);
if (variantKeys.length > 0) {
variantsMap[value] = variantKeys;
}
}
}
// Preset fallback: for models without config-defined variants,
// check if the npm package has preset variant definitions.
// Also collect preset metadata (options, limit) for enrichment.
const presetModels = OPENCODE_PRESET_MODEL_VARIANTS[parsedConfig.npm];
if (presetModels) {
for (const modelId of Object.keys(parsedConfig.models || {})) {
const fullKey = `${providerKey}/${modelId}`;
const preset = presetModels.find((p) => p.id === modelId);
if (!preset) continue;
// Variant fallback
if (!variantsMap[fullKey] && preset.variants) {
const presetKeys = Object.keys(preset.variants).filter(Boolean);
if (presetKeys.length > 0) {
variantsMap[fullKey] = presetKeys;
}
}
// Collect preset metadata for model enrichment
const meta: (typeof presetMetaMap)[string] = {};
if (preset.options) meta.options = preset.options;
if (preset.contextLimit || preset.outputLimit) {
meta.limit = {};
if (preset.contextLimit) meta.limit.context = preset.contextLimit;
if (preset.outputLimit) meta.limit.output = preset.outputLimit;
}
if (Object.keys(meta).length > 0) {
presetMetaMap[fullKey] = meta;
}
}
}
}
return {
options: Array.from(dedupedOptions.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label, "zh-CN")),
variantsMap,
presetMetaMap,
parseFailedProviders,
usedFallbackSource: omoLiveIdsLoadFailed,
};
return Array.from(dedupedOptions.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label, "zh-CN"));
}, [
isOmoCategory,
opencodeProvidersData?.providers,
enabledOpencodeProviderIds,
omoLiveIdsLoadFailed,
]);
const omoModelOptions = omoModelBuild.options;
const omoModelVariantsMap = omoModelBuild.variantsMap;
const omoPresetMetaMap = omoModelBuild.presetMetaMap;
useEffect(() => {
if (!isOmoCategory) return;
const failed = omoModelBuild.parseFailedProviders;
const fallback = omoModelBuild.usedFallbackSource;
if (failed.length === 0 && !fallback) return;
const signature = `${fallback ? "fallback:" : ""}${failed
.slice()
.sort()
.join(",")}`;
if (lastOmoModelSourceWarningRef.current === signature) return;
lastOmoModelSourceWarningRef.current = signature;
if (failed.length > 0) {
toast.warning(
t("omo.modelSourcePartialWarning", {
count: failed.length,
defaultValue:
"Some provider model configs are invalid and were skipped.",
}),
);
const omoModelVariantsMap = useMemo(() => {
const variantsMap: Record<string, string[]> = {};
if (!isOmoCategory) {
return variantsMap;
}
if (fallback) {
toast.warning(
t("omo.modelSourceFallbackWarning", {
defaultValue:
"Failed to load live provider state. Falling back to configured providers.",
}),
);
const allProviders = opencodeProvidersData?.providers;
if (!allProviders) {
return variantsMap;
}
const enabledSet = new Set(enabledOpencodeProviderIds);
if (enabledSet.size === 0) {
return variantsMap;
}
for (const [providerKey, provider] of Object.entries(allProviders)) {
if (provider.category === "omo" || !enabledSet.has(providerKey)) {
continue;
}
const parsedConfig = parseOpencodeConfig(provider.settingsConfig);
for (const [modelId, model] of Object.entries(
parsedConfig.models || {},
)) {
const rawVariants = model.variants;
if (
!rawVariants ||
typeof rawVariants !== "object" ||
Array.isArray(rawVariants)
) {
continue;
}
const variantKeys = Object.keys(rawVariants).filter(Boolean);
if (variantKeys.length === 0) {
continue;
}
variantsMap[`${providerKey}/${modelId}`] = variantKeys;
}
}
return variantsMap;
}, [
isOmoCategory,
omoModelBuild.parseFailedProviders,
omoModelBuild.usedFallbackSource,
t,
opencodeProvidersData?.providers,
enabledOpencodeProviderIds,
]);
const initialOmoSettings =
@@ -873,24 +719,6 @@ 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<string>(() => {
if (appId !== "openclaw") return "";
// In edit mode, use the existing provider ID as the key
return providerId || "";
});
// OpenCode 配置状态
const [opencodeNpm, setOpencodeNpm] = useState<string>(() => {
if (appId !== "opencode") return OPENCODE_DEFAULT_NPM;
return initialOpencodeConfig?.npm || OPENCODE_DEFAULT_NPM;
@@ -1032,128 +860,6 @@ export function ProviderForm({
setUseOmoCommonConfig(useCommonConfig);
}, []);
// OpenClaw 配置状态
const [openclawBaseUrl, setOpenclawBaseUrl] = useState<string>(() => {
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<string>(() => {
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<string>(() => {
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<OpenClawModel[]>(() => {
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<string, any>) => void) => {
try {
@@ -1281,24 +987,6 @@ 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()) {
@@ -1390,19 +1078,7 @@ export function ProviderForm({
}
if (omoOtherFieldsStr.trim()) {
try {
const otherFields = parseOmoOtherFieldsObject(omoOtherFieldsStr);
if (!otherFields) {
toast.error(
t("omo.jsonMustBeObject", {
field: t("omo.otherFields", {
defaultValue: "Other Config",
}),
defaultValue: "{{field}} must be a JSON object",
}),
);
return;
}
omoConfig.otherFields = otherFields;
omoConfig.otherFields = JSON.parse(omoOtherFieldsStr);
} catch {
toast.error(
t("omo.invalidJson", {
@@ -1432,8 +1108,6 @@ export function ProviderForm({
} else {
payload.providerKey = opencodeProviderKey;
}
} else if (appId === "openclaw") {
payload.providerKey = openclawProviderKey;
}
if (category === "omo" && !payload.presetCategory) {
@@ -1448,10 +1122,6 @@ export function ProviderForm({
if (activePreset.isPartner) {
payload.isPartner = activePreset.isPartner;
}
// OpenClaw: 传递预设的 suggestedDefaults 到提交数据
if (activePreset.suggestedDefaults) {
payload.suggestedDefaults = activePreset.suggestedDefaults;
}
}
if (!isEditMode && draftCustomEndpoints.length > 0) {
@@ -1590,21 +1260,6 @@ 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,
@@ -1636,14 +1291,6 @@ export function ProviderForm({
setOpencodeExtraOptions({});
resetOmoDraftState();
}
// OpenClaw 自定义模式:重置为空配置
if (appId === "openclaw") {
setOpenclawProviderKey("");
setOpenclawBaseUrl("");
setOpenclawApiKey("");
setOpenclawApi("openai-completions");
setOpenclawModels([]);
}
return;
}
@@ -1727,40 +1374,6 @@ export function ProviderForm({
return;
}
// OpenClaw preset handling
if (appId === "openclaw") {
const preset = entry.preset as OpenClawProviderPreset;
const config = preset.settingsConfig;
// Update activePreset with suggestedDefaults for OpenClaw
setActivePreset({
id: value,
category: preset.category,
isPartner: preset.isPartner,
partnerPromotionKey: preset.partnerPromotionKey,
suggestedDefaults: preset.suggestedDefaults,
});
// Clear provider key (user must enter their own unique key)
setOpenclawProviderKey("");
// Update OpenClaw-specific states
setOpenclawBaseUrl(config.baseUrl || "");
setOpenclawApiKey(config.apiKey || "");
setOpenclawApi(config.api || "openai-completions");
setOpenclawModels(config.models || []);
// Update form fields
form.reset({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(config, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
const preset = entry.preset as ProviderPreset;
const config = applyTemplateValues(
preset.settingsConfig,
@@ -1865,54 +1478,6 @@ export function ProviderForm({
</p>
)}
</div>
) : appId === "openclaw" ? (
<div className="space-y-2">
<Label htmlFor="openclaw-key">
{t("openclaw.providerKey")}
<span className="text-destructive ml-1">*</span>
</Label>
<Input
id="openclaw-key"
value={openclawProviderKey}
onChange={(e) =>
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 && (
<p className="text-xs text-destructive">
{t("openclaw.providerKeyDuplicate")}
</p>
)}
{openclawProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(openclawProviderKey) && (
<p className="text-xs text-destructive">
{t("openclaw.providerKeyInvalid")}
</p>
)}
{!(
existingOpenclawKeys.includes(openclawProviderKey) &&
!isEditMode
) &&
(openclawProviderKey.trim() === "" ||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(openclawProviderKey)) && (
<p className="text-xs text-muted-foreground">
{t("openclaw.providerKeyHint")}
</p>
)}
</div>
) : undefined
}
/>
@@ -2038,7 +1603,6 @@ export function ProviderForm({
<OmoFormFields
modelOptions={omoModelOptions}
modelVariantsMap={omoModelVariantsMap}
presetMetaMap={omoPresetMetaMap}
agents={omoAgents}
onAgentsChange={setOmoAgents}
categories={omoCategories}
@@ -2048,26 +1612,6 @@ export function ProviderForm({
/>
)}
{/* OpenClaw 专属字段 */}
{appId === "openclaw" && (
<OpenClawFormFields
baseUrl={openclawBaseUrl}
onBaseUrlChange={handleOpenclawBaseUrlChange}
apiKey={openclawApiKey}
onApiKeyChange={handleOpenclawApiKeyChange}
category={category}
shouldShowApiKeyLink={shouldShowOpenclawApiKeyLink}
websiteUrl={openclawWebsiteUrl}
isPartner={isOpenclawPartner}
partnerPromotionKey={openclawPartnerPromotionKey}
api={openclawApi}
onApiChange={handleOpenclawApiChange}
models={openclawModels}
onModelsChange={handleOpenclawModelsChange}
/>
)}
{/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */}
{appId === "codex" ? (
<>
<CodexConfigEditor
@@ -2144,34 +1688,6 @@ export function ProviderForm({
</div>
{settingsConfigErrorField}
</>
) : appId === "openclaw" ? (
<>
<div className="space-y-2">
<Label htmlFor="settingsConfig">{t("provider.configJson")}</Label>
<JsonEditor
value={form.getValues("settingsConfig")}
onChange={(config) => 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"
/>
</div>
<FormField
control={form.control}
name="settingsConfig"
render={() => (
<FormItem className="space-y-0">
<FormMessage />
</FormItem>
)}
/>
</>
) : (
<>
<CommonConfigEditor
@@ -2221,6 +1737,5 @@ export type ProviderFormValues = ProviderFormData & {
presetCategory?: ProviderCategory;
isPartner?: boolean;
meta?: ProviderMeta;
providerKey?: string; // OpenCode/OpenClaw: user-defined provider key
suggestedDefaults?: OpenClawSuggestedDefaults; // OpenClaw: suggested default model configuration
providerKey?: string;
};
@@ -4,10 +4,9 @@ import {
setCodexBaseUrl as setCodexBaseUrlInConfig,
} from "@/utils/providerConfigUtils";
import type { ProviderCategory } from "@/types";
import type { AppId } from "@/lib/api";
interface UseBaseUrlStateProps {
appType: AppId;
appType: "claude" | "codex" | "gemini" | "opencode";
category: ProviderCategory | undefined;
settingsConfig: string;
codexConfig?: string;
@@ -20,7 +20,6 @@ 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({
@@ -34,7 +33,6 @@ export function AppVisibilitySettings({
codex: true,
gemini: true,
opencode: true,
openclaw: true,
};
// Count how many apps are currently visible
-6
View File
@@ -39,7 +39,6 @@ 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";
@@ -596,11 +595,6 @@ export function SettingsPage({
onExport={exportConfig}
onClear={clearSelection}
/>
<div className="pt-6">
<WebdavSyncSection
config={settings?.webdavSync}
/>
</div>
</AccordionContent>
</AccordionItem>
@@ -1,775 +0,0 @@
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<React.ComponentPropsWithoutRef<typeof Button>, "children">) {
const isActive =
actionState === targetState ||
(alsoActiveFor?.includes(actionState) ?? false);
return (
<Button {...props} disabled={actionState !== "idle" || disabled}>
<span className="inline-flex items-center gap-2">
{isActive ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Icon className="h-3.5 w-3.5" />
)}
{isActive ? activeLabel : idleLabel}
</span>
</Button>
);
}
// ─── Main component ─────────────────────────────────────────
export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [actionState, setActionState] = useState<ActionState>("idle");
const [dirty, setDirty] = useState(false);
const [passwordTouched, setPasswordTouched] = useState(false);
const [justSaved, setJustSaved] = useState(false);
const justSavedTimerRef = useRef<ReturnType<typeof setTimeout> | 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<DialogType>(null);
const [remoteInfo, setRemoteInfo] = useState<RemoteSnapshotInfo | null>(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 (
<section className="space-y-4">
<header className="space-y-2">
<h3 className="text-base font-semibold text-foreground">
{t("settings.webdavSync.title")}
</h3>
<p className="text-sm text-muted-foreground">
{t("settings.webdavSync.description")}
</p>
</header>
<div className="space-y-4 rounded-lg border border-border bg-muted/40 p-6">
{/* Config fields */}
<div className="space-y-3">
{/* Service preset selector */}
<div className="flex items-center gap-4">
<label className="w-40 text-xs font-medium text-foreground shrink-0">
{t("settings.webdavSync.presets.label")}
</label>
<Select
value={presetId}
onValueChange={handlePresetChange}
disabled={isLoading}
>
<SelectTrigger className="text-xs flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{WEBDAV_PRESETS.map((preset) => (
<SelectItem key={preset.id} value={preset.id}>
{t(preset.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Server URL */}
<div className="flex items-center gap-4">
<label className="w-40 text-xs font-medium text-foreground shrink-0">
{t("settings.webdavSync.baseUrl")}
</label>
<Input
value={form.baseUrl}
onChange={(e) => updateField("baseUrl", e.target.value)}
onBlur={handleBaseUrlBlur}
placeholder={t("settings.webdavSync.baseUrlPlaceholder")}
className="text-xs flex-1"
disabled={isLoading}
/>
</div>
{/* Username */}
<div className="flex items-center gap-4">
<label className="w-40 text-xs font-medium text-foreground shrink-0">
{t("settings.webdavSync.username")}
</label>
<Input
value={form.username}
onChange={(e) => updateField("username", e.target.value)}
placeholder={t("settings.webdavSync.usernamePlaceholder")}
className="text-xs flex-1"
disabled={isLoading}
/>
</div>
{/* Password */}
<div className="flex items-center gap-4">
<label className="w-40 text-xs font-medium text-foreground shrink-0">
{t("settings.webdavSync.password")}
</label>
<Input
type="password"
value={form.password}
onChange={(e) => updateField("password", e.target.value)}
placeholder={t("settings.webdavSync.passwordPlaceholder")}
className="text-xs flex-1"
autoComplete="off"
disabled={isLoading}
/>
</div>
{/* Preset hint */}
{activePreset?.hint && (
<div className="flex items-start gap-2 pl-44 text-xs text-muted-foreground">
<Info className="h-3.5 w-3.5 shrink-0 mt-0.5" />
<span>{t(activePreset.hint)}</span>
</div>
)}
{/* Remote Root */}
<div className="flex items-center gap-4">
<label className="w-40 text-xs font-medium text-foreground shrink-0">
{t("settings.webdavSync.remoteRoot")}
<span className="block text-[10px] font-normal text-muted-foreground">
{t("settings.webdavSync.remoteRootDefault")}
</span>
</label>
<Input
value={form.remoteRoot}
onChange={(e) => updateField("remoteRoot", e.target.value)}
placeholder="cc-switch-sync"
className="text-xs flex-1"
disabled={isLoading}
/>
</div>
{/* Profile */}
<div className="flex items-center gap-4">
<label className="w-40 text-xs font-medium text-foreground shrink-0">
{t("settings.webdavSync.profile")}
<span className="block text-[10px] font-normal text-muted-foreground">
{t("settings.webdavSync.profileDefault")}
</span>
</label>
<Input
value={form.profile}
onChange={(e) => updateField("profile", e.target.value)}
placeholder="default"
className="text-xs flex-1"
disabled={isLoading}
/>
</div>
</div>
{/* Last sync time */}
{lastSyncDisplay && (
<p className="text-xs text-muted-foreground">
{t("settings.webdavSync.lastSync", { time: lastSyncDisplay })}
</p>
)}
{/* Config buttons + save status */}
<div className="flex flex-wrap items-center gap-3 pt-2">
<ActionButton
type="button"
variant="outline"
size="sm"
onClick={handleTest}
actionState={actionState}
targetState="testing"
icon={Link2}
activeLabel={t("settings.webdavSync.testing")}
idleLabel={t("settings.webdavSync.test")}
/>
<ActionButton
type="button"
variant="outline"
size="sm"
onClick={handleSave}
actionState={actionState}
targetState="saving"
icon={Save}
activeLabel={t("settings.webdavSync.saving")}
idleLabel={t("settings.webdavSync.save")}
/>
{/* Save status indicator */}
{dirty && (
<span className="inline-flex items-center gap-1.5 text-xs text-amber-500 dark:text-amber-400 animate-in fade-in duration-200">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500 dark:bg-amber-400" />
{t("settings.webdavSync.unsaved")}
</span>
)}
{!dirty && justSaved && (
<span className="inline-flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400 animate-in fade-in duration-200">
<Check className="h-3 w-3" />
{t("settings.webdavSync.saved")}
</span>
)}
</div>
{/* Sync buttons */}
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-4">
<ActionButton
type="button"
size="sm"
onClick={handleUploadClick}
disabled={!hasSavedConfig}
actionState={actionState}
targetState="uploading"
alsoActiveFor={["fetching_remote"]}
icon={UploadCloud}
activeLabel={
actionState === "fetching_remote"
? t("settings.webdavSync.fetchingRemote")
: t("settings.webdavSync.uploading")
}
idleLabel={t("settings.webdavSync.upload")}
/>
<ActionButton
type="button"
variant="secondary"
size="sm"
onClick={handleDownloadClick}
disabled={!hasSavedConfig}
actionState={actionState}
targetState="downloading"
alsoActiveFor={["fetching_remote"]}
icon={DownloadCloud}
activeLabel={
actionState === "fetching_remote"
? t("settings.webdavSync.fetchingRemote")
: t("settings.webdavSync.downloading")
}
idleLabel={t("settings.webdavSync.download")}
/>
</div>
{!hasSavedConfig && (
<p className="text-xs text-muted-foreground">
{t("settings.webdavSync.saveBeforeSync")}
</p>
)}
</div>
{/* ─── Upload confirmation dialog ──────────────────── */}
<Dialog
open={dialogType === "upload"}
onOpenChange={(open) => {
if (!open) closeDialog();
}}
>
<DialogContent className="max-w-sm" zIndex="alert">
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
<AlertTriangle className="h-5 w-5 text-destructive" />
{t("settings.webdavSync.confirmUpload.title")}
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3 text-sm leading-relaxed">
<p>{t("settings.webdavSync.confirmUpload.content")}</p>
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
<li>{t("settings.webdavSync.confirmUpload.dbItem")}</li>
<li>{t("settings.webdavSync.confirmUpload.skillsItem")}</li>
</ul>
<p className="text-muted-foreground">
{t("settings.webdavSync.confirmUpload.targetPath")}
{": "}
<code className="ml-1 text-xs bg-muted px-1.5 py-0.5 rounded">
/{form.remoteRoot.trim() || "cc-switch-sync"}/v2/
{form.profile.trim() || "default"}
</code>
</p>
{remoteInfo && (
<div className="rounded-lg border border-border bg-muted/50 p-3 space-y-2">
<p className="text-xs font-medium text-foreground">
{t("settings.webdavSync.confirmUpload.existingData")}
</p>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-xs text-muted-foreground">
<dt className="font-medium text-foreground">
{t("settings.webdavSync.confirmUpload.deviceName")}
</dt>
<dd>
<code className="bg-muted px-1.5 py-0.5 rounded">
{remoteInfo.deviceName}
</code>
</dd>
<dt className="font-medium text-foreground">
{t("settings.webdavSync.confirmUpload.createdAt")}
</dt>
<dd>{formatDate(remoteInfo.createdAt)}</dd>
</dl>
</div>
)}
{remoteInfo && (
<p className="text-destructive font-medium">
{t("settings.webdavSync.confirmUpload.warning")}
</p>
)}
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
<Button variant="outline" onClick={closeDialog}>
{t("common.cancel")}
</Button>
<Button variant="destructive" onClick={handleUploadConfirm}>
{t("settings.webdavSync.confirmUpload.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ─── Download confirmation dialog ────────────────── */}
<Dialog
open={dialogType === "download"}
onOpenChange={(open) => {
if (!open) closeDialog();
}}
>
<DialogContent className="max-w-sm" zIndex="alert">
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
<AlertTriangle className="h-5 w-5 text-destructive" />
{t("settings.webdavSync.confirmDownload.title")}
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3 text-sm leading-relaxed">
{remoteInfo && (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-muted-foreground">
<dt className="font-medium text-foreground">
{t("settings.webdavSync.confirmDownload.deviceName")}
</dt>
<dd>
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{remoteInfo.deviceName}
</code>
</dd>
<dt className="font-medium text-foreground">
{t("settings.webdavSync.confirmDownload.createdAt")}
</dt>
<dd>{formatDate(remoteInfo.createdAt)}</dd>
<dt className="font-medium text-foreground">
{t("settings.webdavSync.confirmDownload.artifacts")}
</dt>
<dd>{remoteInfo.artifacts.join(", ")}</dd>
</dl>
)}
<p className="text-destructive font-medium">
{t("settings.webdavSync.confirmDownload.warning")}
</p>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
<Button variant="outline" onClick={closeDialog}>
{t("common.cancel")}
</Button>
<Button variant="destructive" onClick={handleDownloadConfirm}>
{t("settings.webdavSync.confirmDownload.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}
+7 -5
View File
@@ -213,12 +213,14 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
const query = searchQuery.toLowerCase();
return byStatus.filter((skill) => {
const name = skill.name?.toLowerCase() || "";
const repo =
skill.repoOwner && skill.repoName
? `${skill.repoOwner}/${skill.repoName}`.toLowerCase()
: "";
const description = skill.description?.toLowerCase() || "";
const directory = skill.directory?.toLowerCase() || "";
return name.includes(query) || repo.includes(query);
return (
name.includes(query) ||
description.includes(query) ||
directory.includes(query)
);
});
}, [skills, searchQuery, filterRepo, filterStatus]);
+3 -7
View File
@@ -53,7 +53,7 @@ const UnifiedSkillsPanel = React.forwardRef<
const installFromZipMutation = useInstallSkillsFromZip();
const enabledCounts = useMemo(() => {
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 };
if (!skills) return counts;
skills.forEach((skill) => {
for (const app of APP_IDS) {
@@ -306,7 +306,6 @@ interface ImportSkillsDialogProps {
name: string;
description?: string;
foundIn: string[];
path: string;
}>;
onImport: (directories: string[]) => void;
onClose: () => void;
@@ -363,11 +362,8 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
{skill.description}
</div>
)}
<div
className="text-xs text-muted-foreground/50 mt-1 truncate"
title={skill.path}
>
{skill.path}
<div className="text-xs text-muted-foreground/70 mt-1">
{t("skills.foundIn")}: {skill.foundIn.join(", ")}
</div>
</div>
</label>
-9
View File
@@ -1,9 +0,0 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
-103
View File
@@ -1,103 +0,0 @@
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
const Command = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandInput = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div
className="flex items-center border-b px-3 focus-within:outline-none focus-within:ring-0"
cmdk-input-wrapper=""
>
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-9 w-full rounded-md bg-transparent py-3 text-sm outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandItem = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
export {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
};
-28
View File
@@ -1,28 +0,0 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ComponentRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "start", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 rounded-md border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
@@ -1,95 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import MarkdownEditor from "@/components/MarkdownEditor";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { workspaceApi } from "@/lib/api/workspace";
interface WorkspaceFileEditorProps {
filename: string;
isOpen: boolean;
onClose: () => void;
}
const WorkspaceFileEditor: React.FC<WorkspaceFileEditorProps> = ({
filename,
isOpen,
onClose,
}) => {
const { t } = useTranslation();
const [content, setContent] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!isOpen || !filename) return;
setLoading(true);
workspaceApi
.readFile(filename)
.then((data) => {
setContent(data ?? "");
})
.catch((err) => {
console.error("Failed to read workspace file:", err);
toast.error(t("workspace.loadFailed"));
})
.finally(() => setLoading(false));
}, [isOpen, filename, t]);
const handleSave = useCallback(async () => {
setSaving(true);
try {
await workspaceApi.writeFile(filename, content);
toast.success(t("workspace.saveSuccess"));
} catch (err) {
console.error("Failed to save workspace file:", err);
toast.error(t("workspace.saveFailed"));
} finally {
setSaving(false);
}
}, [filename, content, t]);
return (
<FullScreenPanel
isOpen={isOpen}
title={t("workspace.editing", { filename })}
onClose={onClose}
footer={
<Button onClick={handleSave} disabled={saving || loading}>
{saving ? t("common.saving") : t("common.save")}
</Button>
}
>
{loading ? (
<div className="flex items-center justify-center h-64 text-muted-foreground">
{t("prompts.loading")}
</div>
) : (
<MarkdownEditor
value={content}
onChange={setContent}
darkMode={isDarkMode}
placeholder={`# ${filename}\n\n...`}
minHeight="calc(100vh - 240px)"
/>
)}
</FullScreenPanel>
);
};
export default WorkspaceFileEditor;
@@ -1,129 +0,0 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
FileCode,
Heart,
User,
IdCard,
Wrench,
Brain,
Activity,
Rocket,
Power,
CheckCircle2,
Circle,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { workspaceApi } from "@/lib/api/workspace";
import WorkspaceFileEditor from "./WorkspaceFileEditor";
interface WorkspaceFile {
filename: string;
icon: LucideIcon;
descKey: string;
}
const WORKSPACE_FILES: WorkspaceFile[] = [
{ filename: "AGENTS.md", icon: FileCode, descKey: "workspace.files.agents" },
{ filename: "SOUL.md", icon: Heart, descKey: "workspace.files.soul" },
{ filename: "USER.md", icon: User, descKey: "workspace.files.user" },
{
filename: "IDENTITY.md",
icon: IdCard,
descKey: "workspace.files.identity",
},
{ filename: "TOOLS.md", icon: Wrench, descKey: "workspace.files.tools" },
{ filename: "MEMORY.md", icon: Brain, descKey: "workspace.files.memory" },
{
filename: "HEARTBEAT.md",
icon: Activity,
descKey: "workspace.files.heartbeat",
},
{
filename: "BOOTSTRAP.md",
icon: Rocket,
descKey: "workspace.files.bootstrap",
},
{ filename: "BOOT.md", icon: Power, descKey: "workspace.files.boot" },
];
const WorkspaceFilesPanel: React.FC = () => {
const { t } = useTranslation();
const [editingFile, setEditingFile] = useState<string | null>(null);
const [fileExists, setFileExists] = useState<Record<string, boolean>>({});
const checkFileExistence = async () => {
const results: Record<string, boolean> = {};
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 (
<div className="px-6 pt-4 pb-8">
<p className="text-sm text-muted-foreground mb-6">
~/.openclaw/workspace/
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{WORKSPACE_FILES.map((file) => {
const Icon = file.icon;
const exists = fileExists[file.filename];
return (
<button
key={file.filename}
onClick={() => setEditingFile(file.filename)}
className="flex items-start gap-3 p-4 rounded-xl border border-border bg-card hover:bg-accent/50 transition-colors text-left group"
>
<div className="mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors">
<Icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-foreground">
{file.filename}
</span>
{exists ? (
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-500 flex-shrink-0" />
) : (
<Circle className="w-3.5 h-3.5 text-muted-foreground/40 flex-shrink-0" />
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{t(file.descKey)}
</p>
</div>
</button>
);
})}
</div>
<WorkspaceFileEditor
filename={editingFile ?? ""}
isOpen={!!editingFile}
onClose={handleEditorClose}
/>
</div>
);
};
export default WorkspaceFilesPanel;
+2 -21
View File
@@ -1,11 +1,6 @@
import React from "react";
import type { AppId } from "@/lib/api/types";
import {
ClaudeIcon,
CodexIcon,
GeminiIcon,
OpenClawIcon,
} from "@/components/BrandIcons";
import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons";
import { ProviderIcon } from "@/components/ProviderIcon";
export interface AppConfig {
@@ -15,13 +10,7 @@ export interface AppConfig {
badgeClass: string;
}
export const APP_IDS: AppId[] = [
"claude",
"codex",
"gemini",
"opencode",
"openclaw",
];
export const APP_IDS: AppId[] = ["claude", "codex", "gemini", "opencode"];
export const APP_ICON_MAP: Record<AppId, AppConfig> = {
claude: {
@@ -63,12 +52,4 @@ export const APP_ICON_MAP: Record<AppId, AppConfig> = {
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: <OpenClawIcon size={14} />,
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",
},
};
+4
View File
@@ -99,6 +99,8 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
isPartner: true, // 合作伙伴
partnerPromotionKey: "zhipu", // 促销信息 i18n key
icon: "zhipu",
iconColor: "#0F62FE",
},
@@ -117,6 +119,8 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
isPartner: true, // 合作伙伴
partnerPromotionKey: "zhipu", // 促销信息 i18n key
icon: "zhipu",
iconColor: "#0F62FE",
},
File diff suppressed because it is too large Load Diff
+12 -389
View File
@@ -24,384 +24,6 @@ export const opencodeNpmPackages = [
{ value: "@ai-sdk/google", label: "Google (Gemini)" },
] as const;
export interface PresetModelVariant {
id: string;
name?: string;
contextLimit?: number;
outputLimit?: number;
modalities?: { input: string[]; output: string[] };
options?: Record<string, unknown>;
variants?: Record<string, Record<string, unknown>>;
}
export const OPENCODE_PRESET_MODEL_VARIANTS: Record<
string,
PresetModelVariant[]
> = {
"@ai-sdk/openai-compatible": [
{
id: "MiniMax-M2.1",
name: "MiniMax M2.1",
contextLimit: 204800,
outputLimit: 131072,
modalities: { input: ["text"], output: ["text"] },
},
{
id: "glm-4.7",
name: "GLM 4.7",
contextLimit: 204800,
outputLimit: 131072,
modalities: { input: ["text"], output: ["text"] },
},
{
id: "kimi-k2.5",
name: "Kimi K2.5",
contextLimit: 262144,
outputLimit: 262144,
modalities: { input: ["text", "image", "video"], output: ["text"] },
},
],
"@ai-sdk/google": [
{
id: "gemini-2.5-flash-lite",
name: "Gemini 2.5 Flash Lite",
contextLimit: 1048576,
outputLimit: 65536,
modalities: {
input: ["text", "image", "pdf", "video", "audio"],
output: ["text"],
},
variants: {
auto: {
thinkingConfig: { includeThoughts: true, thinkingBudget: -1 },
},
"no-thinking": { thinkingConfig: { thinkingBudget: 0 } },
},
},
{
id: "gemini-3-flash-preview",
name: "Gemini 3 Flash Preview",
contextLimit: 1048576,
outputLimit: 65536,
modalities: {
input: ["text", "image", "pdf", "video", "audio"],
output: ["text"],
},
variants: {
minimal: {
thinkingConfig: { includeThoughts: true, thinkingLevel: "minimal" },
},
low: {
thinkingConfig: { includeThoughts: true, thinkingLevel: "low" },
},
medium: {
thinkingConfig: { includeThoughts: true, thinkingLevel: "medium" },
},
high: {
thinkingConfig: { includeThoughts: true, thinkingLevel: "high" },
},
},
},
{
id: "gemini-3-pro-preview",
name: "Gemini 3 Pro Preview",
contextLimit: 1048576,
outputLimit: 65536,
modalities: {
input: ["text", "image", "pdf", "video", "audio"],
output: ["text"],
},
variants: {
low: {
thinkingConfig: { includeThoughts: true, thinkingLevel: "low" },
},
high: {
thinkingConfig: { includeThoughts: true, thinkingLevel: "high" },
},
},
},
],
"@ai-sdk/openai": [
{
id: "gpt-5",
name: "GPT-5",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "low",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "high",
},
},
},
{
id: "gpt-5.1",
name: "GPT-5.1",
contextLimit: 400000,
outputLimit: 272000,
modalities: { input: ["text", "image"], output: ["text"] },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "low",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "high",
},
},
},
{
id: "gpt-5.1-codex",
name: "GPT-5.1 Codex",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
options: { include: ["reasoning.encrypted_content"], store: false },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "medium",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "medium",
},
},
},
{
id: "gpt-5.1-codex-max",
name: "GPT-5.1 Codex Max",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
options: { include: ["reasoning.encrypted_content"], store: false },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "medium",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "medium",
},
xhigh: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
textVerbosity: "medium",
},
},
},
{
id: "gpt-5.2",
name: "GPT-5.2",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "medium",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "medium",
},
xhigh: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
textVerbosity: "medium",
},
},
},
{
id: "gpt-5.2-codex",
name: "GPT-5.2 Codex",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
options: { include: ["reasoning.encrypted_content"], store: false },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "medium",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "medium",
},
xhigh: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
textVerbosity: "medium",
},
},
},
{
id: "gpt-5.3-codex",
name: "GPT-5.3 Codex",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
options: { include: ["reasoning.encrypted_content"], store: false },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "medium",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "medium",
},
xhigh: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
textVerbosity: "medium",
},
},
},
],
"@ai-sdk/anthropic": [
{
id: "claude-sonnet-4-5-20250929",
name: "Claude Sonnet 4.5",
contextLimit: 200000,
outputLimit: 64000,
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { effort: "low" },
medium: { effort: "medium" },
high: { effort: "high" },
},
},
{
id: "claude-opus-4-5-20251101",
name: "Claude Opus 4.5",
contextLimit: 200000,
outputLimit: 64000,
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { thinking: { budgetTokens: 5000, type: "enabled" } },
medium: { thinking: { budgetTokens: 13000, type: "enabled" } },
high: { thinking: { budgetTokens: 18000, type: "enabled" } },
},
},
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextLimit: 1000000,
outputLimit: 128000,
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { effort: "low" },
medium: { effort: "medium" },
high: { effort: "high" },
max: { effort: "max" },
},
},
{
id: "claude-haiku-4-5-20251001",
name: "Claude Haiku 4.5",
contextLimit: 200000,
outputLimit: 64000,
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
{
id: "gemini-claude-opus-4-5-thinking",
name: "Antigravity - Claude Opus 4.5",
contextLimit: 200000,
outputLimit: 64000,
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { effort: "low" },
medium: { effort: "medium" },
high: { effort: "high" },
},
},
{
id: "gemini-claude-sonnet-4-5-thinking",
name: "Antigravity - Claude Sonnet 4.5",
contextLimit: 200000,
outputLimit: 64000,
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { thinking: { budgetTokens: 5000, type: "enabled" } },
medium: { thinking: { budgetTokens: 13000, type: "enabled" } },
high: { thinking: { budgetTokens: 18000, type: "enabled" } },
},
},
],
};
/**
* Look up preset metadata for a model by npm package and model ID.
* Returns enrichment fields (options, limit, modalities) that can be
* merged into a model definition when the user's config doesn't already
* provide them.
*/
export function getPresetModelDefaults(
npm: string,
modelId: string,
): PresetModelVariant | undefined {
const models = OPENCODE_PRESET_MODEL_VARIANTS[npm];
if (!models) return undefined;
return models.find((m) => m.id === modelId);
}
export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
{
name: "DeepSeek",
@@ -445,6 +67,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "zhipu",
icon: "zhipu",
iconColor: "#0F62FE",
templateValues: {
@@ -477,6 +101,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "zhipu",
icon: "zhipu",
iconColor: "#0F62FE",
templateValues: {
@@ -844,7 +470,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
models: {
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
"claude-opus-4-6": { name: "Claude Opus 4.6" },
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
},
},
category: "aggregator",
@@ -871,7 +497,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
models: {
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
"claude-opus-4-6": { name: "Claude Opus 4.6" },
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
},
},
category: "aggregator",
@@ -898,7 +524,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
models: {
"anthropic/claude-sonnet-4.5": { name: "Claude Sonnet 4.5" },
"anthropic/claude-opus-4.6": { name: "Claude Opus 4.6" },
"anthropic/claude-opus-4.5": { name: "Claude Opus 4.5" },
},
},
category: "aggregator",
@@ -952,7 +578,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
models: {
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
"claude-opus-4-6": { name: "Claude Opus 4.6" },
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
},
},
category: "third_party",
@@ -980,7 +606,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
models: {
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
"claude-opus-4-6": { name: "Claude Opus 4.6" },
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
},
},
category: "third_party",
@@ -1009,7 +635,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
models: {
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
"claude-opus-4-6": { name: "Claude Opus 4.6" },
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
},
},
category: "third_party",
@@ -1038,10 +664,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
models: {
"gpt-5.2": { name: "GPT-5.2" },
"gpt-5.2-codex": {
name: "GPT-5.2 Codex",
options: { include: ["reasoning.encrypted_content"], store: false },
},
"gpt-5.2-codex": { name: "GPT-5.2 Codex" },
},
},
category: "third_party",
@@ -1070,7 +693,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
models: {
"claude-sonnet-4.5": { name: "Claude Sonnet 4.5" },
"claude-opus-4.6": { name: "Claude Opus 4.6" },
"claude-opus-4.5": { name: "Claude Opus 4.5" },
},
},
category: "third_party",
-131
View File
@@ -1,131 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { openclawApi } from "@/lib/api/openclaw";
import { providersApi } from "@/lib/api/providers";
import type {
OpenClawEnvConfig,
OpenClawToolsConfig,
OpenClawAgentsDefaults,
} from "@/types";
/**
* Centralized query keys for all OpenClaw-related queries.
* Import this from any file that needs to invalidate OpenClaw caches.
*/
export const openclawKeys = {
all: ["openclaw"] as const,
liveProviderIds: ["openclaw", "liveProviderIds"] as const,
defaultModel: ["openclaw", "defaultModel"] as const,
env: ["openclaw", "env"] as const,
tools: ["openclaw", "tools"] as const,
agentsDefaults: ["openclaw", "agentsDefaults"] as const,
};
// ============================================================
// Query hooks
// ============================================================
/**
* Query live provider IDs from openclaw.json config.
* Used by ProviderList to show "In Config" badge.
*/
export function useOpenClawLiveProviderIds(enabled: boolean) {
return useQuery({
queryKey: openclawKeys.liveProviderIds,
queryFn: () => providersApi.getOpenClawLiveProviderIds(),
enabled,
});
}
/**
* Query the default model from agents.defaults.model.
* Used by ProviderList to show which provider is the default.
*/
export function useOpenClawDefaultModel(enabled: boolean) {
return useQuery({
queryKey: openclawKeys.defaultModel,
queryFn: () => openclawApi.getDefaultModel(),
enabled,
});
}
/**
* Query env section of openclaw.json.
*/
export function useOpenClawEnv() {
return useQuery({
queryKey: openclawKeys.env,
queryFn: () => openclawApi.getEnv(),
staleTime: 30_000,
});
}
/**
* Query tools section of openclaw.json.
*/
export function useOpenClawTools() {
return useQuery({
queryKey: openclawKeys.tools,
queryFn: () => openclawApi.getTools(),
staleTime: 30_000,
});
}
/**
* Query agents.defaults section of openclaw.json.
*/
export function useOpenClawAgentsDefaults() {
return useQuery({
queryKey: openclawKeys.agentsDefaults,
queryFn: () => openclawApi.getAgentsDefaults(),
staleTime: 30_000,
});
}
// ============================================================
// Mutation hooks
// ============================================================
/**
* Save env config. Invalidates env query on success.
* Toast notifications are handled by the component.
*/
export function useSaveOpenClawEnv() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (env: OpenClawEnvConfig) => openclawApi.setEnv(env),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: openclawKeys.env });
},
});
}
/**
* Save tools config. Invalidates tools query on success.
* Toast notifications are handled by the component.
*/
export function useSaveOpenClawTools() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (tools: OpenClawToolsConfig) => openclawApi.setTools(tools),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: openclawKeys.tools });
},
});
}
/**
* Save agents.defaults config. Invalidates both agentsDefaults and defaultModel
* queries on success (since changing agents.defaults may affect the default model).
* Toast notifications are handled by the component.
*/
export function useSaveOpenClawAgentsDefaults() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (defaults: OpenClawAgentsDefaults) =>
openclawApi.setAgentsDefaults(defaults),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: openclawKeys.agentsDefaults });
queryClient.invalidateQueries({ queryKey: openclawKeys.defaultModel });
},
});
}
+11 -107
View File
@@ -2,14 +2,8 @@ import { useCallback } from "react";
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,
OpenClawProviderConfig,
OpenClawDefaultModel,
} from "@/types";
import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets";
import { providersApi, settingsApi, type AppId } from "@/lib/api";
import type { Provider, UsageScript } from "@/types";
import {
useAddProviderMutation,
useUpdateProviderMutation,
@@ -17,7 +11,6 @@ import {
useSwitchProviderMutation,
} from "@/lib/query";
import { extractErrorMessage } from "@/utils/errorUtils";
import { openclawKeys } from "@/hooks/useOpenClaw";
/**
* Hook for managing provider actions (add, update, delete, switch)
@@ -61,55 +54,10 @@ export function useProviderActions(activeApp: AppId) {
// 添加供应商
const addProvider = useCallback(
async (
provider: Omit<Provider, "id"> & {
providerKey?: string;
suggestedDefaults?: OpenClawSuggestedDefaults;
},
) => {
async (provider: Omit<Provider, "id"> & { providerKey?: string }) => {
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, activeApp, t],
[addProviderMutation],
);
// 更新供应商
@@ -156,15 +104,13 @@ export function useProviderActions(activeApp: AppId) {
);
} else {
// 普通供应商:显示切换成功
// 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
? "已添加到配置"
: "切换成功!";
// OpenCode: show "added to config" message instead of "switched"
const messageKey =
activeApp === "opencode"
? "notifications.addToConfigSuccess"
: "notifications.switchSuccess";
const defaultMessage =
activeApp === "opencode" ? "已添加到配置" : "切换成功!";
toast.success(t(messageKey, { defaultValue: defaultMessage }), {
closeButton: true,
@@ -224,54 +170,12 @@ export function useProviderActions(activeApp: AppId) {
[activeApp, queryClient, t],
);
// Set provider as default model (OpenClaw only)
const setAsDefaultModel = useCallback(
async (provider: Provider) => {
const config = provider.settingsConfig as OpenClawProviderConfig;
if (!config.models || config.models.length === 0) {
toast.error(
t("notifications.openclawNoModels", {
defaultValue: "该供应商没有配置模型",
}),
);
return;
}
const model: OpenClawDefaultModel = {
primary: `${provider.id}/${config.models[0].id}`,
fallbacks: config.models.slice(1).map((m) => `${provider.id}/${m.id}`),
};
try {
await openclawApi.setDefaultModel(model);
await queryClient.invalidateQueries({
queryKey: openclawKeys.defaultModel,
});
toast.success(
t("notifications.openclawDefaultModelSet", {
defaultValue: "已设为默认模型",
}),
{ closeButton: true },
);
} catch (error) {
const detail =
extractErrorMessage(error) ||
t("notifications.openclawDefaultModelSetFailed", {
defaultValue: "设置默认模型失败",
});
toast.error(detail);
}
},
[queryClient, t],
);
return {
addProvider,
updateProvider,
switchProvider,
deleteProvider,
saveUsageScript,
setAsDefaultModel,
isLoading:
addProviderMutation.isPending ||
updateProviderMutation.isPending ||
+2 -4
View File
@@ -135,10 +135,9 @@ export function useSettings(): UseSettingsResult {
const sanitizedOpencodeDir = sanitizeDir(
mergedSettings.opencodeConfigDir,
);
const { webdavSync: _ignoredWebdavSync, ...restSettings } = mergedSettings;
const payload: Settings = {
...restSettings,
...mergedSettings,
claudeConfigDir: sanitizedClaudeDir,
codexConfigDir: sanitizedCodexDir,
geminiConfigDir: sanitizedGeminiDir,
@@ -252,10 +251,9 @@ 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 = {
...restSettings,
...mergedSettings,
claudeConfigDir: sanitizedClaudeDir,
codexConfigDir: sanitizedCodexDir,
geminiConfigDir: sanitizedGeminiDir,
+12 -271
View File
@@ -88,8 +88,6 @@
"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",
@@ -164,11 +162,7 @@
"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",
"openclawModelsRegistered": "Models have been registered to /model list",
"openclawDefaultModelSet": "Set as default model",
"openclawDefaultModelSetFailed": "Failed to set default model",
"openclawNoModels": "No models configured"
"openLinkFailed": "Failed to open link"
},
"confirm": {
"deleteProvider": "Delete Provider",
@@ -272,77 +266,6 @@
"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",
@@ -484,8 +407,7 @@
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode",
"openclaw": "OpenClaw"
"opencode": "OpenCode"
},
"sessionManager": {
"title": "Session Manager",
@@ -568,6 +490,7 @@
"officialHint": "💡 Official provider uses browser login, no API Key needed",
"getApiKey": "Get API Key",
"partnerPromotion": {
"zhipu": "Zhipu GLM is an official partner of CC Switch. Use this link to top up and get a 10% discount",
"packycode": "PackyCode is an official partner of CC Switch. Register using this link and enter \"cc-switch\" promo code during recharge to get 10% off",
"minimax_cn": "MiniMax Coding Plan Special Offer, Starter from ¥9.9",
"minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)",
@@ -738,38 +661,6 @@
"modelOptionKeyPlaceholder": "provider",
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
},
"openclaw": {
"providerKey": "Provider Key",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
"providerKeyRequired": "Provider key is required",
"providerKeyDuplicate": "This key is already in use",
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
"apiProtocol": "API Protocol",
"selectProtocol": "Select API Protocol",
"apiProtocolHint": "Select the protocol type compatible with the provider's API. Most providers use OpenAI Completions format.",
"baseUrl": "API Endpoint",
"baseUrlHint": "The provider's API endpoint address.",
"models": "Models",
"addModel": "Add Model",
"noModels": "No models configured. Click Add Model to configure available models.",
"modelId": "Model ID",
"modelIdPlaceholder": "claude-3-sonnet",
"modelName": "Display Name",
"modelNamePlaceholder": "Claude 3 Sonnet",
"contextWindow": "Context Window",
"maxTokens": "Max Output Tokens",
"reasoning": "Reasoning Mode",
"reasoningOn": "Enabled",
"reasoningOff": "Disabled",
"inputCost": "Input Cost ($/M tokens)",
"outputCost": "Output Cost ($/M tokens)",
"advancedOptions": "Advanced Options",
"cacheReadCost": "Cache Read Cost ($/M tokens)",
"cacheWriteCost": "Cache Write Cost ($/M tokens)",
"cacheCostHint": "Cache costs are used to calculate Prompt Caching costs. Leave empty if not using caching.",
"modelsHint": "Configure the models supported by this provider. Model ID is used for API calls, Display Name for the interface."
},
"providerPreset": {
"label": "Provider Preset",
"custom": "Custom Configuration",
@@ -965,8 +856,7 @@
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode",
"openclaw": "OpenClaw"
"opencode": "OpenCode"
}
},
"userLevelPath": "User-level MCP path",
@@ -1139,74 +1029,6 @@
"deleteMessage": "Are you sure you want to delete prompt \"{{name}}\"?"
}
},
"workspace": {
"title": "Workspace Files",
"manage": "Workspace",
"files": {
"agents": "Agent instructions and rules",
"soul": "Agent personality and communication style",
"user": "User profile and preferences",
"identity": "Agent name and avatar",
"tools": "Local tool documentation",
"memory": "Long-term memory and decisions",
"heartbeat": "Heartbeat run checklist",
"bootstrap": "First-run bootstrap ritual",
"boot": "Gateway reboot checklist"
},
"editing": "Edit {{filename}}",
"saveSuccess": "Saved successfully",
"saveFailed": "Failed to save",
"loadFailed": "Failed to load"
},
"openclaw": {
"env": {
"title": "Environment Variables",
"description": "Manage environment variables in openclaw.json (API keys, custom variables, etc.)",
"keyPlaceholder": "Variable name",
"valuePlaceholder": "Value",
"add": "Add Variable",
"saveSuccess": "Environment variables saved",
"saveFailed": "Failed to save environment variables",
"loadFailed": "Failed to load environment variables",
"duplicateKey": "Duplicate variable name detected: {{key}}"
},
"tools": {
"title": "Tool Permissions",
"description": "Manage tool permissions in openclaw.json (allow/deny lists)",
"profile": "Permission Profile",
"profiles": {
"default": "Default",
"strict": "Strict",
"permissive": "Permissive",
"custom": "Custom"
},
"allowList": "Allow List",
"denyList": "Deny List",
"patternPlaceholder": "Tool name or pattern",
"addAllow": "Add Allow",
"addDeny": "Add Deny",
"saveSuccess": "Tool permissions saved",
"saveFailed": "Failed to save tool permissions",
"loadFailed": "Failed to load tool permissions"
},
"agents": {
"title": "Agents Config",
"description": "Manage agents.defaults in openclaw.json (default model, runtime parameters, etc.)",
"modelSection": "Model Configuration",
"primaryModel": "Primary Model",
"primaryModelHint": "Format: provider/model-id",
"fallbackModels": "Fallback Models",
"fallbackModelsHint": "Comma-separated, ordered by priority",
"runtimeSection": "Runtime Parameters",
"workspace": "Workspace Path",
"timeout": "Timeout (seconds)",
"contextTokens": "Context Tokens",
"maxConcurrent": "Max Concurrent",
"saveSuccess": "Agents config saved",
"saveFailed": "Failed to save agents config",
"loadFailed": "Failed to load agents config"
}
},
"env": {
"warning": {
"title": "Environment Variable Conflicts Detected",
@@ -1322,7 +1144,7 @@
"skillCount": "{{count}} skills detected"
},
"search": "Search Skills",
"searchPlaceholder": "Search skill name or repo...",
"searchPlaceholder": "Search skill name or description...",
"filter": {
"placeholder": "Filter by status",
"all": "All",
@@ -1349,8 +1171,7 @@
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode",
"openclaw": "OpenClaw"
"opencode": "OpenCode"
},
"installFromZip": {
"button": "Install from ZIP",
@@ -1753,21 +1574,16 @@
"clearWrapped": "(Clear)",
"defaultWrapped": "(Default)",
"variantPlaceholder": "variant",
"selectEnabledModel": "Select configured model",
"selectModel": "Select configured model",
"recommendedHint": "Recommended: {{model}}",
"searchModel": "Search model...",
"selectEnabledModel": "Select enabled model",
"selectModelFirst": "Select model first",
"noEnabledModels": "No configured models",
"noEnabledModels": "No enabled models",
"noVariantsForModel": "No variants for model",
"currentValueNotEnabled": "{{value}} (current value, not configured)",
"currentValueNotEnabled": "{{value}} (current value, not enabled)",
"currentValueUnavailable": "{{value}} (current value, unavailable)",
"advancedLabel": "Advanced",
"advancedJsonInvalid": "Advanced JSON is invalid",
"advancedJsonHint": "temperature, top_p, budgetTokens, prompt_append, permission, etc. Leave empty for defaults",
"noEnabledModelsWarning": "No configured models available. Configure OpenCode models first.",
"modelSourcePartialWarning": "Some provider model configs are invalid and were skipped.",
"modelSourceFallbackWarning": "Failed to load live provider state. Falling back to configured providers.",
"noEnabledModelsWarning": "No enabled models available. Configure and enable OpenCode models first.",
"importLocalReplaceSuccess": "Imported local file and replaced Agents/Categories/Other Fields",
"importLocalFailed": "Failed to read local file: {{error}}",
"agentKeyPlaceholder": "agent key",
@@ -1778,7 +1594,7 @@
"modelConfiguration": "Model Configuration",
"fillRecommended": "Fill Recommended",
"configSummary": "{{agents}} agents, {{categories}} categories configured · Click ⚙ for advanced params",
"enabledModelsCount": "{{count}} configured models available",
"enabledModelsCount": "{{count}} enabled models available",
"source": "from:",
"otherFieldsJson": "Other Fields (JSON)",
"searchOrType": "Search or type custom value...",
@@ -1804,81 +1620,6 @@
"advancedExperimental": "Experimental Features",
"advancedBackgroundTask": "Background Tasks",
"advancedBrowserAutomation": "Browser Automation",
"advancedClaudeCode": "Claude Code",
"agentDesc": {
"sisyphus": "Main orchestrator",
"hephaestus": "Autonomous deep worker",
"prometheus": "Strategic planner",
"atlas": "Task manager",
"oracle": "Strategic advisor",
"librarian": "Multi-repo researcher",
"explore": "Fast code search",
"multimodalLooker": "Media analyzer",
"metis": "Pre-plan analysis advisor",
"momus": "Plan reviewer",
"sisyphusJunior": "Delegated task executor"
},
"agentTooltip": {
"sisyphus": "Main orchestrator responsible for task planning, delegation and parallel execution. Uses extended thinking (32k budget) and drives workflows through TODO lists to ensure task completion.",
"atlas": "Main orchestrator (holds TODO list) responsible for task distribution and coordination during execution phase. Delegates to specialized agents rather than completing all work directly.",
"prometheus": "Strategic planner that collects requirements through interview mode and creates detailed work plans. Can only read/write Markdown files in .sisyphus/ directory, never writes code directly.",
"hephaestus": "Autonomous deep worker (\"legitimate craftsman\") inspired by AmpCode deep mode. Goal-oriented execution that launches 2-5 explore/librarian agents in parallel for research before taking action.",
"oracle": "Architecture decision and debugging advisor. Read-only consulting agent providing excellent logical reasoning and deep analysis. Cannot write files or delegate tasks.",
"librarian": "Multi-repository analysis and documentation retrieval expert. Deeply understands codebases and provides evidence-based answers. Excels at finding official documentation and open-source implementation examples.",
"explore": "Fast codebase exploration and context grep expert. Uses lightweight models for high-speed search. The scout for understanding code structure.",
"multimodalLooker": "Visual content expert that analyzes PDFs, images, charts and other non-text media, extracting information and insights from them.",
"metis": "Plan consultant that performs pre-analysis before planning. Identifies hidden intents, ambiguities and AI failure points to prevent over-engineering.",
"momus": "Plan reviewer that validates plan clarity, verifiability and completeness with high precision. Rejects and requests revisions until plans are perfect.",
"sisyphusJunior": "Category-generated executor, automatically created via category parameters. Focuses on executing assigned tasks and cannot re-delegate, preventing infinite delegation loops."
},
"categoryDesc": {
"visualEngineering": "Visual/frontend engineering",
"ultrabrain": "Ultra thinking",
"deep": "Deep work",
"artistry": "Creative/artistic",
"quick": "Quick response",
"unspecifiedLow": "General low tier",
"unspecifiedHigh": "General high tier",
"writing": "Writing"
},
"categoryTooltip": {
"visualEngineering": "Frontend and visual engineering category for UI/UX design, styling, animation and interface implementation. Defaults to Gemini 3 Pro model.",
"ultrabrain": "Deep logical reasoning category for complex architectural decisions requiring extensive analysis. Defaults to GPT-5.3 Codex ultra-high reasoning variant.",
"deep": "Deep autonomous problem-solving category with goal-oriented execution. Conducts thorough research before action, suitable for difficult problems requiring deep understanding.",
"artistry": "Highly creative and artistic task category that inspires novel ideas and creative solutions. Defaults to Gemini 3 Pro max variant.",
"quick": "Lightweight task category for single-file modifications, typo fixes, and simple adjustments. Defaults to Claude Haiku 4.5 fast model.",
"unspecifiedLow": "Uncategorized low-effort task category for tasks that don't fit other categories with small workload. Defaults to Claude Sonnet 4.5.",
"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"
"advancedClaudeCode": "Claude Code"
}
}
+12 -271
View File
@@ -88,8 +88,6 @@
"addOpenCodeProvider": "OpenCode プロバイダーを追加",
"addToConfig": "追加",
"removeFromConfig": "削除",
"setAsDefault": "有効化",
"isDefault": "デフォルト",
"inConfig": "追加済み",
"addProviderHint": "一覧にすばやく切り替えられるよう、ここに情報を入力してください。",
"editClaudeProvider": "Claude Code プロバイダーを編集",
@@ -164,11 +162,7 @@
"settingsSaved": "設定を保存しました",
"settingsSaveFailed": "設定の保存に失敗しました: {{error}}",
"openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です",
"openLinkFailed": "リンクを開けませんでした",
"openclawModelsRegistered": "モデルが /model リストに登録されました",
"openclawDefaultModelSet": "デフォルトモデルに設定しました",
"openclawDefaultModelSetFailed": "デフォルトモデルの設定に失敗しました",
"openclawNoModels": "モデルが設定されていません"
"openLinkFailed": "リンクを開けませんでした"
},
"confirm": {
"deleteProvider": "プロバイダーを削除",
@@ -272,77 +266,6 @@
"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",
@@ -484,8 +407,7 @@
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode",
"openclaw": "OpenClaw"
"opencode": "OpenCode"
},
"sessionManager": {
"title": "セッション管理",
@@ -568,6 +490,7 @@
"officialHint": "💡 公式プロバイダーはブラウザログインで、API Key は不要です",
"getApiKey": "API Key を取得",
"partnerPromotion": {
"zhipu": "Zhipu GLM は CC Switch の公式パートナーです。リンク経由でチャージすると 10% 割引",
"packycode": "PackyCode は CC Switch の公式パートナーです。登録後チャージ時に \"cc-switch\" を入力すると 10% オフ",
"minimax_cn": "MiniMax Coding Plan 特別価格、Starter ¥9.9 から",
"minimax_en": "MiniMax Coding Plan Black Friday、Starter が月額 $280% OFF",
@@ -738,38 +661,6 @@
"modelOptionKeyPlaceholder": "provider",
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
},
"openclaw": {
"providerKey": "プロバイダーキー",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。",
"providerKeyRequired": "プロバイダーキーを入力してください",
"providerKeyDuplicate": "このキーは既に使用されています",
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。",
"apiProtocol": "API プロトコル",
"selectProtocol": "API プロトコルを選択",
"apiProtocolHint": "プロバイダーの API と互換性のあるプロトコルタイプを選択してください。ほとんどのプロバイダーは OpenAI Completions 形式を使用します。",
"baseUrl": "API エンドポイント",
"baseUrlHint": "プロバイダーの API エンドポイントアドレス。",
"models": "モデル一覧",
"addModel": "モデルを追加",
"noModels": "モデルが設定されていません。「モデルを追加」をクリックして利用可能なモデルを設定してください。",
"modelId": "モデル ID",
"modelIdPlaceholder": "claude-3-sonnet",
"modelName": "表示名",
"modelNamePlaceholder": "Claude 3 Sonnet",
"contextWindow": "コンテキストウィンドウ",
"maxTokens": "最大出力トークン数",
"reasoning": "推論モード",
"reasoningOn": "有効",
"reasoningOff": "無効",
"inputCost": "入力コスト ($/M トークン)",
"outputCost": "出力コスト ($/M トークン)",
"advancedOptions": "詳細オプション",
"cacheReadCost": "キャッシュ読取コスト ($/M トークン)",
"cacheWriteCost": "キャッシュ書込コスト ($/M トークン)",
"cacheCostHint": "キャッシュコストは Prompt Caching のコスト計算に使用されます。キャッシュを使用しない場合は空欄のままにしてください。",
"modelsHint": "このプロバイダーがサポートするモデルを設定します。モデル ID は API 呼び出しに、表示名はインターフェースに使用されます。"
},
"providerPreset": {
"label": "プロバイダータイプ",
"custom": "カスタム設定",
@@ -965,8 +856,7 @@
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode",
"openclaw": "OpenClaw"
"opencode": "OpenCode"
}
},
"userLevelPath": "ユーザーレベルの MCP パス",
@@ -1139,74 +1029,6 @@
"deleteMessage": "プロンプト「{{name}}」を削除してもよろしいですか?"
}
},
"workspace": {
"title": "ワークスペースファイル",
"manage": "ワークスペース",
"files": {
"agents": "エージェントの操作指示とルール",
"soul": "エージェントの人格とコミュニケーションスタイル",
"user": "ユーザープロファイルと設定",
"identity": "エージェントの名前とアバター",
"tools": "ローカルツールドキュメント",
"memory": "長期記憶と意思決定記録",
"heartbeat": "ハートビート実行チェックリスト",
"bootstrap": "初回実行ブートストラップ",
"boot": "ゲートウェイ再起動チェックリスト"
},
"editing": "{{filename}} を編集",
"saveSuccess": "保存しました",
"saveFailed": "保存に失敗しました",
"loadFailed": "読み込みに失敗しました"
},
"openclaw": {
"env": {
"title": "環境変数",
"description": "openclaw.json の環境変数設定を管理(APIキー、カスタム変数など)",
"keyPlaceholder": "変数名",
"valuePlaceholder": "値",
"add": "変数を追加",
"saveSuccess": "環境変数を保存しました",
"saveFailed": "環境変数の保存に失敗しました",
"loadFailed": "環境変数の読み込みに失敗しました",
"duplicateKey": "重複する変数名が検出されました: {{key}}"
},
"tools": {
"title": "ツール権限",
"description": "openclaw.json のツール権限設定を管理(許可/拒否リスト)",
"profile": "権限プロファイル",
"profiles": {
"default": "デフォルト",
"strict": "厳格",
"permissive": "寛容",
"custom": "カスタム"
},
"allowList": "許可リスト",
"denyList": "拒否リスト",
"patternPlaceholder": "ツール名またはパターン",
"addAllow": "許可を追加",
"addDeny": "拒否を追加",
"saveSuccess": "ツール権限を保存しました",
"saveFailed": "ツール権限の保存に失敗しました",
"loadFailed": "ツール権限の読み込みに失敗しました"
},
"agents": {
"title": "Agents 設定",
"description": "openclaw.json の agents.defaults 設定を管理(デフォルトモデル、ランタイムパラメータなど)",
"modelSection": "モデル設定",
"primaryModel": "プライマリモデル",
"primaryModelHint": "形式: provider/model-id",
"fallbackModels": "フォールバックモデル",
"fallbackModelsHint": "カンマ区切り、優先度順",
"runtimeSection": "ランタイムパラメータ",
"workspace": "ワークスペースパス",
"timeout": "タイムアウト(秒)",
"contextTokens": "コンテキストトークン数",
"maxConcurrent": "最大同時実行数",
"saveSuccess": "Agents 設定を保存しました",
"saveFailed": "Agents 設定の保存に失敗しました",
"loadFailed": "Agents 設定の読み込みに失敗しました"
}
},
"env": {
"warning": {
"title": "競合する環境変数を検出しました",
@@ -1320,7 +1142,7 @@
"skillCount": "{{count}} 件のスキルを検出"
},
"search": "スキルを検索",
"searchPlaceholder": "スキル名またはリポジトリで検索...",
"searchPlaceholder": "スキル名または説明で検索...",
"filter": {
"placeholder": "状態で絞り込み",
"all": "すべて",
@@ -1347,8 +1169,7 @@
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode",
"openclaw": "OpenClaw"
"opencode": "OpenCode"
},
"installFromZip": {
"button": "ZIP からインストール",
@@ -1734,21 +1555,16 @@
"clearWrapped": "(クリア)",
"defaultWrapped": "(デフォルト)",
"variantPlaceholder": "variant",
"selectEnabledModel": "設定済みモデルを選択",
"selectModel": "設定済みモデルを選択",
"recommendedHint": "推奨: {{model}}",
"searchModel": "モデルを検索...",
"selectEnabledModel": "有効なモデルを選択",
"selectModelFirst": "先にモデルを選択",
"noEnabledModels": "設定済みモデルがありません",
"noEnabledModels": "有効なモデルがありません",
"noVariantsForModel": "このモデルには思考レベルがありません",
"currentValueNotEnabled": "{{value}} (現在値・未設定)",
"currentValueNotEnabled": "{{value}} (現在値・未有効)",
"currentValueUnavailable": "{{value}} (現在値・利用不可)",
"advancedLabel": "詳細",
"advancedJsonInvalid": "詳細 JSON が不正です",
"advancedJsonHint": "temperature, top_p, budgetTokens, prompt_append, permission など。空欄でデフォルトを使用します",
"noEnabledModelsWarning": "利用可能な設定済みモデルがありません。先に OpenCode モデルを設定してください。",
"modelSourcePartialWarning": "一部プロバイダーのモデル設定が不正なため、候補から除外しました。",
"modelSourceFallbackWarning": "live プロバイダー状態の取得に失敗したため、設定済みプロバイダーへフォールバックしました。",
"noEnabledModelsWarning": "利用可能な有効モデルがありません。先に OpenCode モデルを有効化してください。",
"importLocalReplaceSuccess": "ローカルファイルから読み込み、Agents/Categories/Other Fields を置き換えました",
"importLocalFailed": "ローカルファイルの読み込みに失敗しました: {{error}}",
"agentKeyPlaceholder": "agent キー",
@@ -1759,7 +1575,7 @@
"modelConfiguration": "モデル設定",
"fillRecommended": "推奨を入力",
"configSummary": "{{agents}} 個の Agent、{{categories}} 個の Category を設定済み · ⚙ で詳細を展開",
"enabledModelsCount": "設定済みモデル {{count}} 件",
"enabledModelsCount": "有効モデル {{count}} 件",
"source": "出典:",
"otherFieldsJson": "その他のフィールド (JSON)",
"searchOrType": "検索またはカスタム値を入力...",
@@ -1785,81 +1601,6 @@
"advancedExperimental": "実験的機能",
"advancedBackgroundTask": "バックグラウンドタスク",
"advancedBrowserAutomation": "ブラウザ自動化",
"advancedClaudeCode": "Claude Code",
"agentDesc": {
"sisyphus": "メインオーケストレーター",
"hephaestus": "自律型ディープワーカー",
"prometheus": "戦略プランナー",
"atlas": "タスクマネージャー",
"oracle": "戦略アドバイザー",
"librarian": "マルチリポジトリ研究員",
"explore": "高速コード検索",
"multimodalLooker": "メディアアナライザー",
"metis": "計画前分析アドバイザー",
"momus": "プランレビュアー",
"sisyphusJunior": "委任タスクエグゼキューター"
},
"agentTooltip": {
"sisyphus": "メインオーケストレーター。タスクの計画、委任、並列実行を担当。拡張思考(32k バジェット)を使用し、TODO リストでワークフローを駆動してタスク完了を確保します。",
"atlas": "メインオーケストレーター(TODO リスト保持)。実行フェーズでのタスク配分と調整を担当。すべての作業を直接行うのではなく、専門エージェントに委任します。",
"prometheus": "戦略プランナー。インタビューモードで要件を収集し、詳細な作業計画を策定。.sisyphus/ ディレクトリ内の Markdown ファイルの読み書きのみ可能で、直接コードを書くことはありません。",
"hephaestus": "自律型ディープワーカー(「正当な職人」)。AmpCode ディープモードに着想を得た目標指向の実行を行い、行動前に 2-5 個の探索/ライブラリアンエージェントを並列起動して調査します。",
"oracle": "アーキテクチャ決定とデバッグのアドバイザー。読み取り専用のコンサルティングエージェントで、優れた論理的推論と深い分析を提供。ファイルの書き込みやタスクの委任はできません。",
"librarian": "マルチリポジトリ分析とドキュメント検索の専門家。コードベースを深く理解し、エビデンスに基づく回答を提供。公式ドキュメントやオープンソース実装例の検索に長けています。",
"explore": "高速コードベース探索とコンテキスト grep の専門家。軽量モデルを使用した高速検索で、コード構造を理解するための先鋒です。",
"multimodalLooker": "ビジュアルコンテンツの専門家。PDF、画像、グラフなどの非テキストメディアを分析し、情報とインサイトを抽出します。",
"metis": "プランコンサルタント。計画前に事前分析を行い、隠れた意図、曖昧な点、AI の失敗ポイントを特定して、過剰エンジニアリングを防止します。",
"momus": "プランレビュアー。計画の明確さ、検証可能性、完全性を高精度で検証。計画が完璧になるまで却下と修正を要求します。",
"sisyphusJunior": "カテゴリ生成のエグゼキューター。category パラメータにより自動生成され、割り当てられたタスクの実行に専念し、再委任はできません。無限委任ループを防止します。"
},
"categoryDesc": {
"visualEngineering": "ビジュアル/フロントエンド工学",
"ultrabrain": "超深度思考",
"deep": "ディープワーク",
"artistry": "クリエイティブ/芸術",
"quick": "クイックレスポンス",
"unspecifiedLow": "汎用ロースペック",
"unspecifiedHigh": "汎用ハイスペック",
"writing": "ライティング"
},
"categoryTooltip": {
"visualEngineering": "フロントエンドとビジュアルエンジニアリングカテゴリ。UI/UX デザイン、スタイリング、アニメーション、インターフェース実装に特化。デフォルトで Gemini 3 Pro モデルを使用。",
"ultrabrain": "ディープロジック推論カテゴリ。広範な分析が必要な複雑なアーキテクチャ決定に使用。デフォルトで GPT-5.3 Codex の超高推論バリアントを使用。",
"deep": "ディープ自律問題解決カテゴリ。目標指向の実行で、行動前に徹底的な調査を実施。深い理解が必要な難問に適しています。",
"artistry": "高度にクリエイティブで芸術的なタスクカテゴリ。斬新なアイデアとクリエイティブなソリューションを促進。デフォルトで Gemini 3 Pro の max バリアントを使用。",
"quick": "軽量タスクカテゴリ。単一ファイルの修正、タイポ修正、簡単な調整などの些細な作業に使用。デフォルトで Claude Haiku 4.5 高速モデルを使用。",
"unspecifiedLow": "未分類の低作業量タスクカテゴリ。他のカテゴリに該当せず作業量が小さいタスクに適用。デフォルトで Claude Sonnet 4.5 を使用。",
"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": "このプリセットの推奨デフォルトモデル設定を使用します"
"advancedClaudeCode": "Claude Code"
}
}
+13 -272
View File
@@ -88,8 +88,6 @@
"addOpenCodeProvider": "添加 OpenCode 供应商",
"addToConfig": "添加",
"removeFromConfig": "移除",
"setAsDefault": "启用",
"isDefault": "默认",
"inConfig": "已添加",
"addProviderHint": "填写信息后即可在列表中快速切换供应商。",
"editClaudeProvider": "编辑 Claude Code 供应商",
@@ -164,11 +162,7 @@
"settingsSaved": "设置已保存",
"settingsSaveFailed": "保存设置失败:{{error}}",
"openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用",
"openLinkFailed": "链接打开失败",
"openclawModelsRegistered": "模型已注册到 /model 列表",
"openclawDefaultModelSet": "已设为默认模型",
"openclawDefaultModelSetFailed": "设置默认模型失败",
"openclawNoModels": "该供应商没有配置模型"
"openLinkFailed": "链接打开失败"
},
"confirm": {
"deleteProvider": "删除供应商",
@@ -272,77 +266,6 @@
"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",
@@ -484,8 +407,7 @@
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode",
"openclaw": "OpenClaw"
"opencode": "OpenCode"
},
"sessionManager": {
"title": "会话管理",
@@ -568,6 +490,7 @@
"officialHint": "💡 官方供应商使用浏览器登录,无需配置 API Key",
"getApiKey": "获取 API Key",
"partnerPromotion": {
"zhipu": "智谱 GLM 是 CC Switch 的官方合作伙伴,使用此链接充值可以获得9折优惠",
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠",
"minimax_cn": "MiniMax Coding Plan 特惠,Starter 套餐 9.9 元起",
"minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)",
@@ -738,38 +661,6 @@
"modelOptionKeyPlaceholder": "provider",
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
},
"openclaw": {
"providerKey": "供应商标识",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
"providerKeyRequired": "请填写供应商标识",
"providerKeyDuplicate": "此标识已被使用,请更换",
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
"apiProtocol": "API 协议",
"selectProtocol": "选择 API 协议",
"apiProtocolHint": "选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。",
"baseUrl": "API 端点",
"baseUrlHint": "供应商的 API 端点地址。",
"models": "模型列表",
"addModel": "添加模型",
"noModels": "暂无模型配置。点击添加模型来配置可用模型。",
"modelId": "模型 ID",
"modelIdPlaceholder": "claude-3-sonnet",
"modelName": "显示名称",
"modelNamePlaceholder": "Claude 3 Sonnet",
"contextWindow": "上下文窗口",
"maxTokens": "最大输出 Tokens",
"reasoning": "推理模式",
"reasoningOn": "启用",
"reasoningOff": "关闭",
"inputCost": "输入价格 ($/M tokens)",
"outputCost": "输出价格 ($/M tokens)",
"advancedOptions": "高级选项",
"cacheReadCost": "缓存读取价格 ($/M tokens)",
"cacheWriteCost": "缓存写入价格 ($/M tokens)",
"cacheCostHint": "缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。",
"modelsHint": "配置该供应商支持的模型。模型 ID 用于 API 调用,显示名称用于界面展示。"
},
"providerPreset": {
"label": "预设供应商",
"custom": "自定义配置",
@@ -965,8 +856,7 @@
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode",
"openclaw": "OpenClaw"
"opencode": "OpenCode"
}
},
"userLevelPath": "用户级 MCP 配置路径",
@@ -1139,74 +1029,6 @@
"deleteMessage": "确定要删除提示词 \"{{name}}\" 吗?"
}
},
"workspace": {
"title": "Workspace 文件管理",
"manage": "Workspace",
"files": {
"agents": "Agent 操作指令和规则",
"soul": "Agent 人格和沟通风格",
"user": "用户档案和偏好",
"identity": "Agent 名称和头像",
"tools": "本地工具文档",
"memory": "长期记忆和决策记录",
"heartbeat": "心跳运行清单",
"bootstrap": "首次运行仪式",
"boot": "网关重启清单"
},
"editing": "编辑 {{filename}}",
"saveSuccess": "保存成功",
"saveFailed": "保存失败",
"loadFailed": "读取失败"
},
"openclaw": {
"env": {
"title": "环境变量",
"description": "管理 openclaw.json 中的环境变量配置(API Key、自定义变量等)",
"keyPlaceholder": "变量名",
"valuePlaceholder": "值",
"add": "添加变量",
"saveSuccess": "环境变量已保存",
"saveFailed": "保存环境变量失败",
"loadFailed": "读取环境变量失败",
"duplicateKey": "检测到重复的变量名: {{key}}"
},
"tools": {
"title": "工具权限",
"description": "管理 openclaw.json 中的工具权限配置(允许/拒绝列表)",
"profile": "权限模式",
"profiles": {
"default": "默认",
"strict": "严格",
"permissive": "宽松",
"custom": "自定义"
},
"allowList": "允许列表",
"denyList": "拒绝列表",
"patternPlaceholder": "工具名称或模式",
"addAllow": "添加允许",
"addDeny": "添加拒绝",
"saveSuccess": "工具权限已保存",
"saveFailed": "保存工具权限失败",
"loadFailed": "读取工具权限失败"
},
"agents": {
"title": "Agents 配置",
"description": "管理 openclaw.json 中的 agents.defaults 配置(默认模型、运行参数等)",
"modelSection": "模型配置",
"primaryModel": "主模型",
"primaryModelHint": "格式:provider/model-id",
"fallbackModels": "回退模型",
"fallbackModelsHint": "逗号分隔,按优先级排列",
"runtimeSection": "运行参数",
"workspace": "工作区路径",
"timeout": "超时时间(秒)",
"contextTokens": "上下文 Token 数",
"maxConcurrent": "最大并发数",
"saveSuccess": "Agents 配置已保存",
"saveFailed": "保存 Agents 配置失败",
"loadFailed": "读取 Agents 配置失败"
}
},
"env": {
"warning": {
"title": "检测到系统环境变量冲突",
@@ -1322,7 +1144,7 @@
"skillCount": "识别到 {{count}} 个技能"
},
"search": "搜索技能",
"searchPlaceholder": "搜索技能名称或仓库名称...",
"searchPlaceholder": "搜索技能名称或描述...",
"filter": {
"placeholder": "状态筛选",
"all": "全部",
@@ -1349,8 +1171,7 @@
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode",
"openclaw": "OpenClaw"
"opencode": "OpenCode"
},
"installFromZip": {
"button": "从 ZIP 安装",
@@ -1753,21 +1574,16 @@
"clearWrapped": "(清空)",
"defaultWrapped": "(默认)",
"variantPlaceholder": "思考等级",
"selectEnabledModel": "选择已配置模型",
"selectModel": "选择已配置模型",
"recommendedHint": "推荐: {{model}}",
"searchModel": "搜索模型...",
"selectEnabledModel": "选择已启用模型",
"selectModelFirst": "先选择模型",
"noEnabledModels": "暂无已配置模型",
"noEnabledModels": "暂无已启用模型",
"noVariantsForModel": "该模型无思考等级",
"currentValueNotEnabled": "{{value}}(当前值,未配置",
"currentValueUnavailable": "{{value}}(当前值,不可用)",
"currentValueNotEnabled": "{{value}}(当前值,未启用",
"currentValueUnavailable": "{{value}}(当前值,未启用)",
"advancedLabel": "高级参数",
"advancedJsonInvalid": "高级参数 JSON 无效",
"advancedJsonHint": "temperature, top_p, budgetTokens, prompt_append, permission 等,留空使用默认值",
"noEnabledModelsWarning": "当前没有可用的已配置模型,请先配置 OpenCode 模型",
"modelSourcePartialWarning": "部分供应商模型配置无效,已自动跳过。",
"modelSourceFallbackWarning": "读取 live 供应商状态失败,已回退到已配置供应商列表。",
"noEnabledModelsWarning": "当前没有可用的已启用模型,请先启用并配置 OpenCode 模型",
"importLocalReplaceSuccess": "已从本地文件导入并覆盖 Agent/Category/Other Fields",
"importLocalFailed": "读取本地文件失败: {{error}}",
"agentKeyPlaceholder": "agent 键名",
@@ -1778,7 +1594,7 @@
"modelConfiguration": "模型配置",
"fillRecommended": "填充推荐",
"configSummary": "已配置 {{agents}} 个 Agent{{categories}} 个 Category · 点击 ⚙ 展开高级参数",
"enabledModelsCount": "可选已配置模型 {{count}} 个",
"enabledModelsCount": "可选已启用模型 {{count}} 个",
"source": "来源:",
"otherFieldsJson": "其他字段 (JSON)",
"searchOrType": "搜索或输入自定义值...",
@@ -1804,81 +1620,6 @@
"advancedExperimental": "实验性功能",
"advancedBackgroundTask": "后台任务",
"advancedBrowserAutomation": "浏览器自动化",
"advancedClaudeCode": "Claude Code",
"agentDesc": {
"sisyphus": "主编排者",
"hephaestus": "自主深度工作者",
"prometheus": "战略规划者",
"atlas": "任务管理者",
"oracle": "战略顾问",
"librarian": "多仓库研究员",
"explore": "快速代码搜索",
"multimodalLooker": "媒体分析器",
"metis": "规划前分析顾问",
"momus": "计划审查者",
"sisyphusJunior": "委托任务执行器"
},
"agentTooltip": {
"sisyphus": "主编排器,负责任务规划、委派与并行执行,使用扩展思考(32k 预算),通过 TODO 驱动工作流确保任务完成。",
"atlas": "主编排器(持有 TODO 列表),负责执行阶段的任务分发与协调,不直接完成所有工作而是委派给专业代理。",
"prometheus": "战略规划师,通过访谈模式收集需求并制定详细工作计划,仅能在 .sisyphus/ 目录读写 Markdown 文件,从不直接写代码。",
"hephaestus": "自主深度工作者(「合法工匠」),受 AmpCode 深度模式启发,目标导向执行,行动前会并行启动 2-5 个探索/图书管理员代理进行研究。",
"oracle": "架构决策与调试顾问,只读咨询代理,提供出色的逻辑推理和深度分析,不能写文件或委派任务。",
"librarian": "多仓库分析与文档检索专家,深度理解代码库并提供基于证据的答案,擅长查找官方文档和开源实现示例。",
"explore": "快速代码库探索与上下文 grep 专家,使用轻量级模型进行高速搜索,是理解代码结构的先锋。",
"multimodalLooker": "视觉内容专家,分析 PDF、图片、图表等非文本媒体,提取其中的信息与洞察。",
"metis": "计划顾问,在规划前进行预分析,识别隐藏意图、模糊点和 AI 失败点,防止过度工程化。",
"momus": "计划评审员,高精度验证计划的清晰度、可验证性和完整性,拒绝并要求修订直到计划完美。",
"sisyphusJunior": "类别生成的执行器,由 category 参数自动生成,专注于执行分配的任务且不能再委派,防止无限委派循环。"
},
"categoryDesc": {
"visualEngineering": "视觉/前端工程",
"ultrabrain": "超级思考",
"deep": "深度工作",
"artistry": "创意/文艺",
"quick": "快速响应",
"unspecifiedLow": "通用低配",
"unspecifiedHigh": "通用高配",
"writing": "写作"
},
"categoryTooltip": {
"visualEngineering": "前端与视觉工程类别,专注于 UI/UX 设计、样式、动画和界面实现,默认使用 Gemini 3 Pro 模型。",
"ultrabrain": "深度逻辑推理类别,用于需要广泛分析的复杂架构决策,默认使用 GPT-5.3 Codex 的超高推理变体。",
"deep": "深度自主问题解决类别,目标导向执行,行动前进行彻底研究,适用于需要深度理解的棘手问题。",
"artistry": "高度创意与艺术性任务类别,激发新颖想法和创造性解决方案,默认使用 Gemini 3 Pro 的最大变体。",
"quick": "轻量任务类别,用于单文件修改、错别字修复、简单调整等琐碎工作,默认使用 Claude Haiku 4.5 快速模型。",
"unspecifiedLow": "未归类低工作量任务类别,适用于不适合其他类别且工作量较小的任务,默认使用 Claude Sonnet 4.5。",
"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": "使用此预设推荐的默认模型配置"
"advancedClaudeCode": "Claude Code"
}
}
-22
View File
@@ -1,22 +0,0 @@
<svg viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="lobster-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ff4d4d"/>
<stop offset="100%" stop-color="#991b1b"/>
</linearGradient>
</defs>
<!-- Body -->
<path d="M60 10 C30 10 15 35 15 55 C15 75 30 95 45 100 L45 110 L55 110 L55 100 C55 100 60 102 65 100 L65 110 L75 110 L75 100 C90 95 105 75 105 55 C105 35 90 10 60 10Z" fill="url(#lobster-gradient)"/>
<!-- Left Claw -->
<path d="M20 45 C5 40 0 50 5 60 C10 70 20 65 25 55 C28 48 25 45 20 45Z" fill="url(#lobster-gradient)"/>
<!-- Right Claw -->
<path d="M100 45 C115 40 120 50 115 60 C110 70 100 65 95 55 C92 48 95 45 100 45Z" fill="url(#lobster-gradient)"/>
<!-- Antenna -->
<path d="M45 15 Q35 5 30 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round"/>
<path d="M75 15 Q85 5 90 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round"/>
<!-- Eyes -->
<circle cx="45" cy="35" r="6" fill="#050810"/>
<circle cx="75" cy="35" r="6" fill="#050810"/>
<circle cx="46" cy="34" r="2.5" fill="#00e5cc"/>
<circle cx="76" cy="34" r="2.5" fill="#00e5cc"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long
-7
View File
@@ -247,13 +247,6 @@ export const iconMetadata: Record<string, IconMetadata> = {
keywords: ["gpt", "chatgpt"],
defaultColor: "currentColor",
},
openclaw: {
name: "openclaw",
displayName: "OpenClaw",
category: "ai-provider",
keywords: ["openclaw", "lobster", "claw"],
defaultColor: "#ff4f40",
},
packycode: {
name: "packycode",
displayName: "PackyCode",
-2
View File
@@ -7,9 +7,7 @@ export { skillsApi } from "./skills";
export { usageApi } from "./usage";
export { vscodeApi } from "./vscode";
export { proxyApi } from "./proxy";
export { openclawApi } from "./openclaw";
export { sessionsApi } from "./sessions";
export { workspaceApi } from "./workspace";
export * as configApi from "./config";
export type { ProviderSwitchEvent } from "./providers";
export type { Prompt } from "./prompts";
-105
View File
@@ -1,105 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import type {
OpenClawDefaultModel,
OpenClawModelCatalogEntry,
OpenClawAgentsDefaults,
OpenClawEnvConfig,
OpenClawToolsConfig,
} from "@/types";
/**
* OpenClaw configuration API
*
* Manages ~/.openclaw/openclaw.json sections:
* - agents.defaults (model, catalog)
* - env (environment variables)
* - tools (permissions)
*/
export const openclawApi = {
// ============================================================
// Agents Configuration
// ============================================================
/**
* Get default model configuration (agents.defaults.model)
*/
async getDefaultModel(): Promise<OpenClawDefaultModel | null> {
return await invoke("get_openclaw_default_model");
},
/**
* Set default model configuration (agents.defaults.model)
*/
async setDefaultModel(model: OpenClawDefaultModel): Promise<void> {
return await invoke("set_openclaw_default_model", { model });
},
/**
* Get model catalog/allowlist (agents.defaults.models)
*/
async getModelCatalog(): Promise<Record<
string,
OpenClawModelCatalogEntry
> | null> {
return await invoke("get_openclaw_model_catalog");
},
/**
* Set model catalog/allowlist (agents.defaults.models)
*/
async setModelCatalog(
catalog: Record<string, OpenClawModelCatalogEntry>,
): Promise<void> {
return await invoke("set_openclaw_model_catalog", { catalog });
},
/**
* Get full agents.defaults config (all fields)
*/
async getAgentsDefaults(): Promise<OpenClawAgentsDefaults | null> {
return await invoke("get_openclaw_agents_defaults");
},
/**
* Set full agents.defaults config (all fields)
*/
async setAgentsDefaults(defaults: OpenClawAgentsDefaults): Promise<void> {
return await invoke("set_openclaw_agents_defaults", { defaults });
},
// ============================================================
// Env Configuration
// ============================================================
/**
* Get env config (env section of openclaw.json)
*/
async getEnv(): Promise<OpenClawEnvConfig> {
return await invoke("get_openclaw_env");
},
/**
* Set env config (env section of openclaw.json)
*/
async setEnv(env: OpenClawEnvConfig): Promise<void> {
return await invoke("set_openclaw_env", { env });
},
// ============================================================
// Tools Configuration
// ============================================================
/**
* Get tools config (tools section of openclaw.json)
*/
async getTools(): Promise<OpenClawToolsConfig> {
return await invoke("get_openclaw_tools");
},
/**
* Set tools config (tools section of openclaw.json)
*/
async setTools(tools: OpenClawToolsConfig): Promise<void> {
return await invoke("set_openclaw_tools", { tools });
},
};
-8
View File
@@ -98,14 +98,6 @@ export const providersApi = {
async getOpenCodeLiveProviderIds(): Promise<string[]> {
return await invoke("get_opencode_live_provider_ids");
},
/**
* OpenClaw live ID
* openclaw.json
*/
async getOpenClawLiveProviderIds(): Promise<string[]> {
return await invoke("get_openclaw_live_provider_ids");
},
};
// ============================================================================
+1 -46
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { Settings, WebDavSyncSettings, RemoteSnapshotInfo } from "@/types";
import type { Settings } from "@/types";
import type { AppId } from "./types";
export interface ConfigTransferResult {
@@ -9,15 +9,6 @@ export interface ConfigTransferResult {
backupId?: string;
}
export interface WebDavTestResult {
success: boolean;
message?: string;
}
export interface WebDavSyncResult {
status: string;
}
export const settingsApi = {
async get(): Promise<Settings> {
return await invoke("get_settings");
@@ -102,42 +93,6 @@ export const settingsApi = {
return await invoke("import_config_from_file", { filePath });
},
// ─── WebDAV v2 sync ───────────────────────────────────────
async webdavTestConnection(
settings: WebDavSyncSettings,
preserveEmptyPassword = true,
): Promise<WebDavTestResult> {
return await invoke("webdav_test_connection", {
settings,
preserveEmptyPassword,
});
},
async webdavSyncUpload(): Promise<WebDavSyncResult> {
return await invoke("webdav_sync_upload");
},
async webdavSyncDownload(): Promise<WebDavSyncResult> {
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<void> {
const result = (await invoke("sync_current_providers_live")) as {
success?: boolean;
+1 -3
View File
@@ -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,7 +10,6 @@ export interface SkillApps {
codex: boolean;
gemini: boolean;
opencode: boolean;
openclaw: boolean;
}
/** 已安装的 Skillv3.10.0+ 统一结构) */
@@ -45,7 +44,6 @@ export interface UnmanagedSkill {
name: string;
description?: string;
foundIn: string[];
path: string;
}
/** 技能对象(兼容旧 API) */
+1 -1
View File
@@ -1,2 +1,2 @@
// 前端统一使用 AppId 作为应用标识(与后端命令参数 `app` 一致)
export type AppId = "claude" | "codex" | "gemini" | "opencode" | "openclaw";
export type AppId = "claude" | "codex" | "gemini" | "opencode";
-11
View File
@@ -1,11 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
export const workspaceApi = {
async readFile(filename: string): Promise<string | null> {
return invoke<string | null>("read_workspace_file", { filename });
},
async writeFile(filename: string, content: string): Promise<void> {
return invoke<void>("write_workspace_file", { filename, content });
},
};
+3 -15
View File
@@ -5,7 +5,6 @@ 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();
@@ -17,12 +16,12 @@ export const useAddProviderMutation = (appId: AppId) => {
) => {
let id: string;
if (appId === "opencode" || appId === "openclaw") {
if (appId === "opencode") {
if (providerInput.category === "omo") {
id = `omo-${generateUUID()}`;
} else {
if (!providerInput.providerKey) {
throw new Error(`Provider key is required for ${appId}`);
throw new Error("Provider key is required for OpenCode");
}
id = providerInput.providerKey;
}
@@ -30,10 +29,8 @@ export const useAddProviderMutation = (appId: AppId) => {
id = generateUUID();
}
const { providerKey: _providerKey, ...rest } = providerInput;
const newProvider: Provider = {
...rest,
...providerInput,
id,
createdAt: Date.now(),
};
@@ -177,7 +174,6 @@ 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"],
@@ -186,14 +182,6 @@ export const useSwitchProviderMutation = (appId: AppId) => {
queryKey: ["omo", "current-provider-id"],
});
}
if (appId === "openclaw") {
await queryClient.invalidateQueries({
queryKey: openclawKeys.liveProviderIds,
});
await queryClient.invalidateQueries({
queryKey: openclawKeys.defaultModel,
});
}
try {
await providersApi.updateTrayMenu();
+2 -2
View File
@@ -4,8 +4,8 @@ export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: true,
staleTime: 0,
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 5,
},
mutations: {
retry: false,
-21
View File
@@ -28,27 +28,6 @@ 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<typeof settingsSchema>;
-98
View File
@@ -160,37 +160,6 @@ export interface VisibleApps {
codex: boolean;
gemini: boolean;
opencode: boolean;
openclaw: boolean;
}
// WebDAV v2 同步状态
export interface WebDavSyncStatus {
lastSyncAt?: number | null;
lastError?: string | null;
lastRemoteEtag?: string | null;
lastLocalManifestHash?: string | null;
lastRemoteManifestHash?: string | null;
}
// WebDAV v2 同步配置
export interface WebDavSyncSettings {
enabled?: boolean;
baseUrl?: string;
username?: string;
password?: string;
remoteRoot?: string;
profile?: string;
status?: WebDavSyncStatus;
}
// 远端快照信息(下载前预览)
export interface RemoteSnapshotInfo {
deviceName: string;
createdAt: string;
snapshotId: string;
version: number;
compatible: boolean;
artifacts: string[];
}
// 应用设置类型(用于设置对话框与 Tauri API)
@@ -224,8 +193,6 @@ export interface Settings {
geminiConfigDir?: string;
// 覆盖 OpenCode 配置目录(可选)
opencodeConfigDir?: string;
// 覆盖 OpenClaw 配置目录(可选)
openclawConfigDir?: string;
// ===== 当前供应商 ID(设备级)=====
// 当前 Claude 供应商 ID(优先于数据库 is_current
@@ -239,9 +206,6 @@ export interface Settings {
// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
skillSyncMethod?: SkillSyncMethod;
// ===== WebDAV v2 同步设置 =====
webdavSync?: WebDavSyncSettings;
// ===== 终端设置 =====
// 首选终端应用(可选,默认使用系统默认终端)
// macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
@@ -290,7 +254,6 @@ export interface McpApps {
codex: boolean;
gemini: boolean;
opencode: boolean;
openclaw: boolean;
}
// MCP 服务器条目(v3.7.0 统一结构)
@@ -428,64 +391,3 @@ export interface OpenCodeMcpServerSpec {
// 通用字段
enabled?: boolean;
}
// ============================================================================
// OpenClaw 专属配置(v3.11.0+
// ============================================================================
// OpenClaw 模型配置
export interface OpenClawModel {
id: string;
name: string;
alias?: string;
reasoning?: boolean; // 是否支持推理模式(如 o1、DeepSeek R1
input?: string[]; // 支持的输入类型(如 ["text"]、["text", "image"]
cost?: {
input: number;
output: number;
cacheRead?: number; // 缓存读取价格
cacheWrite?: number; // 缓存写入价格
};
contextWindow?: number;
maxTokens?: number; // 最大输出 token 数
}
// OpenClaw 默认模型配置(agents.defaults.model
export interface OpenClawDefaultModel {
primary: string;
fallbacks?: string[];
}
// OpenClaw 模型目录条目(agents.defaults.models 中的值)
export interface OpenClawModelCatalogEntry {
alias?: string;
}
// OpenClaw 供应商配置(settings_config 结构)
// 对应 OpenClaw 的 models.providers.<provider-id> 配置
export interface OpenClawProviderConfig {
baseUrl?: string; // API 端点
apiKey?: string; // API 密钥
api?: string; // API 协议类型(如 "openai-completions"、"anthropic"
models?: OpenClawModel[]; // 可用模型列表
}
// OpenClaw agents.defaults 完整配置
export interface OpenClawAgentsDefaults {
model?: OpenClawDefaultModel;
models?: Record<string, OpenClawModelCatalogEntry>;
[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
}
+50 -62
View File
@@ -27,8 +27,8 @@ export interface OmoLocalFileData {
export interface OmoAgentDef {
key: string;
display: string;
descKey: string;
tooltipKey: string;
descZh: string;
descEn: string;
recommended?: string;
group: "main" | "sub";
}
@@ -36,97 +36,97 @@ export interface OmoAgentDef {
export interface OmoCategoryDef {
key: string;
display: string;
descKey: string;
tooltipKey: string;
descZh: string;
descEn: string;
recommended?: string;
}
export const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [
{
key: "sisyphus",
key: "Sisyphus",
display: "Sisyphus",
descKey: "omo.agentDesc.sisyphus",
tooltipKey: "omo.agentTooltip.sisyphus",
descZh: "主编排者",
descEn: "Main orchestrator",
recommended: "claude-opus-4-6",
group: "main",
},
{
key: "hephaestus",
key: "Hephaestus",
display: "Hephaestus",
descKey: "omo.agentDesc.hephaestus",
tooltipKey: "omo.agentTooltip.hephaestus",
descZh: "自主深度工作者",
descEn: "Autonomous deep worker",
recommended: "gpt-5.3-codex",
group: "main",
},
{
key: "prometheus",
key: "Prometheus",
display: "Prometheus",
descKey: "omo.agentDesc.prometheus",
tooltipKey: "omo.agentTooltip.prometheus",
descZh: "战略规划者",
descEn: "Strategic planner",
recommended: "claude-opus-4-6",
group: "main",
},
{
key: "atlas",
key: "Atlas",
display: "Atlas",
descKey: "omo.agentDesc.atlas",
tooltipKey: "omo.agentTooltip.atlas",
descZh: "任务管理者",
descEn: "Task manager",
recommended: "kimi-k2.5",
group: "main",
},
{
key: "oracle",
display: "Oracle",
descKey: "omo.agentDesc.oracle",
tooltipKey: "omo.agentTooltip.oracle",
descZh: "战略顾问",
descEn: "Strategic advisor",
recommended: "gpt-5.3",
group: "sub",
},
{
key: "librarian",
display: "Librarian",
descKey: "omo.agentDesc.librarian",
tooltipKey: "omo.agentTooltip.librarian",
descZh: "多仓库研究员",
descEn: "Multi-repo researcher",
recommended: "glm-4.7",
group: "sub",
},
{
key: "explore",
display: "Explore",
descKey: "omo.agentDesc.explore",
tooltipKey: "omo.agentTooltip.explore",
descZh: "快速代码搜索",
descEn: "Fast code search",
recommended: "grok-code-fast-1",
group: "sub",
},
{
key: "multimodal-looker",
display: "Multimodal-Looker",
descKey: "omo.agentDesc.multimodalLooker",
tooltipKey: "omo.agentTooltip.multimodalLooker",
descZh: "媒体分析器",
descEn: "Media analyzer",
recommended: "gemini-3-flash",
group: "sub",
},
{
key: "metis",
key: "Metis",
display: "Metis",
descKey: "omo.agentDesc.metis",
tooltipKey: "omo.agentTooltip.metis",
descZh: "规划前分析顾问",
descEn: "Pre-plan analysis advisor",
recommended: "claude-opus-4-6",
group: "sub",
},
{
key: "momus",
key: "Momus",
display: "Momus",
descKey: "omo.agentDesc.momus",
tooltipKey: "omo.agentTooltip.momus",
descZh: "计划审查者",
descEn: "Plan reviewer",
recommended: "gpt-5.3",
group: "sub",
},
{
key: "sisyphus-junior",
key: "Sisyphus-Junior",
display: "Sisyphus-Junior",
descKey: "omo.agentDesc.sisyphusJunior",
tooltipKey: "omo.agentTooltip.sisyphusJunior",
descZh: "委托任务执行器",
descEn: "Delegated task executor",
group: "sub",
},
];
@@ -135,57 +135,57 @@ export const OMO_BUILTIN_CATEGORIES: OmoCategoryDef[] = [
{
key: "visual-engineering",
display: "Visual Engineering",
descKey: "omo.categoryDesc.visualEngineering",
tooltipKey: "omo.categoryTooltip.visualEngineering",
descZh: "视觉/前端工程",
descEn: "Visual/frontend engineering",
recommended: "gemini-3-pro",
},
{
key: "ultrabrain",
display: "Ultrabrain",
descKey: "omo.categoryDesc.ultrabrain",
tooltipKey: "omo.categoryTooltip.ultrabrain",
descZh: "超级思考",
descEn: "Ultra thinking",
recommended: "claude-opus-4-6",
},
{
key: "deep",
display: "Deep",
descKey: "omo.categoryDesc.deep",
tooltipKey: "omo.categoryTooltip.deep",
descZh: "深度工作",
descEn: "Deep work",
recommended: "gpt-5.3-codex",
},
{
key: "artistry",
display: "Artistry",
descKey: "omo.categoryDesc.artistry",
tooltipKey: "omo.categoryTooltip.artistry",
descZh: "创意/文艺",
descEn: "Creative/artistic",
recommended: "claude-opus-4-6",
},
{
key: "quick",
display: "Quick",
descKey: "omo.categoryDesc.quick",
tooltipKey: "omo.categoryTooltip.quick",
descZh: "快速响应",
descEn: "Quick response",
recommended: "gemini-3-flash",
},
{
key: "unspecified-low",
display: "Unspecified Low",
descKey: "omo.categoryDesc.unspecifiedLow",
tooltipKey: "omo.categoryTooltip.unspecifiedLow",
descZh: "通用低配",
descEn: "General low tier",
recommended: "gemini-3-flash",
},
{
key: "unspecified-high",
display: "Unspecified High",
descKey: "omo.categoryDesc.unspecifiedHigh",
tooltipKey: "omo.categoryTooltip.unspecifiedHigh",
descZh: "通用高配",
descEn: "General high tier",
recommended: "gpt-5.3-codex",
},
{
key: "writing",
display: "Writing",
descKey: "omo.categoryDesc.writing",
tooltipKey: "omo.categoryTooltip.writing",
descZh: "写作",
descEn: "Writing",
recommended: "claude-opus-4-6",
},
];
@@ -316,17 +316,6 @@ export const OMO_CLAUDE_CODE_PLACEHOLDER = `{
"plugins": true
}`;
export function parseOmoOtherFieldsObject(
raw: string,
): Record<string, unknown> | undefined {
if (!raw.trim()) return undefined;
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return undefined;
}
return parsed as Record<string, unknown>;
}
export function mergeOmoConfigPreview(
global: OmoGlobalConfig,
agents: Record<string, Record<string, unknown>>,
@@ -362,8 +351,7 @@ export function mergeOmoConfigPreview(
if (Object.keys(agents).length > 0) result["agents"] = agents;
if (Object.keys(categories).length > 0) result["categories"] = categories;
try {
const other = parseOmoOtherFieldsObject(otherFieldsStr);
if (!other) return result;
const other = JSON.parse(otherFieldsStr || "{}");
for (const [k, v] of Object.entries(other)) {
result[k] = v;
}
-1
View File
@@ -46,7 +46,6 @@ export interface ProxyTakeoverStatus {
codex: boolean;
gemini: boolean;
opencode: boolean;
openclaw: boolean;
}
export interface ProviderHealth {
-1
View File
@@ -433,7 +433,6 @@ type = "stdio"
codex: false,
gemini: false,
opencode: false,
openclaw: false,
});
expect(onSave).toHaveBeenCalledTimes(1);
expect(toastErrorMock).not.toHaveBeenCalled();

Some files were not shown because too many files have changed in this diff Show More