mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b98ff9b51 | |||
| 0b4912090a | |||
| 4be515e178 | |||
| ff29b939a8 | |||
| 7b20c17ea4 | |||
| 5a17a67b8b | |||
| dca12b9f7d | |||
| f974d203a8 | |||
| 44ba9468b5 | |||
| 5081206176 | |||
| 00b424628f | |||
| bc87f9d9eb | |||
| eccb95b83d | |||
| 2b1f0f0f7e | |||
| 9035784aa4 | |||
| a61892e46a | |||
| 4f2812f472 | |||
| 705cc8a5af | |||
| 182015264c | |||
| a33f8fe973 | |||
| f1fbe324d1 | |||
| 26a2fc4fe0 | |||
| 02f9f2e3b8 | |||
| c431a86064 | |||
| 46ab15f9f6 | |||
| 7b2cf66812 | |||
| 227f08e910 | |||
| 392344e1e9 | |||
| 3e58e65284 | |||
| 512f22e83a | |||
| 715e9e89c4 | |||
| 47f2c47a2f | |||
| 52db7941ea | |||
| 27bcfcb2b8 | |||
| 9bbb6c51ff | |||
| 5c62c47878 | |||
| 95391f19ac | |||
| 28b125b34f | |||
| d56e0b0344 | |||
| fedb08e846 | |||
| 545be56698 | |||
| 31ec5d73a6 | |||
| 6952360f70 | |||
| e85878e95a | |||
| 433c86b2d3 | |||
| 63fafd6608 | |||
| 7f46a0b910 | |||
| 9c34d04def | |||
| 6098fa7536 | |||
| 721771bfe5 | |||
| 1ecc0fff30 | |||
| b0a177725d | |||
| 2edee31638 | |||
| 62fa5213bf | |||
| caba5f51ec |
@@ -15,11 +15,11 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANG
|
||||
|
||||
## ❤️Sponsor
|
||||
|
||||
[](https://bit.ly/3Nue8mA)
|
||||
[](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
[Click](https://bit.ly/3Nue8mA) to get an exclusive 12% off the MiniMax Coding Plan!
|
||||
[Click](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link) to get an exclusive 12% off the MiniMax Coding Plan!
|
||||
|
||||
---
|
||||
|
||||
@@ -60,6 +60,11 @@ 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
|
||||
|
||||
+8
-3
@@ -15,11 +15,11 @@
|
||||
|
||||
## ❤️スポンサー
|
||||
|
||||
[](https://bit.ly/3Nue8mA)
|
||||
[](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
|
||||
|
||||
MiniMax M2.1 は、実務開発とエージェントワークフロー向けに構築されたオープンソースの最先端モデルです。100 億のアクティブパラメータ / 2,300 億の総パラメータを持つ MoE アーキテクチャにより、高速な推論、簡単なデプロイ、ローカル実行にも対応します。SWE、VIBE、Multi-SWE などの主要コーディングベンチマークでトップクラスの性能を発揮し、コーディング、デジタル環境のナビゲーション、大規模な多段階タスクの処理に優れています。
|
||||
MiniMax-M2.5 は、実際の生産性向上のために設計された最先端の大規模言語モデルです。多様で複雑な実環境のデジタルワークスペースでトレーニングされた M2.5 は、M2.1 のコーディング能力をベースに一般的なオフィス業務へと拡張し、Word・Excel・PowerPoint ファイルの生成と操作、多様なソフトウェア環境間のコンテキスト切り替え、異なるエージェントや人間チーム間での協働を流暢にこなします。SWE-Bench Verified で 80.2%、Multi-SWE-Bench で 51.3%、BrowseComp で 76.3% を達成し、計画的な行動と出力の最適化トレーニングにより、前世代よりもトークン効率に優れています。
|
||||
|
||||
[こちら](https://bit.ly/3Nue8mA)から MiniMax Coding Plan の限定 12% オフを入手!
|
||||
[こちら](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)から MiniMax Coding Plan の限定 12% オフを入手!
|
||||
|
||||
---
|
||||
|
||||
@@ -60,6 +60,11 @@ 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>
|
||||
|
||||
## スクリーンショット
|
||||
|
||||
+6
-1
@@ -17,7 +17,7 @@
|
||||
|
||||
[](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)
|
||||
|
||||
MiniMax M2.x 系列模型是面向实际开发与智能体工作流打造的编码模型,M2.1 基于 100 亿激活 / 2300 亿总参的混合专家架构打造,推理更快、部署更便捷且支持本地运行,在 SWE、VIBE、Multi-SWE 等主流代码评测基准中均表现顶尖,擅长代码开发、数字环境适配及规模化处理长链路多步骤任务。
|
||||
MiniMax M2.5 在编程、工具调用与搜索、办公等核心生产力场景均达到或刷新行业 SOTA,拥有架构师级代码能力与高效任务拆解能力,推理速度较上一代提升 37%、token 消耗更优;100 token/s 连续工作一小时仅需 1 美金,让复杂 Agent 规模化部署经济可行,已在企业多职能场景深度落地,加速全民 Agent 时代到来。
|
||||
|
||||
[点击](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)即可领取 MiniMax Coding Plan 专属 88 折优惠!
|
||||
|
||||
@@ -61,6 +61,11 @@ 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.
|
After Width: | Height: | Size: 207 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 152 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 193 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
@@ -54,9 +54,11 @@
|
||||
"@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",
|
||||
|
||||
Generated
+42
@@ -50,6 +50,9 @@ importers:
|
||||
'@radix-ui/react-checkbox':
|
||||
specifier: ^1.3.3
|
||||
version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-collapsible':
|
||||
specifier: ^1.1.12
|
||||
version: 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -59,6 +62,9 @@ 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)
|
||||
@@ -1048,6 +1054,19 @@ 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:
|
||||
@@ -3890,6 +3909,29 @@ 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)
|
||||
|
||||
Generated
+63
@@ -714,6 +714,7 @@ dependencies = [
|
||||
"futures",
|
||||
"hyper",
|
||||
"indexmap 2.11.4",
|
||||
"json5",
|
||||
"log",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
@@ -727,6 +728,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
@@ -746,6 +748,7 @@ dependencies = [
|
||||
"tower-http 0.5.2",
|
||||
"url",
|
||||
"uuid",
|
||||
"webkit2gtk",
|
||||
"winreg 0.52.0",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
@@ -2519,6 +2522,17 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "json5"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_derive",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonptr"
|
||||
version = "0.6.3"
|
||||
@@ -3403,6 +3417,49 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pest"
|
||||
version = "2.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"ucd-trie",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_derive"
|
||||
version = "2.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_generator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_generator"
|
||||
version = "2.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_meta",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_meta"
|
||||
version = "2.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.8.0"
|
||||
@@ -5886,6 +5943,12 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "ucd-trie"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.1.0"
|
||||
|
||||
@@ -57,14 +57,19 @@ url = "2.5"
|
||||
auto-launch = "0.5"
|
||||
once_cell = "1.21.3"
|
||||
base64 = "0.22"
|
||||
rusqlite = { version = "0.31", features = ["bundled", "backup"] }
|
||||
rusqlite = { version = "0.31", features = ["bundled", "backup", "hooks"] }
|
||||
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"
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ impl McpApps {
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::OpenClaw => false, // OpenClaw doesn't support MCP
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +36,7 @@ impl McpApps {
|
||||
AppType::Codex => self.codex = enabled,
|
||||
AppType::Gemini => self.gemini = enabled,
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +85,7 @@ impl SkillApps {
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::OpenClaw => false, // OpenClaw doesn't support Skills
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +96,7 @@ impl SkillApps {
|
||||
AppType::Codex => self.codex = enabled,
|
||||
AppType::Gemini => self.gemini = enabled,
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +226,9 @@ pub struct McpRoot {
|
||||
/// OpenCode MCP 配置(v4.0.0+,实际使用 opencode.json)
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub opencode: McpConfig,
|
||||
/// OpenClaw MCP 配置(v4.1.0+,实际使用 openclaw.json)
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub openclaw: McpConfig,
|
||||
}
|
||||
|
||||
impl Default for McpRoot {
|
||||
@@ -234,6 +241,7 @@ impl Default for McpRoot {
|
||||
codex: McpConfig::default(),
|
||||
gemini: McpConfig::default(),
|
||||
opencode: McpConfig::default(),
|
||||
openclaw: McpConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,6 +264,8 @@ pub struct PromptRoot {
|
||||
pub gemini: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub opencode: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub openclaw: PromptConfig,
|
||||
}
|
||||
|
||||
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
||||
@@ -271,6 +281,7 @@ pub enum AppType {
|
||||
Codex,
|
||||
Gemini,
|
||||
OpenCode,
|
||||
OpenClaw,
|
||||
}
|
||||
|
||||
impl AppType {
|
||||
@@ -280,15 +291,16 @@ impl AppType {
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini",
|
||||
AppType::OpenCode => "opencode",
|
||||
AppType::OpenClaw => "openclaw",
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this app uses additive mode
|
||||
///
|
||||
/// - Switch mode (false): Only the current provider is written to live config (Claude, Codex, Gemini)
|
||||
/// - Additive mode (true): All providers are written to live config (OpenCode)
|
||||
/// - Additive mode (true): All providers are written to live config (OpenCode, OpenClaw)
|
||||
pub fn is_additive_mode(&self) -> bool {
|
||||
matches!(self, AppType::OpenCode)
|
||||
matches!(self, AppType::OpenCode | AppType::OpenClaw)
|
||||
}
|
||||
|
||||
/// Return an iterator over all app types
|
||||
@@ -298,6 +310,7 @@ impl AppType {
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::OpenCode,
|
||||
AppType::OpenClaw,
|
||||
]
|
||||
.into_iter()
|
||||
}
|
||||
@@ -313,10 +326,11 @@ impl FromStr for AppType {
|
||||
"codex" => Ok(AppType::Codex),
|
||||
"gemini" => Ok(AppType::Gemini),
|
||||
"opencode" => Ok(AppType::OpenCode),
|
||||
"openclaw" => Ok(AppType::OpenClaw),
|
||||
other => Err(AppError::localized(
|
||||
"unsupported_app",
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode."),
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw."),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -336,6 +350,9 @@ 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 {
|
||||
@@ -346,6 +363,7 @@ impl CommonConfigSnippets {
|
||||
AppType::Codex => self.codex.as_ref(),
|
||||
AppType::Gemini => self.gemini.as_ref(),
|
||||
AppType::OpenCode => self.opencode.as_ref(),
|
||||
AppType::OpenClaw => self.openclaw.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,6 +374,7 @@ impl CommonConfigSnippets {
|
||||
AppType::Codex => self.codex = snippet,
|
||||
AppType::Gemini => self.gemini = snippet,
|
||||
AppType::OpenCode => self.opencode = snippet,
|
||||
AppType::OpenClaw => self.openclaw = snippet,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -396,6 +415,7 @@ impl Default for MultiAppConfig {
|
||||
apps.insert("codex".to_string(), ProviderManager::default());
|
||||
apps.insert("gemini".to_string(), ProviderManager::default());
|
||||
apps.insert("opencode".to_string(), ProviderManager::default());
|
||||
apps.insert("openclaw".to_string(), ProviderManager::default());
|
||||
|
||||
Self {
|
||||
version: 2,
|
||||
@@ -555,6 +575,7 @@ impl MultiAppConfig {
|
||||
AppType::Codex => &self.mcp.codex,
|
||||
AppType::Gemini => &self.mcp.gemini,
|
||||
AppType::OpenCode => &self.mcp.opencode,
|
||||
AppType::OpenClaw => &self.mcp.openclaw,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,6 +586,7 @@ impl MultiAppConfig {
|
||||
AppType::Codex => &mut self.mcp.codex,
|
||||
AppType::Gemini => &mut self.mcp.gemini,
|
||||
AppType::OpenCode => &mut self.mcp.opencode,
|
||||
AppType::OpenClaw => &mut self.mcp.openclaw,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,6 +601,7 @@ impl MultiAppConfig {
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
@@ -599,6 +622,7 @@ impl MultiAppConfig {
|
||||
|| !self.prompts.codex.prompts.is_empty()
|
||||
|| !self.prompts.gemini.prompts.is_empty()
|
||||
|| !self.prompts.opencode.prompts.is_empty()
|
||||
|| !self.prompts.openclaw.prompts.is_empty()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -611,6 +635,7 @@ impl MultiAppConfig {
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::OpenCode,
|
||||
AppType::OpenClaw,
|
||||
] {
|
||||
// 复用已有的单应用导入逻辑
|
||||
if Self::auto_import_prompt_if_exists(self, app)? {
|
||||
@@ -681,6 +706,7 @@ impl MultiAppConfig {
|
||||
AppType::Codex => &mut config.prompts.codex.prompts,
|
||||
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
||||
AppType::OpenCode => &mut config.prompts.opencode.prompts,
|
||||
AppType::OpenClaw => &mut config.prompts.openclaw.prompts,
|
||||
};
|
||||
|
||||
prompts.insert(id, prompt);
|
||||
@@ -709,12 +735,18 @@ impl MultiAppConfig {
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
// 收集所有应用的 MCP
|
||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||
for app in [
|
||||
AppType::Claude,
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::OpenCode,
|
||||
] {
|
||||
let old_servers = match app {
|
||||
AppType::Claude => &self.mcp.claude.servers,
|
||||
AppType::Codex => &self.mcp.codex.servers,
|
||||
AppType::Gemini => &self.mcp.gemini.servers,
|
||||
AppType::OpenCode => &self.mcp.opencode.servers,
|
||||
AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip
|
||||
};
|
||||
|
||||
for (id, entry) in old_servers {
|
||||
|
||||
@@ -59,6 +59,15 @@ 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +83,7 @@ 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())
|
||||
@@ -86,6 +96,7 @@ 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() {
|
||||
|
||||
@@ -5,10 +5,15 @@ use std::path::PathBuf;
|
||||
use tauri::State;
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
use crate::commands::sync_support::{
|
||||
post_sync_warning_from_result, run_post_import_sync, success_payload_with_warning,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::services::provider::ProviderService;
|
||||
use crate::store::AppState;
|
||||
|
||||
// ─── File import/export ──────────────────────────────────────
|
||||
|
||||
/// 导出数据库为 SQL 备份
|
||||
#[tauri::command]
|
||||
pub async fn export_config_to_file(
|
||||
@@ -37,27 +42,15 @@ pub async fn import_config_from_file(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Value, String> {
|
||||
let db = state.db.clone();
|
||||
let db_for_state = db.clone();
|
||||
let db_for_sync = db.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let path_buf = PathBuf::from(&filePath);
|
||||
let backup_id = db.import_sql(&path_buf)?;
|
||||
|
||||
// 导入后同步当前供应商到各自的 live 配置
|
||||
let app_state = AppState::new(db_for_state);
|
||||
if let Err(err) = ProviderService::sync_current_to_live(&app_state) {
|
||||
log::warn!("导入后同步 live 配置失败: {err}");
|
||||
let warning = post_sync_warning_from_result(Ok(run_post_import_sync(db_for_sync)));
|
||||
if let Some(msg) = warning.as_ref() {
|
||||
log::warn!("[Import] post-import sync warning: {msg}");
|
||||
}
|
||||
|
||||
// 重新加载设置到内存缓存,确保导入的设置生效
|
||||
if let Err(err) = crate::settings::reload_settings() {
|
||||
log::warn!("导入后重载设置失败: {err}");
|
||||
}
|
||||
|
||||
Ok::<_, AppError>(json!({
|
||||
"success": true,
|
||||
"message": "SQL imported successfully",
|
||||
"backupId": backup_id
|
||||
}))
|
||||
Ok::<_, AppError>(success_payload_with_warning(backup_id, warning))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("导入配置失败: {e}"))?
|
||||
@@ -80,6 +73,8 @@ pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<V
|
||||
.map_err(|e: AppError| e.to_string())
|
||||
}
|
||||
|
||||
// ─── File dialogs ────────────────────────────────────────────
|
||||
|
||||
/// 保存文件对话框
|
||||
#[tauri::command]
|
||||
pub async fn save_file_dialog<R: tauri::Runtime>(
|
||||
|
||||
+183
-35
@@ -313,6 +313,79 @@ 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;
|
||||
@@ -320,88 +393,99 @@ 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![
|
||||
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
|
||||
];
|
||||
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"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
search_paths.push(std::path::PathBuf::from("/opt/homebrew/bin"));
|
||||
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
|
||||
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"),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
|
||||
search_paths.push(std::path::PathBuf::from("/usr/bin"));
|
||||
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"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(appdata) = dirs::data_dir() {
|
||||
search_paths.push(appdata.join("npm"));
|
||||
push_unique_path(&mut search_paths, appdata.join("npm"));
|
||||
}
|
||||
search_paths.push(std::path::PathBuf::from("C:\\Program Files\\nodejs"));
|
||||
push_unique_path(
|
||||
&mut search_paths,
|
||||
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() {
|
||||
search_paths.push(bin_path);
|
||||
push_unique_path(&mut search_paths, 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() {
|
||||
search_paths.push(bin_path);
|
||||
push_unique_path(&mut search_paths, bin_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 Go 路径支持 (opencode 使用 go install 安装)
|
||||
if tool == "opencode" {
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 在每个路径中查找工具
|
||||
let current_path = std::env::var("PATH").unwrap_or_default();
|
||||
|
||||
for path in &search_paths {
|
||||
let tool_path = if cfg!(target_os = "windows") {
|
||||
path.join(format!("{tool}.cmd"))
|
||||
} else {
|
||||
path.join(tool)
|
||||
};
|
||||
#[cfg(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();
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let new_path = format!("{}:{}", path.display(), current_path);
|
||||
|
||||
#[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);
|
||||
for tool_path in tool_executable_candidates(tool, path) {
|
||||
if !tool_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let output = {
|
||||
// 使用 cmd /C 包装执行,确保子进程也在隐藏的控制台中运行
|
||||
Command::new("cmd")
|
||||
.args(["/C", &format!("\"{}\" --version", tool_path.display())])
|
||||
.env("PATH", &new_path)
|
||||
@@ -971,3 +1055,67 @@ 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"),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ mod import_export;
|
||||
mod mcp;
|
||||
mod misc;
|
||||
mod omo;
|
||||
mod openclaw;
|
||||
mod plugin;
|
||||
mod prompt;
|
||||
mod provider;
|
||||
@@ -17,7 +18,10 @@ mod session_manager;
|
||||
mod settings;
|
||||
pub mod skill;
|
||||
mod stream_check;
|
||||
mod sync_support;
|
||||
mod usage;
|
||||
mod webdav_sync;
|
||||
mod workspace;
|
||||
|
||||
pub use config::*;
|
||||
pub use deeplink::*;
|
||||
@@ -28,6 +32,7 @@ pub use import_export::*;
|
||||
pub use mcp::*;
|
||||
pub use misc::*;
|
||||
pub use omo::*;
|
||||
pub use openclaw::*;
|
||||
pub use plugin::*;
|
||||
pub use prompt::*;
|
||||
pub use provider::*;
|
||||
@@ -37,3 +42,5 @@ pub use settings::*;
|
||||
pub use skill::*;
|
||||
pub use stream_check::*;
|
||||
pub use usage::*;
|
||||
pub use webdav_sync::*;
|
||||
pub use workspace::*;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
use std::collections::HashMap;
|
||||
use tauri::State;
|
||||
|
||||
use crate::openclaw_config;
|
||||
use crate::store::AppState;
|
||||
|
||||
// ============================================================================
|
||||
// OpenClaw Provider Commands (migrated from provider.rs)
|
||||
// ============================================================================
|
||||
|
||||
/// Import providers from OpenClaw live config to database.
|
||||
///
|
||||
/// OpenClaw uses additive mode — users may already have providers
|
||||
/// configured in openclaw.json.
|
||||
#[tauri::command]
|
||||
pub fn import_openclaw_providers_from_live(state: State<'_, AppState>) -> Result<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())
|
||||
}
|
||||
@@ -318,3 +318,7 @@ 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,16 +2,28 @@
|
||||
|
||||
use tauri::AppHandle;
|
||||
|
||||
fn merge_settings_for_save(
|
||||
mut incoming: crate::settings::AppSettings,
|
||||
existing: &crate::settings::AppSettings,
|
||||
) -> crate::settings::AppSettings {
|
||||
if incoming.webdav_sync.is_none() {
|
||||
incoming.webdav_sync = existing.webdav_sync.clone();
|
||||
}
|
||||
incoming
|
||||
}
|
||||
|
||||
/// 获取设置
|
||||
#[tauri::command]
|
||||
pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
|
||||
Ok(crate::settings::get_settings())
|
||||
Ok(crate::settings::get_settings_for_frontend())
|
||||
}
|
||||
|
||||
/// 保存设置
|
||||
#[tauri::command]
|
||||
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
|
||||
crate::settings::update_settings(settings).map_err(|e| e.to_string())?;
|
||||
let existing = crate::settings::get_settings();
|
||||
let merged = merge_settings_for_save(settings, &existing);
|
||||
crate::settings::update_settings(merged).map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -54,6 +66,58 @@ pub async fn set_auto_launch(enabled: bool) -> Result<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> {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use crate::services::provider::ProviderService;
|
||||
use crate::settings;
|
||||
use crate::store::AppState;
|
||||
|
||||
pub(crate) fn run_post_import_sync(db: Arc<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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use serde_json::{json, Value};
|
||||
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, source: &str) {
|
||||
settings.status.last_error = Some(error.to_string());
|
||||
settings.status.last_error_source = Some(source.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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn webdav_sync_mutex() -> &'static tokio::sync::Mutex<()> {
|
||||
webdav_sync_service::sync_mutex()
|
||||
}
|
||||
|
||||
async fn run_with_webdav_lock<T, Fut>(operation: Fut) -> Result<T, AppError>
|
||||
where
|
||||
Fut: std::future::Future<Output = Result<T, AppError>>,
|
||||
{
|
||||
webdav_sync_service::run_with_sync_lock(operation).await
|
||||
}
|
||||
|
||||
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, "manual")
|
||||
})
|
||||
}
|
||||
|
||||
#[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 _auto_sync_suppression = crate::services::webdav_auto_sync::AutoSyncSuppressionGuard::new();
|
||||
|
||||
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, "manual")
|
||||
})?;
|
||||
|
||||
// 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::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
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()),
|
||||
"manual",
|
||||
);
|
||||
|
||||
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"
|
||||
);
|
||||
assert_eq!(after.status.last_error_source.as_deref(), Some("manual"));
|
||||
}
|
||||
|
||||
#[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/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use crate::config::write_text_file;
|
||||
use crate::openclaw_config::get_openclaw_dir;
|
||||
|
||||
/// Allowed workspace filenames (whitelist for security)
|
||||
const ALLOWED_FILES: &[&str] = &[
|
||||
"AGENTS.md",
|
||||
"SOUL.md",
|
||||
"USER.md",
|
||||
"IDENTITY.md",
|
||||
"TOOLS.md",
|
||||
"MEMORY.md",
|
||||
"HEARTBEAT.md",
|
||||
"BOOTSTRAP.md",
|
||||
"BOOT.md",
|
||||
];
|
||||
|
||||
fn validate_filename(filename: &str) -> Result<(), String> {
|
||||
if !ALLOWED_FILES.contains(&filename) {
|
||||
return Err(format!(
|
||||
"Invalid workspace filename: {filename}. Allowed: {}",
|
||||
ALLOWED_FILES.join(", ")
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read an OpenClaw workspace file content.
|
||||
/// Returns None if the file does not exist.
|
||||
#[tauri::command]
|
||||
pub async fn read_workspace_file(filename: String) -> Result<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}"))
|
||||
}
|
||||
@@ -16,10 +16,15 @@ use tempfile::NamedTempFile;
|
||||
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
|
||||
|
||||
impl Database {
|
||||
/// 导出为 SQLite 兼容的 SQL 文本(内存字符串)
|
||||
pub fn export_sql_string(&self) -> Result<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 snapshot = self.snapshot_to_memory()?;
|
||||
let dump = Self::dump_sql(&snapshot)?;
|
||||
let dump = self.export_sql_string()?;
|
||||
|
||||
if let Some(parent) = target_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
@@ -38,6 +43,12 @@ impl Database {
|
||||
}
|
||||
|
||||
let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?;
|
||||
let sql_content = sql_raw.trim_start_matches('\u{feff}');
|
||||
self.import_sql_string(sql_content)
|
||||
}
|
||||
|
||||
/// 从 SQL 字符串导入,返回生成的备份 ID(若无备份则为空字符串)
|
||||
pub fn import_sql_string(&self, sql_raw: &str) -> Result<String, AppError> {
|
||||
let sql_content = sql_raw.trim_start_matches('\u{feff}');
|
||||
Self::validate_cc_switch_sql_export(sql_content)?;
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ impl Database {
|
||||
|
||||
/// 获取整流器配置
|
||||
///
|
||||
/// 返回整流器配置,如果不存在则返回默认值(全部启用)
|
||||
/// 返回整流器配置,如果不存在则返回默认值(全部开启)
|
||||
pub fn get_rectifier_config(&self) -> Result<crate::proxy::types::RectifierConfig, AppError> {
|
||||
match self.get_setting("rectifier_config")? {
|
||||
Some(json) => serde_json::from_str(&json)
|
||||
|
||||
@@ -37,7 +37,7 @@ pub use dao::OmoGlobalConfig;
|
||||
|
||||
use crate::config::get_app_config_dir;
|
||||
use crate::error::AppError;
|
||||
use rusqlite::Connection;
|
||||
use rusqlite::{hooks::Action, Connection};
|
||||
use serde::Serialize;
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -76,6 +76,17 @@ pub struct Database {
|
||||
pub(crate) conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
fn register_db_change_hook(conn: &Connection) {
|
||||
conn.update_hook(Some(
|
||||
|action: Action, _database: &str, table: &str, _row_id: i64| match action {
|
||||
Action::SQLITE_INSERT | Action::SQLITE_UPDATE | Action::SQLITE_DELETE => {
|
||||
crate::services::webdav_auto_sync::notify_db_changed(table);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// 初始化数据库连接并创建表
|
||||
///
|
||||
@@ -93,6 +104,7 @@ impl Database {
|
||||
// 启用外键约束
|
||||
conn.execute("PRAGMA foreign_keys = ON;", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
register_db_change_hook(&conn);
|
||||
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
@@ -111,6 +123,7 @@ impl Database {
|
||||
// 启用外键约束
|
||||
conn.execute("PRAGMA foreign_keys = ON;", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
register_db_change_hook(&conn);
|
||||
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
|
||||
@@ -297,6 +297,15 @@ fn schema_migration_v4_adds_pricing_model_columns() {
|
||||
r#"
|
||||
CREATE TABLE proxy_config (app_type TEXT PRIMARY KEY);
|
||||
CREATE TABLE proxy_request_logs (request_id TEXT PRIMARY KEY, model TEXT NOT NULL);
|
||||
CREATE TABLE mcp_servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
server_config TEXT NOT NULL,
|
||||
enabled_claude INTEGER NOT NULL DEFAULT 0,
|
||||
enabled_codex INTEGER NOT NULL DEFAULT 0,
|
||||
enabled_gemini INTEGER NOT NULL DEFAULT 0,
|
||||
enabled_opencode INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.expect("seed v4 schema");
|
||||
|
||||
@@ -175,6 +175,10 @@ 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}"
|
||||
|
||||
@@ -146,6 +146,7 @@ pub(crate) fn build_provider_from_request(
|
||||
AppType::Codex => build_codex_settings(request),
|
||||
AppType::Gemini => build_gemini_settings(request),
|
||||
AppType::OpenCode => build_opencode_settings(request),
|
||||
AppType::OpenClaw => build_openclaw_settings(request),
|
||||
};
|
||||
|
||||
// Build usage script configuration if provided
|
||||
@@ -391,6 +392,35 @@ fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value
|
||||
})
|
||||
}
|
||||
|
||||
fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
let endpoint = get_primary_endpoint(request);
|
||||
|
||||
// Build OpenClaw provider config
|
||||
// Format: { baseUrl, apiKey, api, models }
|
||||
let mut config = serde_json::Map::new();
|
||||
|
||||
if !endpoint.is_empty() {
|
||||
config.insert("baseUrl".to_string(), json!(endpoint));
|
||||
}
|
||||
|
||||
if let Some(api_key) = &request.api_key {
|
||||
config.insert("apiKey".to_string(), json!(api_key));
|
||||
}
|
||||
|
||||
// Default to OpenAI-compatible API
|
||||
config.insert("api".to_string(), json!("openai-completions"));
|
||||
|
||||
// Build models array
|
||||
if let Some(model) = &request.model {
|
||||
config.insert(
|
||||
"models".to_string(),
|
||||
json!([{ "id": model, "name": model }]),
|
||||
);
|
||||
}
|
||||
|
||||
json!(config)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Config Merge Logic
|
||||
// =============================================================================
|
||||
@@ -452,6 +482,10 @@ pub fn parse_and_merge_config(
|
||||
"claude" => merge_claude_config(&mut merged, &config_value)?,
|
||||
"codex" => merge_codex_config(&mut merged, &config_value)?,
|
||||
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
|
||||
// Additive mode apps use JSON config directly; pass through as-is
|
||||
"openclaw" | "opencode" => {
|
||||
merge_additive_config(&mut merged, &config_value)?;
|
||||
}
|
||||
"" => {
|
||||
// No app specified, skip merging
|
||||
return Ok(merged);
|
||||
@@ -623,6 +657,47 @@ fn merge_gemini_config(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Merge configuration for additive mode apps (OpenClaw, OpenCode)
|
||||
///
|
||||
/// These apps use JSON config directly, so we only extract common fields
|
||||
/// (api_key, endpoint, model) from the config if not already set in URL params.
|
||||
fn merge_additive_config(
|
||||
request: &mut DeepLinkImportRequest,
|
||||
config: &serde_json::Value,
|
||||
) -> Result<(), AppError> {
|
||||
// Extract api_key from config if not provided in URL
|
||||
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(api_key) = config
|
||||
.get("apiKey")
|
||||
.or_else(|| config.get("api_key"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
request.api_key = Some(api_key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Extract endpoint from config if not provided in URL
|
||||
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(base_url) = config
|
||||
.get("baseUrl")
|
||||
.or_else(|| config.get("base_url"))
|
||||
.or_else(|| config.get("options").and_then(|o| o.get("baseURL")))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
request.endpoint = Some(base_url.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-fill homepage from endpoint
|
||||
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
|
||||
request.homepage = infer_homepage_from_endpoint(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract base_url from Codex TOML config
|
||||
fn extract_codex_base_url(toml_value: &toml::Value) -> Option<String> {
|
||||
// Try to find base_url in model_providers section
|
||||
|
||||
+55
-1
@@ -13,6 +13,7 @@ mod gemini_config;
|
||||
mod gemini_mcp;
|
||||
mod init_status;
|
||||
mod mcp;
|
||||
mod openclaw_config;
|
||||
mod opencode_config;
|
||||
mod panic_hook;
|
||||
mod prompt;
|
||||
@@ -500,7 +501,7 @@ pub fn run() {
|
||||
log::info!("✓ Imported {count} OpenCode provider(s) from live config");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No OpenCode providers found to import"),
|
||||
Err(e) => log::debug!("○ Failed to import OpenCode providers: {e}"),
|
||||
Err(e) => log::warn!("○ Failed to import OpenCode providers: {e}"),
|
||||
}
|
||||
|
||||
// 2.2 OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入)
|
||||
@@ -525,6 +526,17 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 2.3 OpenClaw 供应商导入(累加式模式,需特殊处理)
|
||||
// OpenClaw 与 OpenCode 类似:配置文件中可同时存在多个供应商
|
||||
// 需要遍历 models.providers 字段下的每个供应商并导入
|
||||
match crate::services::provider::import_openclaw_providers_from_live(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} OpenClaw provider(s) from live config");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No OpenClaw providers found to import"),
|
||||
Err(e) => log::warn!("○ Failed to import OpenClaw providers: {e}"),
|
||||
}
|
||||
|
||||
// 3. 导入 MCP 服务器配置(表空时触发)
|
||||
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
|
||||
log::info!("MCP table empty, importing from live configurations...");
|
||||
@@ -570,6 +582,8 @@ pub fn run() {
|
||||
crate::app_config::AppType::Claude,
|
||||
crate::app_config::AppType::Codex,
|
||||
crate::app_config::AppType::Gemini,
|
||||
crate::app_config::AppType::OpenCode,
|
||||
crate::app_config::AppType::OpenClaw,
|
||||
] {
|
||||
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
|
||||
&app_state,
|
||||
@@ -688,6 +702,10 @@ pub fn run() {
|
||||
}
|
||||
|
||||
let _tray = tray_builder.build(app)?;
|
||||
crate::services::webdav_auto_sync::start_worker(
|
||||
app_state.db.clone(),
|
||||
app.handle().clone(),
|
||||
);
|
||||
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
||||
app.manage(app_state);
|
||||
|
||||
@@ -768,6 +786,21 @@ 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") {
|
||||
@@ -871,6 +904,11 @@ pub fn run() {
|
||||
// theirs: config import/export and dialogs
|
||||
commands::export_config_to_file,
|
||||
commands::import_config_from_file,
|
||||
commands::webdav_test_connection,
|
||||
commands::webdav_sync_upload,
|
||||
commands::webdav_sync_download,
|
||||
commands::webdav_sync_save_settings,
|
||||
commands::webdav_sync_fetch_remote_info,
|
||||
commands::save_file_dialog,
|
||||
commands::open_file_dialog,
|
||||
commands::open_zip_file_dialog,
|
||||
@@ -972,6 +1010,19 @@ pub fn run() {
|
||||
// OpenCode specific
|
||||
commands::import_opencode_providers_from_live,
|
||||
commands::get_opencode_live_provider_ids,
|
||||
// OpenClaw specific
|
||||
commands::import_openclaw_providers_from_live,
|
||||
commands::get_openclaw_live_provider_ids,
|
||||
commands::get_openclaw_default_model,
|
||||
commands::set_openclaw_default_model,
|
||||
commands::get_openclaw_model_catalog,
|
||||
commands::set_openclaw_model_catalog,
|
||||
commands::get_openclaw_agents_defaults,
|
||||
commands::set_openclaw_agents_defaults,
|
||||
commands::get_openclaw_env,
|
||||
commands::set_openclaw_env,
|
||||
commands::get_openclaw_tools,
|
||||
commands::set_openclaw_tools,
|
||||
// Global upstream proxy
|
||||
commands::get_global_proxy_url,
|
||||
commands::set_global_proxy_url,
|
||||
@@ -984,6 +1035,9 @@ pub fn run() {
|
||||
commands::get_current_omo_provider_id,
|
||||
commands::get_omo_provider_count,
|
||||
commands::disable_current_omo,
|
||||
// Workspace files (OpenClaw)
|
||||
commands::read_workspace_file,
|
||||
commands::write_workspace_file,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
//! OpenClaw 配置文件读写模块
|
||||
//!
|
||||
//! 处理 `~/.openclaw/openclaw.json` 配置文件的读写操作(JSON5 格式)。
|
||||
//! OpenClaw 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。
|
||||
//!
|
||||
//! ## 配置文件格式
|
||||
//!
|
||||
//! ```json5
|
||||
//! {
|
||||
//! // 模型供应商配置(映射为 CC Switch 的"供应商")
|
||||
//! models: {
|
||||
//! mode: "merge",
|
||||
//! providers: {
|
||||
//! "custom-provider": {
|
||||
//! baseUrl: "https://api.example.com/v1",
|
||||
//! apiKey: "${API_KEY}",
|
||||
//! api: "openai-completions",
|
||||
//! models: [{ id: "model-id", name: "Model Name" }]
|
||||
//! }
|
||||
//! }
|
||||
//! },
|
||||
//! // 环境变量配置
|
||||
//! env: {
|
||||
//! ANTHROPIC_API_KEY: "sk-...",
|
||||
//! vars: { ... }
|
||||
//! },
|
||||
//! // Agent 默认模型配置
|
||||
//! agents: {
|
||||
//! defaults: {
|
||||
//! model: {
|
||||
//! primary: "provider/model",
|
||||
//! fallbacks: ["provider2/model2"]
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use crate::config::write_json_file;
|
||||
use crate::error::AppError;
|
||||
use crate::settings::get_openclaw_override_dir;
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// ============================================================================
|
||||
// Path Functions
|
||||
// ============================================================================
|
||||
|
||||
/// 获取 OpenClaw 配置目录
|
||||
///
|
||||
/// 默认路径: `~/.openclaw/`
|
||||
/// 可通过 settings.openclaw_config_dir 覆盖
|
||||
pub fn get_openclaw_dir() -> PathBuf {
|
||||
if let Some(override_dir) = get_openclaw_override_dir() {
|
||||
return override_dir;
|
||||
}
|
||||
|
||||
// 所有平台统一使用 ~/.openclaw
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".openclaw"))
|
||||
.unwrap_or_else(|| PathBuf::from(".openclaw"))
|
||||
}
|
||||
|
||||
/// 获取 OpenClaw 配置文件路径
|
||||
///
|
||||
/// 返回 `~/.openclaw/openclaw.json`
|
||||
pub fn get_openclaw_config_path() -> PathBuf {
|
||||
get_openclaw_dir().join("openclaw.json")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Type Definitions
|
||||
// ============================================================================
|
||||
|
||||
/// OpenClaw 供应商配置(对应 models.providers 中的条目)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpenClawProviderConfig {
|
||||
/// API 基础 URL
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<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)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use crate::codex_config::get_codex_auth_path;
|
||||
use crate::config::get_claude_settings_path;
|
||||
use crate::error::AppError;
|
||||
use crate::gemini_config::get_gemini_dir;
|
||||
use crate::openclaw_config::get_openclaw_dir;
|
||||
use crate::opencode_config::get_opencode_dir;
|
||||
|
||||
/// 返回指定应用所使用的提示词文件路径。
|
||||
@@ -14,6 +15,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<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 {
|
||||
@@ -21,6 +23,7 @@ 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,7 +8,10 @@ use super::{
|
||||
failover_switch::FailoverSwitchManager,
|
||||
provider_router::ProviderRouter,
|
||||
providers::{get_adapter, ProviderAdapter, ProviderType},
|
||||
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
|
||||
thinking_budget_rectifier::{rectify_thinking_budget, should_rectify_thinking_budget},
|
||||
thinking_rectifier::{
|
||||
normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,
|
||||
},
|
||||
types::{ProxyStatus, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
@@ -157,6 +160,7 @@ impl RequestForwarder {
|
||||
|
||||
// 整流器重试标记:确保整流最多触发一次
|
||||
let mut rectifier_retried = false;
|
||||
let mut budget_rectifier_retried = false;
|
||||
|
||||
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
|
||||
let bypass_circuit_breaker = providers.len() == 1;
|
||||
@@ -258,6 +262,7 @@ impl RequestForwarder {
|
||||
provider_type,
|
||||
ProviderType::Claude | ProviderType::ClaudeAuth
|
||||
);
|
||||
let mut signature_rectifier_non_retryable_client_error = false;
|
||||
|
||||
if is_anthropic_provider {
|
||||
let error_message = extract_error_message(&e);
|
||||
@@ -293,12 +298,185 @@ impl RequestForwarder {
|
||||
// 首次触发:整流请求体
|
||||
let rectified = rectify_anthropic_request(&mut body);
|
||||
|
||||
// 整流未生效:直接返回错误(不可重试客户端错误)
|
||||
// 整流未生效:继续尝试 budget 整流路径,避免误判后短路
|
||||
if !rectified.applied {
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-006] 整流器触发但无可整流内容,不做无意义重试"
|
||||
"[{app_type_str}] [RECT-006] thinking 签名整流器触发但无可整流内容,继续检查 budget;若 budget 也未命中则按客户端错误返回"
|
||||
);
|
||||
signature_rectifier_non_retryable_client_error = true;
|
||||
} else {
|
||||
log::info!(
|
||||
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
|
||||
app_type_str,
|
||||
rectified.removed_thinking_blocks,
|
||||
rectified.removed_redacted_thinking_blocks,
|
||||
rectified.removed_signature_fields
|
||||
);
|
||||
|
||||
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
|
||||
let _ = std::mem::replace(&mut rectifier_retried, true);
|
||||
|
||||
// 使用同一供应商重试(不计入熔断器)
|
||||
match self
|
||||
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
|
||||
// 记录成功
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 更新当前应用类型使用的 provider
|
||||
{
|
||||
let mut current_providers =
|
||||
self.current_providers.write().await;
|
||||
current_providers.insert(
|
||||
app_type_str.to_string(),
|
||||
(provider.id.clone(), provider.name.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
// 更新成功统计
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.success_requests += 1;
|
||||
status.last_error = None;
|
||||
let should_switch =
|
||||
self.current_provider_id_at_start.as_str()
|
||||
!= provider.id.as_str();
|
||||
if should_switch {
|
||||
status.failover_count += 1;
|
||||
|
||||
// 异步触发供应商切换,更新 UI/托盘
|
||||
let fm = self.failover_manager.clone();
|
||||
let ah = self.app_handle.clone();
|
||||
let pid = provider.id.clone();
|
||||
let pname = provider.name.clone();
|
||||
let at = app_type_str.to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = fm
|
||||
.try_switch(ah.as_ref(), &at, &pid, &pname)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests
|
||||
as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(ForwardResult {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
});
|
||||
}
|
||||
Err(retry_err) => {
|
||||
// 整流重试仍失败:区分错误类型决定是否记录熔断器
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
|
||||
);
|
||||
|
||||
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
|
||||
let is_provider_error = match &retry_err {
|
||||
ProxyError::Timeout(_)
|
||||
| ProxyError::ForwardFailed(_) => true,
|
||||
ProxyError::UpstreamError { status, .. } => {
|
||||
*status >= 500
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if is_provider_error {
|
||||
// Provider 问题:记录失败到熔断器
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
false,
|
||||
Some(retry_err.to_string()),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
// 客户端问题:仅释放 permit,不记录熔断器
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some(retry_err.to_string());
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
return Err(ForwardError {
|
||||
error: retry_err,
|
||||
provider: Some(provider.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检测是否需要触发 budget 整流器(仅 Claude/ClaudeAuth 供应商)
|
||||
if is_anthropic_provider {
|
||||
let error_message = extract_error_message(&e);
|
||||
if should_rectify_thinking_budget(
|
||||
error_message.as_deref(),
|
||||
&self.rectifier_config,
|
||||
) {
|
||||
// 已经重试过:直接返回错误(不可重试客户端错误)
|
||||
if budget_rectifier_retried {
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-013] budget 整流器已触发过,不再重试"
|
||||
);
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some(e.to_string());
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
return Err(ForwardError {
|
||||
error: e,
|
||||
provider: Some(provider.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
let budget_rectified = rectify_thinking_budget(&mut body);
|
||||
if !budget_rectified.applied {
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-014] budget 整流器触发但无可整流内容,不做无意义重试"
|
||||
);
|
||||
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
@@ -321,15 +499,13 @@ impl RequestForwarder {
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
|
||||
"[{}] [RECT-010] thinking budget 整流器触发, before={:?}, after={:?}",
|
||||
app_type_str,
|
||||
rectified.removed_thinking_blocks,
|
||||
rectified.removed_redacted_thinking_blocks,
|
||||
rectified.removed_signature_fields
|
||||
budget_rectified.before,
|
||||
budget_rectified.after
|
||||
);
|
||||
|
||||
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
|
||||
let _ = std::mem::replace(&mut rectifier_retried, true);
|
||||
let _ = std::mem::replace(&mut budget_rectifier_retried, true);
|
||||
|
||||
// 使用同一供应商重试(不计入熔断器)
|
||||
match self
|
||||
@@ -337,8 +513,7 @@ impl RequestForwarder {
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
|
||||
// 记录成功
|
||||
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
@@ -350,7 +525,6 @@ impl RequestForwarder {
|
||||
)
|
||||
.await;
|
||||
|
||||
// 更新当前应用类型使用的 provider
|
||||
{
|
||||
let mut current_providers =
|
||||
self.current_providers.write().await;
|
||||
@@ -360,7 +534,6 @@ impl RequestForwarder {
|
||||
);
|
||||
}
|
||||
|
||||
// 更新成功统计
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.success_requests += 1;
|
||||
@@ -370,14 +543,11 @@ impl RequestForwarder {
|
||||
!= provider.id.as_str();
|
||||
if should_switch {
|
||||
status.failover_count += 1;
|
||||
|
||||
// 异步触发供应商切换,更新 UI/托盘
|
||||
let fm = self.failover_manager.clone();
|
||||
let ah = self.app_handle.clone();
|
||||
let pid = provider.id.clone();
|
||||
let pname = provider.name.clone();
|
||||
let at = app_type_str.to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = fm
|
||||
.try_switch(ah.as_ref(), &at, &pid, &pname)
|
||||
@@ -397,12 +567,10 @@ impl RequestForwarder {
|
||||
});
|
||||
}
|
||||
Err(retry_err) => {
|
||||
// 整流重试仍失败:区分错误类型决定是否记录熔断器
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
|
||||
"[{app_type_str}] [RECT-012] budget 整流重试仍失败: {retry_err}"
|
||||
);
|
||||
|
||||
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
|
||||
let is_provider_error = match &retry_err {
|
||||
ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => {
|
||||
true
|
||||
@@ -412,7 +580,6 @@ impl RequestForwarder {
|
||||
};
|
||||
|
||||
if is_provider_error {
|
||||
// Provider 问题:记录失败到熔断器
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
@@ -424,7 +591,6 @@ impl RequestForwarder {
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
// 客户端问题:仅释放 permit,不记录熔断器
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
@@ -451,6 +617,28 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
if signature_rectifier_non_retryable_client_error {
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some(e.to_string());
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
return Err(ForwardError {
|
||||
error: e,
|
||||
provider: Some(provider.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
// 失败:记录失败并更新熔断器
|
||||
let _ = self
|
||||
.router
|
||||
@@ -575,6 +763,9 @@ impl RequestForwarder {
|
||||
let (mapped_body, _original_model, _mapped_model) =
|
||||
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||
|
||||
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
|
||||
let mapped_body = normalize_thinking_type(mapped_body);
|
||||
|
||||
// 转换请求体(如果需要)
|
||||
let request_body = if needs_transform {
|
||||
adapter.transform_request(mapped_body, provider)?
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod response_handler;
|
||||
pub mod response_processor;
|
||||
pub(crate) mod server;
|
||||
pub mod session;
|
||||
pub mod thinking_budget_rectifier;
|
||||
pub mod thinking_rectifier;
|
||||
pub(crate) mod types;
|
||||
pub mod usage;
|
||||
|
||||
@@ -97,11 +97,21 @@ impl ModelMapping {
|
||||
|
||||
/// 检测请求是否启用了 thinking 模式
|
||||
pub fn has_thinking_enabled(body: &Value) -> bool {
|
||||
body.get("thinking")
|
||||
match body
|
||||
.get("thinking")
|
||||
.and_then(|v| v.as_object())
|
||||
.and_then(|o| o.get("type"))
|
||||
.and_then(|t| t.as_str())
|
||||
== Some("enabled")
|
||||
{
|
||||
Some("enabled") | Some("adaptive") => true,
|
||||
Some("disabled") | None => false,
|
||||
Some(other) => {
|
||||
log::warn!(
|
||||
"[ModelMapper] 未知 thinking.type='{other}',按 disabled 处理以避免误路由 reasoning 模型"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 对请求体应用模型映射
|
||||
@@ -300,6 +310,30 @@ mod tests {
|
||||
assert!(mapped.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_adaptive() {
|
||||
let provider = create_provider_with_mapping();
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"thinking": {"type": "adaptive"}
|
||||
});
|
||||
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "reasoning-model");
|
||||
assert_eq!(mapped, Some("reasoning-model".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_unknown_type() {
|
||||
let provider = create_provider_with_mapping();
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"thinking": {"type": "some_future_type"}
|
||||
});
|
||||
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "sonnet-mapped");
|
||||
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_insensitive() {
|
||||
let provider = create_provider_with_mapping();
|
||||
|
||||
@@ -136,6 +136,10 @@ impl ProviderType {
|
||||
// OpenCode doesn't support proxy, but return a default type for completeness
|
||||
ProviderType::Codex // Fallback to Codex-like type
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy, but return a default type for completeness
|
||||
ProviderType::Codex // Fallback to Codex-like type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +188,10 @@ pub fn get_adapter(app_type: &AppType) -> Box<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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
//! Thinking Budget 整流器
|
||||
//!
|
||||
//! 用于自动修复 Anthropic API 中因 thinking budget 约束导致的请求错误。
|
||||
//! 当上游 API 返回 budget_tokens 相关错误时,系统会自动调整 budget 参数并重试。
|
||||
|
||||
use super::types::RectifierConfig;
|
||||
use serde_json::Value;
|
||||
|
||||
/// 最大 thinking budget tokens
|
||||
const MAX_THINKING_BUDGET: u64 = 32000;
|
||||
|
||||
/// 最大 max_tokens 值
|
||||
const MAX_TOKENS_VALUE: u64 = 64000;
|
||||
|
||||
/// max_tokens 必须大于 budget_tokens
|
||||
const MIN_MAX_TOKENS_FOR_BUDGET: u64 = MAX_THINKING_BUDGET + 1;
|
||||
|
||||
/// Budget 整流结果
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct BudgetRectifySnapshot {
|
||||
/// max_tokens
|
||||
pub max_tokens: Option<u64>,
|
||||
/// thinking.type
|
||||
pub thinking_type: Option<String>,
|
||||
/// thinking.budget_tokens
|
||||
pub thinking_budget_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
/// Budget 整流结果
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct BudgetRectifyResult {
|
||||
/// 是否应用了整流
|
||||
pub applied: bool,
|
||||
/// 整流前快照
|
||||
pub before: BudgetRectifySnapshot,
|
||||
/// 整流后快照
|
||||
pub after: BudgetRectifySnapshot,
|
||||
}
|
||||
|
||||
/// 检测是否需要触发 thinking budget 整流器
|
||||
///
|
||||
/// 检测条件:error message 同时包含 `budget_tokens` + `thinking` 相关约束
|
||||
pub fn should_rectify_thinking_budget(
|
||||
error_message: Option<&str>,
|
||||
config: &RectifierConfig,
|
||||
) -> bool {
|
||||
// 检查总开关
|
||||
if !config.enabled {
|
||||
return false;
|
||||
}
|
||||
// 检查子开关
|
||||
if !config.request_thinking_budget {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(msg) = error_message else {
|
||||
return false;
|
||||
};
|
||||
let lower = msg.to_lowercase();
|
||||
|
||||
// 与 CCH 对齐:仅在包含 budget_tokens + thinking + 1024 约束时触发
|
||||
let has_budget_tokens_reference =
|
||||
lower.contains("budget_tokens") || lower.contains("budget tokens");
|
||||
let has_thinking_reference = lower.contains("thinking");
|
||||
let has_1024_constraint = lower.contains("greater than or equal to 1024")
|
||||
|| lower.contains(">= 1024")
|
||||
|| (lower.contains("1024") && lower.contains("input should be"));
|
||||
if has_budget_tokens_reference && has_thinking_reference && has_1024_constraint {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 对请求体执行 budget 整流
|
||||
///
|
||||
/// 整流动作:
|
||||
/// - `thinking.type = "enabled"`
|
||||
/// - `thinking.budget_tokens = 32000`
|
||||
/// - 如果 `max_tokens < 32001`,设为 `64000`
|
||||
pub fn rectify_thinking_budget(body: &mut Value) -> BudgetRectifyResult {
|
||||
let before = snapshot_budget(body);
|
||||
|
||||
// 与 CCH 对齐:adaptive 请求不改写
|
||||
if before.thinking_type.as_deref() == Some("adaptive") {
|
||||
return BudgetRectifyResult {
|
||||
applied: false,
|
||||
before: before.clone(),
|
||||
after: before,
|
||||
};
|
||||
}
|
||||
|
||||
// 与 CCH 对齐:缺少/非法 thinking 时自动创建后再整流
|
||||
if !body.get("thinking").is_some_and(Value::is_object) {
|
||||
body["thinking"] = Value::Object(serde_json::Map::new());
|
||||
}
|
||||
|
||||
let Some(thinking) = body.get_mut("thinking").and_then(|t| t.as_object_mut()) else {
|
||||
return BudgetRectifyResult {
|
||||
applied: false,
|
||||
before: before.clone(),
|
||||
after: before,
|
||||
};
|
||||
};
|
||||
|
||||
thinking.insert("type".to_string(), Value::String("enabled".to_string()));
|
||||
thinking.insert(
|
||||
"budget_tokens".to_string(),
|
||||
Value::Number(MAX_THINKING_BUDGET.into()),
|
||||
);
|
||||
|
||||
if before.max_tokens.is_none() || before.max_tokens < Some(MIN_MAX_TOKENS_FOR_BUDGET) {
|
||||
body["max_tokens"] = Value::Number(MAX_TOKENS_VALUE.into());
|
||||
}
|
||||
|
||||
let after = snapshot_budget(body);
|
||||
BudgetRectifyResult {
|
||||
applied: before != after,
|
||||
before,
|
||||
after,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_budget(body: &Value) -> BudgetRectifySnapshot {
|
||||
let max_tokens = body.get("max_tokens").and_then(|v| v.as_u64());
|
||||
let thinking = body.get("thinking").and_then(|t| t.as_object());
|
||||
let thinking_type = thinking
|
||||
.and_then(|t| t.get("type"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToString::to_string);
|
||||
let thinking_budget_tokens = thinking
|
||||
.and_then(|t| t.get("budget_tokens"))
|
||||
.and_then(|v| v.as_u64());
|
||||
BudgetRectifySnapshot {
|
||||
max_tokens,
|
||||
thinking_type,
|
||||
thinking_budget_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn enabled_config() -> RectifierConfig {
|
||||
RectifierConfig {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn budget_disabled_config() -> RectifierConfig {
|
||||
RectifierConfig {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn master_disabled_config() -> RectifierConfig {
|
||||
RectifierConfig {
|
||||
enabled: false,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== should_rectify_thinking_budget 测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_detect_budget_tokens_thinking_error() {
|
||||
assert!(should_rectify_thinking_budget(
|
||||
Some("thinking.budget_tokens: Input should be greater than or equal to 1024"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_budget_tokens_max_tokens_error() {
|
||||
assert!(!should_rectify_thinking_budget(
|
||||
Some("budget_tokens must be less than max_tokens"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_budget_tokens_1024_error() {
|
||||
assert!(!should_rectify_thinking_budget(
|
||||
Some("budget_tokens: value must be at least 1024"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_budget_tokens_with_thinking_and_1024_error() {
|
||||
assert!(should_rectify_thinking_budget(
|
||||
Some("thinking budget_tokens must be >= 1024"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_trigger_for_unrelated_error() {
|
||||
assert!(!should_rectify_thinking_budget(
|
||||
Some("Request timeout"),
|
||||
&enabled_config()
|
||||
));
|
||||
assert!(!should_rectify_thinking_budget(None, &enabled_config()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disabled_budget_config() {
|
||||
assert!(!should_rectify_thinking_budget(
|
||||
Some("thinking.budget_tokens: Input should be greater than or equal to 1024"),
|
||||
&budget_disabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_master_disabled() {
|
||||
assert!(!should_rectify_thinking_budget(
|
||||
Some("thinking.budget_tokens: Input should be greater than or equal to 1024"),
|
||||
&master_disabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== rectify_thinking_budget 测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_rectify_budget_basic() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled", "budget_tokens": 512 },
|
||||
"max_tokens": 1024
|
||||
});
|
||||
|
||||
let result = rectify_thinking_budget(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.before.thinking_type.as_deref(), Some("enabled"));
|
||||
assert_eq!(result.after.thinking_type.as_deref(), Some("enabled"));
|
||||
assert_eq!(result.before.thinking_budget_tokens, Some(512));
|
||||
assert_eq!(
|
||||
result.after.thinking_budget_tokens,
|
||||
Some(MAX_THINKING_BUDGET)
|
||||
);
|
||||
assert_eq!(result.before.max_tokens, Some(1024));
|
||||
assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET);
|
||||
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_budget_skips_adaptive() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "adaptive", "budget_tokens": 512 },
|
||||
"max_tokens": 1024
|
||||
});
|
||||
|
||||
let result = rectify_thinking_budget(&mut body);
|
||||
|
||||
assert!(!result.applied);
|
||||
assert_eq!(result.before, result.after);
|
||||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], 512);
|
||||
assert_eq!(body["max_tokens"], 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_budget_preserves_large_max_tokens() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled", "budget_tokens": 512 },
|
||||
"max_tokens": 100000
|
||||
});
|
||||
|
||||
let result = rectify_thinking_budget(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.before.max_tokens, Some(100000));
|
||||
assert_eq!(result.after.max_tokens, Some(100000));
|
||||
assert_eq!(body["max_tokens"], 100000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_budget_creates_thinking_object_when_missing() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"max_tokens": 1024
|
||||
});
|
||||
|
||||
let result = rectify_thinking_budget(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.before.thinking_type, None);
|
||||
assert_eq!(result.after.thinking_type.as_deref(), Some("enabled"));
|
||||
assert_eq!(
|
||||
result.after.thinking_budget_tokens,
|
||||
Some(MAX_THINKING_BUDGET)
|
||||
);
|
||||
assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET);
|
||||
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_budget_no_max_tokens() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled", "budget_tokens": 512 }
|
||||
});
|
||||
|
||||
let result = rectify_thinking_budget(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.before.max_tokens, None);
|
||||
assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));
|
||||
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_budget_normalizes_non_enabled_type() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "disabled", "budget_tokens": 512 },
|
||||
"max_tokens": 1024
|
||||
});
|
||||
|
||||
let result = rectify_thinking_budget(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.before.thinking_type.as_deref(), Some("disabled"));
|
||||
assert_eq!(result.after.thinking_type.as_deref(), Some("enabled"));
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET);
|
||||
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_budget_no_change_when_already_valid() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled", "budget_tokens": 32000 },
|
||||
"max_tokens": 64001
|
||||
});
|
||||
|
||||
let result = rectify_thinking_budget(&mut body);
|
||||
|
||||
assert!(!result.applied);
|
||||
assert_eq!(result.before, result.after);
|
||||
assert_eq!(body["thinking"]["budget_tokens"], 32000);
|
||||
assert_eq!(body["max_tokens"], 64001);
|
||||
}
|
||||
}
|
||||
@@ -59,10 +59,12 @@ pub fn should_rectify_thinking_signature(
|
||||
}
|
||||
|
||||
// 场景3: expected thinking or redacted_thinking, found tool_use
|
||||
// 与 CCH 对齐:要求明确包含 tool_use,避免过宽匹配。
|
||||
// 错误示例: "Expected `thinking` or `redacted_thinking`, but found `tool_use`"
|
||||
if lower.contains("expected")
|
||||
&& (lower.contains("thinking") || lower.contains("redacted_thinking"))
|
||||
&& lower.contains("found")
|
||||
&& lower.contains("tool_use")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -73,6 +75,28 @@ pub fn should_rectify_thinking_signature(
|
||||
return true;
|
||||
}
|
||||
|
||||
// 场景5: signature 字段不被接受(第三方渠道)
|
||||
// 错误示例: "xxx.signature: Extra inputs are not permitted"
|
||||
if lower.contains("signature") && lower.contains("extra inputs are not permitted") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 场景6: thinking/redacted_thinking 块被修改
|
||||
// 错误示例: "thinking or redacted_thinking blocks ... cannot be modified"
|
||||
if (lower.contains("thinking") || lower.contains("redacted_thinking"))
|
||||
&& lower.contains("cannot be modified")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 场景7: 非法请求(与 CCH 对齐,按 invalid request 统一兜底)
|
||||
if lower.contains("非法请求")
|
||||
|| lower.contains("illegal request")
|
||||
|| lower.contains("invalid request")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
@@ -159,11 +183,13 @@ pub fn rectify_anthropic_request(body: &mut Value) -> RectifyResult {
|
||||
/// 判断是否需要删除顶层 thinking 字段
|
||||
fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
|
||||
// 检查 thinking 是否启用
|
||||
let thinking_enabled = body
|
||||
let thinking_type = body
|
||||
.get("thinking")
|
||||
.and_then(|t| t.get("type"))
|
||||
.and_then(|t| t.as_str())
|
||||
== Some("enabled");
|
||||
.and_then(|t| t.as_str());
|
||||
|
||||
// 与 CCH 对齐:仅 type=enabled 视为开启
|
||||
let thinking_enabled = thinking_type == Some("enabled");
|
||||
|
||||
if !thinking_enabled {
|
||||
return false;
|
||||
@@ -202,6 +228,11 @@ fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
|
||||
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
|
||||
}
|
||||
|
||||
/// 与 CCH 对齐:请求前不做 thinking type 主动改写。
|
||||
pub fn normalize_thinking_type(body: Value) -> Value {
|
||||
body
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -211,6 +242,7 @@ mod tests {
|
||||
RectifierConfig {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +250,7 @@ mod tests {
|
||||
RectifierConfig {
|
||||
enabled: true,
|
||||
request_thinking_signature: false,
|
||||
request_thinking_budget: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +258,7 @@ mod tests {
|
||||
RectifierConfig {
|
||||
enabled: false,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +298,14 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_detect_thinking_expected_without_tool_use() {
|
||||
assert!(!should_rectify_thinking_signature(
|
||||
Some("messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `text`."),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_must_start_with_thinking() {
|
||||
assert!(should_rectify_thinking_signature(
|
||||
@@ -418,4 +460,230 @@ mod tests {
|
||||
// 此时会触发删除顶层 thinking 的逻辑
|
||||
// 这是预期行为:整流后如果仍然不符合要求,就删除顶层 thinking
|
||||
}
|
||||
|
||||
// ==================== 新增错误场景检测测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_detect_signature_extra_inputs() {
|
||||
// 场景5: signature 字段不被接受
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("xxx.signature: Extra inputs are not permitted"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_thinking_cannot_be_modified() {
|
||||
// 场景6: thinking blocks cannot be modified
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("thinking or redacted_thinking blocks in the response cannot be modified"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_invalid_request() {
|
||||
// 场景7: 非法请求(与 CCH 对齐,统一触发)
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("非法请求:thinking signature 不合法"),
|
||||
&enabled_config()
|
||||
));
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("illegal request: tool_use block mismatch"),
|
||||
&enabled_config()
|
||||
));
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("invalid request: malformed JSON"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_do_not_detect_thinking_type_tag_mismatch() {
|
||||
// 与 CCH 对齐:adaptive tag mismatch 不触发签名整流器
|
||||
assert!(!should_rectify_thinking_signature(
|
||||
Some("Input tag 'adaptive' found using 'type' does not match expected tags"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== adaptive thinking type 测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_rectify_keeps_adaptive_when_no_legacy_blocks() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "adaptive" },
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{ "type": "text", "text": "hello" }]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(!result.applied);
|
||||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||||
assert!(body["thinking"].get("budget_tokens").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_adaptive_preserves_existing_budget_tokens() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "adaptive", "budget_tokens": 5000 },
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{ "type": "text", "text": "hello" }]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(!result.applied);
|
||||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_does_not_change_enabled_type() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled", "budget_tokens": 1024 },
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{ "type": "text", "text": "hello" }]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(!result.applied);
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_removes_top_level_thinking_adaptive() {
|
||||
// 顶层 thinking 仅在 type=enabled 且 tool_use 场景才会删除,adaptive 不删除
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "adaptive" },
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {} }
|
||||
]
|
||||
}, {
|
||||
"role": "user",
|
||||
"content": [{ "type": "tool_result", "tool_use_id": "toolu_1", "content": "ok" }]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(!result.applied);
|
||||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_adaptive_still_cleans_legacy_signature_blocks() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "adaptive" },
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{ "type": "thinking", "thinking": "t", "signature": "sig_thinking" },
|
||||
{ "type": "text", "text": "hello", "signature": "sig_text" }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.removed_thinking_blocks, 1);
|
||||
let content = body["messages"][0]["content"].as_array().unwrap();
|
||||
assert_eq!(content.len(), 1);
|
||||
assert_eq!(content[0]["type"], "text");
|
||||
assert!(content[0].get("signature").is_none());
|
||||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||||
}
|
||||
|
||||
// ==================== normalize_thinking_type 测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_normalize_thinking_type_adaptive_unchanged() {
|
||||
let body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "adaptive" }
|
||||
});
|
||||
|
||||
let result = normalize_thinking_type(body);
|
||||
|
||||
assert_eq!(result["thinking"]["type"], "adaptive");
|
||||
assert!(result["thinking"].get("budget_tokens").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_thinking_type_enabled_unchanged() {
|
||||
let body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled", "budget_tokens": 2048 }
|
||||
});
|
||||
|
||||
let result = normalize_thinking_type(body);
|
||||
|
||||
assert_eq!(result["thinking"]["type"], "enabled");
|
||||
assert_eq!(result["thinking"]["budget_tokens"], 2048);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_thinking_type_disabled_unchanged() {
|
||||
let body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "disabled" }
|
||||
});
|
||||
|
||||
let result = normalize_thinking_type(body);
|
||||
|
||||
assert_eq!(result["thinking"]["type"], "disabled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_thinking_type_preserves_budget() {
|
||||
let body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "adaptive", "budget_tokens": 5000 }
|
||||
});
|
||||
|
||||
let result = normalize_thinking_type(body);
|
||||
|
||||
assert_eq!(result["thinking"]["type"], "adaptive");
|
||||
assert_eq!(result["thinking"]["budget_tokens"], 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_thinking_type_no_thinking() {
|
||||
let body = json!({
|
||||
"model": "claude-test"
|
||||
});
|
||||
|
||||
let result = normalize_thinking_type(body);
|
||||
|
||||
assert!(result.get("thinking").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_thinking_type_unknown_unchanged() {
|
||||
let body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "unexpected", "budget_tokens": 100 }
|
||||
});
|
||||
|
||||
let result = normalize_thinking_type(body);
|
||||
|
||||
assert_eq!(result["thinking"]["type"], "unexpected");
|
||||
assert_eq!(result["thinking"]["budget_tokens"], 100);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,8 @@ pub struct ProxyTakeoverStatus {
|
||||
pub claude: bool,
|
||||
pub codex: bool,
|
||||
pub gemini: bool,
|
||||
pub opencode: bool,
|
||||
pub openclaw: bool,
|
||||
}
|
||||
|
||||
/// API 格式类型(预留,当前不需要格式转换)
|
||||
@@ -195,17 +197,22 @@ pub struct AppProxyConfig {
|
||||
/// 整流器配置
|
||||
///
|
||||
/// 存储在 settings 表中
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RectifierConfig {
|
||||
/// 总开关:是否启用整流器
|
||||
#[serde(default)]
|
||||
/// 总开关:是否启用整流器(默认开启)
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// 请求整流:启用 thinking 签名整流器
|
||||
/// 请求整流:启用 thinking 签名整流器(默认开启)
|
||||
///
|
||||
/// 处理错误:Invalid 'signature' in 'thinking' block
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_true")]
|
||||
pub request_thinking_signature: bool,
|
||||
/// 请求整流:启用 thinking budget 整流器(默认开启)
|
||||
///
|
||||
/// 处理错误:budget_tokens + thinking 相关约束
|
||||
#[serde(default = "default_true")]
|
||||
pub request_thinking_budget: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
@@ -216,6 +223,16 @@ fn default_log_level() -> String {
|
||||
"info".to_string()
|
||||
}
|
||||
|
||||
impl Default for RectifierConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志配置
|
||||
///
|
||||
/// 存储在 settings 表的 log_config 字段中(JSON 格式)
|
||||
@@ -261,32 +278,49 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_default_disabled() {
|
||||
// 验证 RectifierConfig::default() 返回全禁用状态
|
||||
fn test_rectifier_config_default_enabled() {
|
||||
// 验证 RectifierConfig::default() 返回全开启状态
|
||||
let config = RectifierConfig::default();
|
||||
assert!(!config.enabled, "整流器总开关默认应为 false");
|
||||
assert!(config.enabled, "整流器总开关默认应为 true");
|
||||
assert!(
|
||||
!config.request_thinking_signature,
|
||||
"thinking 签名整流器默认应为 false"
|
||||
config.request_thinking_signature,
|
||||
"thinking 签名整流器默认应为 true"
|
||||
);
|
||||
assert!(
|
||||
config.request_thinking_budget,
|
||||
"thinking budget 整流器默认应为 true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_serde_default() {
|
||||
// 验证反序列化缺字段时使用默认值 false
|
||||
// 验证反序列化缺字段时使用默认值 true
|
||||
let json = "{}";
|
||||
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(!config.enabled);
|
||||
assert!(!config.request_thinking_signature);
|
||||
assert!(config.enabled);
|
||||
assert!(config.request_thinking_signature);
|
||||
assert!(config.request_thinking_budget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_serde_explicit_true() {
|
||||
// 验证显式设置 true 时正确反序列化
|
||||
let json = r#"{"enabled": true, "requestThinkingSignature": true}"#;
|
||||
let json =
|
||||
r#"{"enabled": true, "requestThinkingSignature": true, "requestThinkingBudget": true}"#;
|
||||
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(config.enabled);
|
||||
assert!(config.request_thinking_signature);
|
||||
assert!(config.request_thinking_budget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_serde_partial_fields() {
|
||||
// 验证只设置部分字段时,缺失字段使用默认值 true
|
||||
let json = r#"{"enabled": true, "requestThinkingSignature": false}"#;
|
||||
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(config.enabled);
|
||||
assert!(!config.request_thinking_signature);
|
||||
assert!(config.request_thinking_budget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -126,6 +126,10 @@ impl ConfigService {
|
||||
// OpenCode uses additive mode, no live sync needed
|
||||
// OpenCode providers are managed directly in the config file
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw uses additive mode, no live sync needed
|
||||
// OpenClaw providers are managed directly in the config file
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -123,6 +123,11 @@ impl McpService {
|
||||
&server.server,
|
||||
)?;
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw MCP support is still in development (Issue #4834)
|
||||
// Skip for now
|
||||
log::debug!("OpenClaw MCP support is still in development, skipping sync");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -148,6 +153,10 @@ impl McpService {
|
||||
AppType::OpenCode => {
|
||||
mcp::remove_server_from_opencode(id)?;
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw MCP support is still in development
|
||||
log::debug!("OpenClaw MCP support is still in development, skipping remove");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ pub mod skill;
|
||||
pub mod speedtest;
|
||||
pub mod stream_check;
|
||||
pub mod usage_stats;
|
||||
pub mod webdav;
|
||||
pub mod webdav_auto_sync;
|
||||
pub mod webdav_sync;
|
||||
|
||||
pub use config::ConfigService;
|
||||
pub use mcp::McpService;
|
||||
|
||||
@@ -286,10 +286,22 @@ 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 {
|
||||
@@ -297,16 +309,17 @@ impl OmoService {
|
||||
};
|
||||
|
||||
let mut global = OmoGlobalConfig::default();
|
||||
Self::merge_global_from_obj(&obj, &mut global);
|
||||
Self::merge_global_from_obj(obj, &mut global);
|
||||
global.other_fields = other_fields.clone();
|
||||
|
||||
Ok(OmoLocalFileData {
|
||||
OmoLocalFileData {
|
||||
agents,
|
||||
categories,
|
||||
other_fields,
|
||||
global,
|
||||
file_path: actual_path.to_string_lossy().to_string(),
|
||||
file_path,
|
||||
last_modified,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_jsonc_comments(input: &str) -> String {
|
||||
@@ -400,7 +413,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;
|
||||
@@ -411,7 +424,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]
|
||||
@@ -422,7 +435,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;
|
||||
@@ -434,4 +447,61 @@ 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,48 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
}
|
||||
}
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw uses additive mode - write provider to config
|
||||
use crate::openclaw_config;
|
||||
use crate::openclaw_config::OpenClawProviderConfig;
|
||||
|
||||
// Convert settings_config to OpenClawProviderConfig
|
||||
let openclaw_config_result =
|
||||
serde_json::from_value::<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(())
|
||||
}
|
||||
@@ -340,6 +382,21 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,6 +405,12 @@ 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() {
|
||||
@@ -415,23 +478,9 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
||||
"config": config_obj
|
||||
})
|
||||
}
|
||||
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()?
|
||||
// OpenCode and OpenClaw use additive mode and are handled by early return above
|
||||
AppType::OpenCode | AppType::OpenClaw => {
|
||||
unreachable!("additive mode apps are handled by early return")
|
||||
}
|
||||
};
|
||||
|
||||
@@ -609,3 +658,87 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<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(())
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ use crate::store::AppState;
|
||||
|
||||
// Re-export sub-module functions for external access
|
||||
pub use live::{
|
||||
import_default_config, import_opencode_providers_from_live, read_live_settings,
|
||||
sync_current_to_live,
|
||||
import_default_config, import_openclaw_providers_from_live,
|
||||
import_opencode_providers_from_live, read_live_settings, sync_current_to_live,
|
||||
};
|
||||
|
||||
// Internal re-exports (pub(crate))
|
||||
@@ -30,7 +30,9 @@ pub(crate) use live::sanitize_claude_settings_for_live;
|
||||
pub(crate) use live::write_live_snapshot;
|
||||
|
||||
// Internal re-exports
|
||||
use live::{remove_opencode_provider_from_live, write_gemini_live};
|
||||
use live::{
|
||||
remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live,
|
||||
};
|
||||
use usage::validate_usage_script;
|
||||
|
||||
/// Provider business logic service
|
||||
@@ -142,10 +144,10 @@ impl ProviderService {
|
||||
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
||||
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
|
||||
///
|
||||
/// 对于 OpenCode(累加模式),不存在"当前供应商"概念,直接返回空字符串。
|
||||
/// 对于累加模式应用(OpenCode, OpenClaw),不存在"当前供应商"概念,直接返回空字符串。
|
||||
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
|
||||
// OpenCode uses additive mode - no "current" provider concept
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
// Additive mode apps have no "current" provider concept
|
||||
if app_type.is_additive_mode() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
crate::settings::get_effective_current_provider(&state.db, &app_type)
|
||||
@@ -162,10 +164,12 @@ impl ProviderService {
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
|
||||
// OpenCode uses additive mode - always write to live config
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
// Additive mode apps (OpenCode, OpenClaw) - always write to live config
|
||||
if app_type.is_additive_mode() {
|
||||
// OMO providers use exclusive mode and write to dedicated config file.
|
||||
if provider.category.as_deref() == Some("omo") {
|
||||
if matches!(app_type, AppType::OpenCode)
|
||||
&& provider.category.as_deref() == Some("omo")
|
||||
{
|
||||
// Do not auto-enable newly added OMO providers.
|
||||
// Users must explicitly switch/apply an OMO provider to activate it.
|
||||
return Ok(true);
|
||||
@@ -201,9 +205,11 @@ impl ProviderService {
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
|
||||
// OpenCode uses additive mode - always update in live config
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
if provider.category.as_deref() == Some("omo") {
|
||||
// Additive mode apps (OpenCode, OpenClaw) - always update in live config
|
||||
if app_type.is_additive_mode() {
|
||||
if matches!(app_type, AppType::OpenCode)
|
||||
&& provider.category.as_deref() == Some("omo")
|
||||
{
|
||||
let is_omo_current = state
|
||||
.db
|
||||
.is_omo_provider_current(app_type.as_str(), &provider.id)?;
|
||||
@@ -253,43 +259,48 @@ impl ProviderService {
|
||||
/// Delete a provider
|
||||
///
|
||||
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
|
||||
/// 对于 OpenCode(累加模式),可以随时删除任意供应商,同时从 live 配置中移除。
|
||||
/// 对于累加模式应用(OpenCode, OpenClaw),可以随时删除任意供应商,同时从 live 配置中移除。
|
||||
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||
// OpenCode uses additive mode - no current provider concept
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
let is_omo = state
|
||||
.db
|
||||
.get_provider_by_id(id, app_type.as_str())?
|
||||
.and_then(|p| p.category)
|
||||
.as_deref()
|
||||
== Some("omo");
|
||||
|
||||
if is_omo {
|
||||
let was_current = state.db.is_omo_provider_current(app_type.as_str(), id)?;
|
||||
let omo_count = state
|
||||
// Additive mode apps - no current provider concept
|
||||
if app_type.is_additive_mode() {
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
let is_omo = state
|
||||
.db
|
||||
.get_all_providers(app_type.as_str())?
|
||||
.values()
|
||||
.filter(|p| p.category.as_deref() == Some("omo"))
|
||||
.count();
|
||||
.get_provider_by_id(id, app_type.as_str())?
|
||||
.and_then(|p| p.category)
|
||||
.as_deref()
|
||||
== Some("omo");
|
||||
|
||||
if omo_count <= 1 && was_current {
|
||||
return Err(AppError::Message(
|
||||
"无法删除当前启用的最后一个 OMO 配置,请先停用".to_string(),
|
||||
));
|
||||
}
|
||||
if is_omo {
|
||||
let was_current = state.db.is_omo_provider_current(app_type.as_str(), id)?;
|
||||
let omo_count = state
|
||||
.db
|
||||
.get_all_providers(app_type.as_str())?
|
||||
.values()
|
||||
.filter(|p| p.category.as_deref() == Some("omo"))
|
||||
.count();
|
||||
|
||||
state.db.delete_provider(app_type.as_str(), id)?;
|
||||
if was_current {
|
||||
crate::services::OmoService::delete_config_file()?;
|
||||
if omo_count <= 1 && was_current {
|
||||
return Err(AppError::Message(
|
||||
"无法删除当前启用的最后一个 OMO 配置,请先停用".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
state.db.delete_provider(app_type.as_str(), id)?;
|
||||
if was_current {
|
||||
crate::services::OmoService::delete_config_file()?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Remove from database
|
||||
state.db.delete_provider(app_type.as_str(), id)?;
|
||||
// Also remove from live config
|
||||
remove_opencode_provider_from_live(id)?;
|
||||
match app_type {
|
||||
AppType::OpenCode => remove_opencode_provider_from_live(id)?,
|
||||
AppType::OpenClaw => remove_openclaw_provider_from_live(id)?,
|
||||
_ => {} // Should not reach here
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -306,7 +317,7 @@ impl ProviderService {
|
||||
state.db.delete_provider(app_type.as_str(), id)
|
||||
}
|
||||
|
||||
/// Remove provider from live config only (for additive mode apps like OpenCode)
|
||||
/// Remove provider from live config only (for additive mode apps like OpenCode, OpenClaw)
|
||||
///
|
||||
/// Does NOT delete from database - provider remains in the list.
|
||||
/// This is used when user wants to "remove" a provider from active config
|
||||
@@ -338,7 +349,9 @@ impl ProviderService {
|
||||
remove_opencode_provider_from_live(id)?;
|
||||
}
|
||||
}
|
||||
// Future: add other additive mode apps here
|
||||
AppType::OpenClaw => {
|
||||
remove_openclaw_provider_from_live(id)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Message(format!(
|
||||
"App {} does not support remove from live config",
|
||||
@@ -454,22 +467,25 @@ impl ProviderService {
|
||||
// Use effective current provider (validated existence) to ensure backfill targets valid provider
|
||||
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
||||
|
||||
match (current_id, matches!(app_type, AppType::OpenCode)) {
|
||||
(Some(current_id), false) if current_id != id => {
|
||||
// Only backfill when switching to a different provider.
|
||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||
current_provider.settings_config = live_config;
|
||||
// Ignore backfill failure, don't affect switch flow.
|
||||
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
||||
if let Some(current_id) = current_id {
|
||||
if current_id != id {
|
||||
// Additive mode apps - all providers coexist in the same file,
|
||||
// no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)
|
||||
if !app_type.is_additive_mode() {
|
||||
// Only backfill when switching to a different provider
|
||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||
current_provider.settings_config = live_config;
|
||||
// Ignore backfill failure, don't affect switch flow
|
||||
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// OpenCode uses additive mode - skip setting is_current (no such concept)
|
||||
if !matches!(app_type, AppType::OpenCode) {
|
||||
// Additive mode apps skip setting is_current (no such concept)
|
||||
if !app_type.is_additive_mode() {
|
||||
// Update local settings (device-level, takes priority)
|
||||
crate::settings::set_current_provider(&app_type, Some(id))?;
|
||||
|
||||
@@ -515,6 +531,7 @@ impl ProviderService {
|
||||
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
|
||||
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
|
||||
AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,6 +545,7 @@ impl ProviderService {
|
||||
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
|
||||
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
|
||||
AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,6 +702,27 @@ impl ProviderService {
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// Extract common config for OpenClaw (JSON format)
|
||||
fn extract_openclaw_common_config(settings: &Value) -> Result<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.
|
||||
@@ -861,6 +900,17 @@ impl ProviderService {
|
||||
));
|
||||
}
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw uses config structure: { baseUrl, apiKey, api, models }
|
||||
// Basic validation - must be an object
|
||||
if !provider.settings_config.is_object() {
|
||||
return Err(AppError::localized(
|
||||
"provider.openclaw.settings.not_object",
|
||||
"OpenClaw 配置必须是 JSON 对象",
|
||||
"OpenClaw configuration must be a JSON object",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and clean UsageScript configuration (common for all app types)
|
||||
@@ -1032,6 +1082,30 @@ impl ProviderService {
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw uses apiKey and baseUrl directly on the object
|
||||
let api_key = provider
|
||||
.settings_config
|
||||
.get("apiKey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.openclaw.api_key.missing",
|
||||
"缺少 API Key",
|
||||
"API key is missing",
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let base_url = provider
|
||||
.settings_config
|
||||
.get("baseUrl")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,11 +210,16 @@ impl ProxyService {
|
||||
.await
|
||||
.map(|c| c.enabled)
|
||||
.unwrap_or(false);
|
||||
// OpenCode and OpenClaw don't support proxy features, always return false
|
||||
let opencode_enabled = false;
|
||||
let openclaw_enabled = false;
|
||||
|
||||
Ok(ProxyTakeoverStatus {
|
||||
claude: claude_enabled,
|
||||
codex: codex_enabled,
|
||||
gemini: gemini_enabled,
|
||||
opencode: opencode_enabled,
|
||||
openclaw: openclaw_enabled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -372,6 +377,10 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy features
|
||||
return Err("OpenCode 不支持代理功能".to_string());
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features
|
||||
return Err("OpenClaw 不支持代理功能".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
self.sync_live_config_to_provider(app_type, &live_config)
|
||||
@@ -588,6 +597,9 @@ impl ProxyService {
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features, skip silently
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features, skip silently
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -770,6 +782,10 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy features
|
||||
return Err("OpenCode 不支持代理功能".to_string());
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features
|
||||
return Err("OpenClaw 不支持代理功能".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let json_str = serde_json::to_string(&config)
|
||||
@@ -982,6 +998,10 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy features
|
||||
return Err("OpenCode 不支持代理功能".to_string());
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features
|
||||
return Err("OpenClaw 不支持代理功能".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1068,6 +1088,9 @@ impl ProxyService {
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features, skip silently
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features, skip silently
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1103,6 +1126,9 @@ impl ProxyService {
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features, skip silently
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features, skip silently
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1186,6 +1212,10 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy features
|
||||
Err("OpenCode 不支持代理功能".to_string())
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features
|
||||
Err("OpenClaw 不支持代理功能".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1207,6 +1237,10 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy takeover
|
||||
false
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy takeover
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1250,6 +1284,10 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy features
|
||||
Ok(())
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +230,11 @@ impl SkillService {
|
||||
return Ok(custom.join("skills"));
|
||||
}
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
if let Some(custom) = crate::settings::get_openclaw_override_dir() {
|
||||
return Ok(custom.join("skills"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 默认路径:回退到用户主目录下的标准位置
|
||||
@@ -244,6 +249,7 @@ impl SkillService {
|
||||
AppType::Codex => home.join(".codex").join("skills"),
|
||||
AppType::Gemini => home.join(".gemini").join("skills"),
|
||||
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
|
||||
AppType::OpenClaw => home.join(".openclaw").join("skills"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -562,6 +568,7 @@ impl SkillService {
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini",
|
||||
AppType::OpenCode => "opencode",
|
||||
AppType::OpenClaw => "openclaw",
|
||||
};
|
||||
|
||||
unmanaged
|
||||
@@ -611,6 +618,7 @@ impl SkillService {
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini",
|
||||
AppType::OpenCode => "opencode",
|
||||
AppType::OpenClaw => "openclaw",
|
||||
};
|
||||
found_in.push(app_str.to_string());
|
||||
}
|
||||
|
||||
@@ -240,6 +240,14 @@ impl StreamCheckService {
|
||||
"OpenCode does not support health check yet",
|
||||
));
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support stream check yet
|
||||
return Err(AppError::localized(
|
||||
"openclaw_no_stream_check",
|
||||
"OpenClaw 暂不支持健康检查",
|
||||
"OpenClaw does not support health check yet",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let response_time = start.elapsed().as_millis() as u64;
|
||||
@@ -567,6 +575,11 @@ impl StreamCheckService {
|
||||
// Try to extract first model from the models object
|
||||
Self::extract_opencode_model(provider).unwrap_or_else(|| "gpt-4o".to_string())
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw uses models array in settings_config
|
||||
// Try to extract first model from the models array
|
||||
Self::extract_openclaw_model(provider).unwrap_or_else(|| "gpt-4o".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,6 +593,21 @@ impl StreamCheckService {
|
||||
models.keys().next().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn extract_openclaw_model(provider: &Provider) -> Option<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
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
//! 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;
|
||||
use futures::StreamExt;
|
||||
|
||||
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,
|
||||
max_bytes: usize,
|
||||
) -> 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));
|
||||
}
|
||||
ensure_content_length_within_limit(resp.headers(), max_bytes, url)?;
|
||||
|
||||
let etag = resp
|
||||
.headers()
|
||||
.get("etag")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
let mut bytes = Vec::new();
|
||||
let mut stream = resp.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| {
|
||||
AppError::localized(
|
||||
"webdav.response_read_failed",
|
||||
format!("读取 WebDAV 响应失败: {e}"),
|
||||
format!("Failed to read WebDAV response: {e}"),
|
||||
)
|
||||
})?;
|
||||
if bytes.len().saturating_add(chunk.len()) > max_bytes {
|
||||
return Err(response_too_large_error(url, max_bytes));
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(Some((bytes, 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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn response_too_large_error(url: &str, max_bytes: usize) -> AppError {
|
||||
let max_mb = max_bytes / 1024 / 1024;
|
||||
AppError::localized(
|
||||
"webdav.response_too_large",
|
||||
format!(
|
||||
"WebDAV 响应体超过上限({} MB): {}",
|
||||
max_mb,
|
||||
redact_url(url)
|
||||
),
|
||||
format!(
|
||||
"WebDAV response body exceeds limit ({} MB): {}",
|
||||
max_mb,
|
||||
redact_url(url)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_content_length_within_limit(
|
||||
headers: &reqwest::header::HeaderMap,
|
||||
max_bytes: usize,
|
||||
url: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let Some(content_length) = headers.get(reqwest::header::CONTENT_LENGTH) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Ok(raw) = content_length.to_str() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Ok(value) = raw.parse::<u64>() else {
|
||||
return Ok(());
|
||||
};
|
||||
if value > max_bytes as u64 {
|
||||
return Err(response_too_large_error(url, max_bytes));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_LENGTH};
|
||||
|
||||
#[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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_content_length_within_limit_accepts_missing_or_small_values() {
|
||||
let empty = HeaderMap::new();
|
||||
assert!(
|
||||
ensure_content_length_within_limit(&empty, 1024, "https://dav.example.com").is_ok()
|
||||
);
|
||||
|
||||
let mut small = HeaderMap::new();
|
||||
small.insert(CONTENT_LENGTH, HeaderValue::from_static("1024"));
|
||||
assert!(
|
||||
ensure_content_length_within_limit(&small, 1024, "https://dav.example.com").is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_content_length_within_limit_rejects_oversized_values() {
|
||||
let mut large = HeaderMap::new();
|
||||
large.insert(CONTENT_LENGTH, HeaderValue::from_static("2048"));
|
||||
|
||||
let err = ensure_content_length_within_limit(&large, 1024, "https://dav.example.com")
|
||||
.expect_err("oversized response should be rejected");
|
||||
assert!(
|
||||
err.to_string().contains("too large") || err.to_string().contains("超过"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::json;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::mpsc::error::TrySendError;
|
||||
use tokio::sync::mpsc::{channel, Receiver, Sender};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::services::webdav_sync as webdav_sync_service;
|
||||
use crate::settings::{self, WebDavSyncSettings};
|
||||
|
||||
const AUTO_SYNC_DEBOUNCE_MS: u64 = 1000;
|
||||
pub(crate) const MAX_AUTO_SYNC_WAIT_MS: u64 = 10_000;
|
||||
|
||||
static DB_CHANGE_TX: OnceLock<Sender<String>> = OnceLock::new();
|
||||
static AUTO_SYNC_SUPPRESS_DEPTH: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
pub(crate) struct AutoSyncSuppressionGuard;
|
||||
|
||||
impl AutoSyncSuppressionGuard {
|
||||
pub fn new() -> Self {
|
||||
AUTO_SYNC_SUPPRESS_DEPTH.fetch_add(1, Ordering::SeqCst);
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AutoSyncSuppressionGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ =
|
||||
AUTO_SYNC_SUPPRESS_DEPTH.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |value| {
|
||||
Some(value.saturating_sub(1))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_auto_sync_suppressed() -> bool {
|
||||
AUTO_SYNC_SUPPRESS_DEPTH.load(Ordering::SeqCst) > 0
|
||||
}
|
||||
|
||||
pub fn should_trigger_for_table(table: &str) -> bool {
|
||||
let normalized = table.trim().to_ascii_lowercase();
|
||||
matches!(
|
||||
normalized.as_str(),
|
||||
"providers"
|
||||
| "provider_endpoints"
|
||||
| "mcp_servers"
|
||||
| "prompts"
|
||||
| "skills"
|
||||
| "skill_repos"
|
||||
| "settings"
|
||||
| "proxy_config"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn enqueue_change_signal(tx: &Sender<String>, table: &str) -> bool {
|
||||
match tx.try_send(table.to_string()) {
|
||||
Ok(()) => true,
|
||||
Err(TrySendError::Full(_)) | Err(TrySendError::Closed(_)) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn auto_sync_wait_duration(started_at: Instant, now: Instant) -> Option<Duration> {
|
||||
let max_wait = Duration::from_millis(MAX_AUTO_SYNC_WAIT_MS);
|
||||
let debounce = Duration::from_millis(AUTO_SYNC_DEBOUNCE_MS);
|
||||
let elapsed = now.saturating_duration_since(started_at);
|
||||
if elapsed >= max_wait {
|
||||
return None;
|
||||
}
|
||||
Some(debounce.min(max_wait - elapsed))
|
||||
}
|
||||
|
||||
fn should_run_auto_sync(settings: Option<&WebDavSyncSettings>) -> bool {
|
||||
let Some(sync) = settings else {
|
||||
return false;
|
||||
};
|
||||
sync.enabled && sync.auto_sync
|
||||
}
|
||||
|
||||
fn persist_auto_sync_error(settings: &mut WebDavSyncSettings, error: &AppError) {
|
||||
settings.status.last_error = Some(error.to_string());
|
||||
settings.status.last_error_source = Some("auto".to_string());
|
||||
let _ = settings::update_webdav_sync_status(settings.status.clone());
|
||||
}
|
||||
|
||||
fn emit_auto_sync_status_updated(app: &AppHandle, status: &str, error: Option<&str>) {
|
||||
let payload = match error {
|
||||
Some(message) => json!({
|
||||
"source": "auto",
|
||||
"status": status,
|
||||
"error": message,
|
||||
}),
|
||||
None => json!({
|
||||
"source": "auto",
|
||||
"status": status,
|
||||
}),
|
||||
};
|
||||
|
||||
if let Err(err) = app.emit("webdav-sync-status-updated", payload) {
|
||||
log::debug!("[WebDAV] failed to emit sync status update event: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_auto_sync_upload(
|
||||
db: &crate::database::Database,
|
||||
app: &AppHandle,
|
||||
) -> Result<(), AppError> {
|
||||
let mut settings = settings::get_webdav_sync_settings();
|
||||
if !should_run_auto_sync(settings.as_ref()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut sync_settings = match settings.take() {
|
||||
Some(value) => value,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let result = webdav_sync_service::run_with_sync_lock(webdav_sync_service::upload(
|
||||
db,
|
||||
&mut sync_settings,
|
||||
))
|
||||
.await;
|
||||
match result {
|
||||
Ok(_) => {
|
||||
emit_auto_sync_status_updated(app, "success", None);
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
persist_auto_sync_error(&mut sync_settings, &err);
|
||||
emit_auto_sync_status_updated(app, "error", Some(&err.to_string()));
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn notify_db_changed(table: &str) {
|
||||
if is_auto_sync_suppressed() {
|
||||
return;
|
||||
}
|
||||
if !should_trigger_for_table(table) {
|
||||
return;
|
||||
}
|
||||
let Some(tx) = DB_CHANGE_TX.get() else {
|
||||
return;
|
||||
};
|
||||
let _ = enqueue_change_signal(tx, table);
|
||||
}
|
||||
|
||||
pub fn start_worker(db: Arc<crate::database::Database>, app: tauri::AppHandle) {
|
||||
if DB_CHANGE_TX.get().is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Buffer size 1 is enough: we only need "dirty" signals, not every event.
|
||||
let (tx, rx) = channel::<String>(1);
|
||||
if DB_CHANGE_TX.set(tx).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run_worker_loop(db, rx, app).await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_worker_loop(
|
||||
db: Arc<crate::database::Database>,
|
||||
mut rx: Receiver<String>,
|
||||
app: tauri::AppHandle,
|
||||
) {
|
||||
while let Some(first_table) = rx.recv().await {
|
||||
let started_at = Instant::now();
|
||||
let mut merged_count = 1usize;
|
||||
|
||||
loop {
|
||||
let Some(wait_for) = auto_sync_wait_duration(started_at, Instant::now()) else {
|
||||
break;
|
||||
};
|
||||
let timeout = tokio::time::timeout(wait_for, rx.recv()).await;
|
||||
|
||||
match timeout {
|
||||
Ok(Some(_)) => merged_count += 1,
|
||||
Ok(None) => return,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"[WebDAV][AutoSync] Triggered by table={first_table}, merged_changes={merged_count}"
|
||||
);
|
||||
|
||||
if let Err(err) = run_auto_sync_upload(&db, &app).await {
|
||||
log::warn!("[WebDAV][AutoSync] Upload failed: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
auto_sync_wait_duration, enqueue_change_signal, is_auto_sync_suppressed,
|
||||
should_run_auto_sync, should_trigger_for_table, AutoSyncSuppressionGuard,
|
||||
MAX_AUTO_SYNC_WAIT_MS,
|
||||
};
|
||||
use crate::settings::WebDavSyncSettings;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::mpsc::channel;
|
||||
|
||||
#[test]
|
||||
fn should_trigger_sync_for_config_tables_only() {
|
||||
assert!(should_trigger_for_table("providers"));
|
||||
assert!(should_trigger_for_table("settings"));
|
||||
assert!(!should_trigger_for_table("proxy_request_logs"));
|
||||
assert!(!should_trigger_for_table("provider_health"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suppression_guard_enables_and_restores_state() {
|
||||
assert!(!is_auto_sync_suppressed());
|
||||
{
|
||||
let _guard = AutoSyncSuppressionGuard::new();
|
||||
assert!(is_auto_sync_suppressed());
|
||||
}
|
||||
assert!(!is_auto_sync_suppressed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_wait_caps_flush_latency_for_continuous_events() {
|
||||
let started = Instant::now();
|
||||
let later = started + Duration::from_millis(MAX_AUTO_SYNC_WAIT_MS + 1);
|
||||
assert!(auto_sync_wait_duration(started, later).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enqueue_change_signal_drops_when_channel_is_full() {
|
||||
let (tx, _rx) = channel::<String>(1);
|
||||
assert!(enqueue_change_signal(&tx, "providers"));
|
||||
assert!(!enqueue_change_signal(&tx, "providers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_run_auto_sync_requires_enabled_and_auto_sync_flag() {
|
||||
assert!(!should_run_auto_sync(None));
|
||||
|
||||
let disabled = WebDavSyncSettings {
|
||||
enabled: false,
|
||||
auto_sync: true,
|
||||
..WebDavSyncSettings::default()
|
||||
};
|
||||
assert!(!should_run_auto_sync(Some(&disabled)));
|
||||
|
||||
let auto_sync_off = WebDavSyncSettings {
|
||||
enabled: true,
|
||||
auto_sync: false,
|
||||
..WebDavSyncSettings::default()
|
||||
};
|
||||
assert!(!should_run_auto_sync(Some(&auto_sync_off)));
|
||||
|
||||
let enabled = WebDavSyncSettings {
|
||||
enabled: true,
|
||||
auto_sync: true,
|
||||
..WebDavSyncSettings::default()
|
||||
};
|
||||
assert!(should_run_auto_sync(Some(&enabled)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_layer_does_not_depend_on_commands_layer() {
|
||||
let source = include_str!("webdav_auto_sync.rs");
|
||||
let needle = ["crate", "commands", ""].join("::");
|
||||
assert!(
|
||||
!source.contains(&needle),
|
||||
"services layer should not depend on commands layer"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
//! 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::future::Future;
|
||||
use std::process::Command;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
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;
|
||||
const MAX_MANIFEST_BYTES: usize = 1024 * 1024;
|
||||
pub(super) const MAX_SYNC_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024;
|
||||
|
||||
pub fn sync_mutex() -> &'static tokio::sync::Mutex<()> {
|
||||
static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
|
||||
}
|
||||
|
||||
pub async fn run_with_sync_lock<T, Fut>(operation: Fut) -> Result<T, AppError>
|
||||
where
|
||||
Fut: Future<Output = Result<T, AppError>>,
|
||||
{
|
||||
let _guard = sync_mutex().lock().await;
|
||||
operation.await
|
||||
}
|
||||
|
||||
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, MAX_MANIFEST_BYTES)
|
||||
.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, MAX_MANIFEST_BYTES).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_error_source: 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}"),
|
||||
)
|
||||
})?;
|
||||
validate_artifact_size_limit(artifact_name, meta.size)?;
|
||||
|
||||
let url = remote_file_url(settings, artifact_name)?;
|
||||
let (bytes, _) = get_bytes(&url, auth, MAX_SYNC_ARTIFACT_BYTES as usize)
|
||||
.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)
|
||||
}
|
||||
|
||||
fn validate_artifact_size_limit(artifact_name: &str, size: u64) -> Result<(), AppError> {
|
||||
if size > MAX_SYNC_ARTIFACT_BYTES {
|
||||
let max_mb = MAX_SYNC_ARTIFACT_BYTES / 1024 / 1024;
|
||||
return Err(localized(
|
||||
"webdav.sync.artifact_too_large",
|
||||
format!("artifact {artifact_name} 超过下载上限({} MB)", max_mb),
|
||||
format!(
|
||||
"Artifact {artifact_name} exceeds download limit ({} MB)",
|
||||
max_mb
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── 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"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_artifact_size_limit_rejects_oversized_artifacts() {
|
||||
let err = validate_artifact_size_limit("skills.zip", MAX_SYNC_ARTIFACT_BYTES + 1)
|
||||
.expect_err("artifact larger than limit should be rejected");
|
||||
assert!(
|
||||
err.to_string().contains("too large") || err.to_string().contains("超过"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_artifact_size_limit_accepts_limit_boundary() {
|
||||
assert!(validate_artifact_size_limit("skills.zip", MAX_SYNC_ARTIFACT_BYTES).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
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, MAX_SYNC_ARTIFACT_BYTES, REMOTE_SKILLS_ZIP};
|
||||
|
||||
/// 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 = copy_entry_with_total_limit(
|
||||
&mut entry,
|
||||
&mut out,
|
||||
&mut total_bytes,
|
||||
MAX_SYNC_ARTIFACT_BYTES,
|
||||
&out_path,
|
||||
)?;
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
fn copy_entry_with_total_limit<R: Read, W: Write>(
|
||||
reader: &mut R,
|
||||
writer: &mut W,
|
||||
total_bytes: &mut u64,
|
||||
max_total_bytes: u64,
|
||||
out_path: &Path,
|
||||
) -> Result<u64, AppError> {
|
||||
let mut buffer = [0u8; 16 * 1024];
|
||||
let mut written = 0u64;
|
||||
loop {
|
||||
let n = reader
|
||||
.read(&mut buffer)
|
||||
.map_err(|e| AppError::io(out_path, e))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
if total_bytes.saturating_add(n as u64) > max_total_bytes {
|
||||
let max_mb = max_total_bytes / 1024 / 1024;
|
||||
return Err(localized(
|
||||
"webdav.sync.skills_zip_too_large",
|
||||
format!("skills.zip 解压后体积超过上限({} MB)", max_mb),
|
||||
format!("skills.zip extracted size exceeds limit ({} MB)", max_mb),
|
||||
));
|
||||
}
|
||||
|
||||
writer
|
||||
.write_all(&buffer[..n])
|
||||
.map_err(|e| AppError::io(out_path, e))?;
|
||||
*total_bytes += n as u64;
|
||||
written += n as u64;
|
||||
}
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{copy_entry_with_total_limit, mark_visited_dir};
|
||||
use std::collections::HashSet;
|
||||
use std::io::Cursor;
|
||||
use std::path::Path;
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_entry_with_total_limit_rejects_oversized_stream_before_write() {
|
||||
let mut reader = Cursor::new(vec![1u8; 16]);
|
||||
let mut writer = Vec::new();
|
||||
let mut total_bytes = 0u64;
|
||||
|
||||
let err = copy_entry_with_total_limit(
|
||||
&mut reader,
|
||||
&mut writer,
|
||||
&mut total_bytes,
|
||||
8,
|
||||
Path::new("skills-extracted/file.bin"),
|
||||
)
|
||||
.expect_err("stream larger than limit should be rejected");
|
||||
assert!(
|
||||
err.to_string().contains("too large") || err.to_string().contains("超过"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
assert_eq!(
|
||||
writer.len(),
|
||||
0,
|
||||
"should not write when the first chunk exceeds limit"
|
||||
);
|
||||
}
|
||||
}
|
||||
+215
-1
@@ -1,5 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
@@ -33,6 +34,8 @@ pub struct VisibleApps {
|
||||
pub gemini: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub opencode: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub openclaw: bool,
|
||||
}
|
||||
|
||||
impl Default for VisibleApps {
|
||||
@@ -42,6 +45,7 @@ impl Default for VisibleApps {
|
||||
codex: true,
|
||||
gemini: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,10 +58,111 @@ 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_error_source: 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 auto_sync: 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,
|
||||
auto_sync: false,
|
||||
base_url: String::new(),
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
remote_root: default_remote_root(),
|
||||
profile: default_profile(),
|
||||
status: WebDavSyncStatus::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WebDavSyncSettings {
|
||||
pub fn validate(&self) -> Result<(), crate::error::AppError> {
|
||||
if self.base_url.trim().is_empty() {
|
||||
return Err(crate::error::AppError::localized(
|
||||
"webdav.base_url.required",
|
||||
"WebDAV 地址不能为空",
|
||||
"WebDAV URL is required.",
|
||||
));
|
||||
}
|
||||
if self.username.trim().is_empty() {
|
||||
return Err(crate::error::AppError::localized(
|
||||
"webdav.username.required",
|
||||
"WebDAV 用户名不能为空",
|
||||
"WebDAV username is required.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn normalize(&mut self) {
|
||||
self.base_url = self.base_url.trim().to_string();
|
||||
self.username = self.username.trim().to_string();
|
||||
self.remote_root = self.remote_root.trim().to_string();
|
||||
self.profile = self.profile.trim().to_string();
|
||||
if self.remote_root.is_empty() {
|
||||
self.remote_root = default_remote_root();
|
||||
}
|
||||
if self.profile.is_empty() {
|
||||
self.profile = default_profile();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if all credential fields are blank (no config to persist).
|
||||
fn is_empty(&self) -> bool {
|
||||
self.base_url.is_empty() && self.username.is_empty() && self.password.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用设置结构
|
||||
///
|
||||
/// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。
|
||||
@@ -98,6 +203,8 @@ 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)
|
||||
@@ -112,12 +219,23 @@ 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"
|
||||
@@ -150,11 +268,15 @@ impl Default for AppSettings {
|
||||
codex_config_dir: None,
|
||||
gemini_config_dir: None,
|
||||
opencode_config_dir: None,
|
||||
openclaw_config_dir: None,
|
||||
current_provider_claude: None,
|
||||
current_provider_codex: None,
|
||||
current_provider_gemini: None,
|
||||
current_provider_opencode: None,
|
||||
current_provider_openclaw: None,
|
||||
skill_sync_method: SyncMethod::default(),
|
||||
webdav_sync: None,
|
||||
webdav_backup: None,
|
||||
preferred_terminal: None,
|
||||
}
|
||||
}
|
||||
@@ -199,12 +321,26 @@ impl AppSettings {
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
self.openclaw_config_dir = self
|
||||
.openclaw_config_dir
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
self.language = self
|
||||
.language
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| matches!(*s, "en" | "zh" | "ja"))
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if let Some(sync) = &mut self.webdav_sync {
|
||||
sync.normalize();
|
||||
if sync.is_empty() {
|
||||
self.webdav_sync = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_from_file() -> Self {
|
||||
@@ -245,7 +381,27 @@ fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
|
||||
|
||||
let json = serde_json::to_string_pretty(&normalized)
|
||||
.map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::fs::OpenOptions;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&path)
|
||||
.map_err(|e| AppError::io(&path, e))?;
|
||||
file.write_all(json.as_bytes())
|
||||
.map_err(|e| AppError::io(&path, e))?;
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -283,6 +439,15 @@ pub fn get_settings() -> AppSettings {
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn get_settings_for_frontend() -> AppSettings {
|
||||
let mut settings = get_settings();
|
||||
if let Some(sync) = &mut settings.webdav_sync {
|
||||
sync.password.clear();
|
||||
}
|
||||
settings.webdav_backup = None;
|
||||
settings
|
||||
}
|
||||
|
||||
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
|
||||
new_settings.normalize_paths();
|
||||
save_settings_file(&new_settings)?;
|
||||
@@ -295,6 +460,22 @@ 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> {
|
||||
@@ -339,6 +520,14 @@ 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 读取)
|
||||
@@ -352,6 +541,7 @@ 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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,6 +557,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(),
|
||||
AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()),
|
||||
AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()),
|
||||
AppType::OpenCode => settings.current_provider_opencode = id.map(|s| s.to_string()),
|
||||
AppType::OpenClaw => settings.current_provider_openclaw = id.map(|s| s.to_string()),
|
||||
}
|
||||
|
||||
update_settings(settings)
|
||||
@@ -433,3 +624,26 @@ 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;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+210
-58
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { toast } from "sonner";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Plus,
|
||||
@@ -16,6 +17,10 @@ import {
|
||||
Download,
|
||||
FolderArchive,
|
||||
Search,
|
||||
FolderOpen,
|
||||
KeyRound,
|
||||
Shield,
|
||||
Cpu,
|
||||
} from "lucide-react";
|
||||
import type { Provider, VisibleApps } from "@/types";
|
||||
import type { EnvConflict } from "@/types/env";
|
||||
@@ -28,6 +33,7 @@ import {
|
||||
} from "@/lib/api";
|
||||
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
||||
import { useProviderActions } from "@/hooks/useProviderActions";
|
||||
import { openclawKeys } from "@/hooks/useOpenClaw";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
@@ -56,6 +62,10 @@ import { McpIcon } from "@/components/BrandIcons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
|
||||
import { useDisableCurrentOmo } from "@/lib/query/omo";
|
||||
import WorkspaceFilesPanel from "@/components/workspace/WorkspaceFilesPanel";
|
||||
import EnvPanel from "@/components/openclaw/EnvPanel";
|
||||
import ToolsPanel from "@/components/openclaw/ToolsPanel";
|
||||
import AgentsDefaultsPanel from "@/components/openclaw/AgentsDefaultsPanel";
|
||||
|
||||
type View =
|
||||
| "providers"
|
||||
@@ -66,14 +76,30 @@ type View =
|
||||
| "mcp"
|
||||
| "agents"
|
||||
| "universal"
|
||||
| "sessions";
|
||||
| "sessions"
|
||||
| "workspace"
|
||||
| "openclawEnv"
|
||||
| "openclawTools"
|
||||
| "openclawAgents";
|
||||
|
||||
interface WebDavSyncStatusUpdatedPayload {
|
||||
source?: string;
|
||||
status?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
|
||||
const HEADER_HEIGHT = 64; // px
|
||||
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
|
||||
|
||||
const STORAGE_KEY = "cc-switch-last-app";
|
||||
const VALID_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"];
|
||||
const VALID_APPS: AppId[] = [
|
||||
"claude",
|
||||
"codex",
|
||||
"gemini",
|
||||
"opencode",
|
||||
"openclaw",
|
||||
];
|
||||
|
||||
const getInitialApp = (): AppId => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY) as AppId | null;
|
||||
@@ -94,6 +120,10 @@ const VALID_VIEWS: View[] = [
|
||||
"agents",
|
||||
"universal",
|
||||
"sessions",
|
||||
"workspace",
|
||||
"openclawEnv",
|
||||
"openclawTools",
|
||||
"openclawAgents",
|
||||
];
|
||||
|
||||
const getInitialView = (): View => {
|
||||
@@ -123,6 +153,7 @@ function App() {
|
||||
codex: true,
|
||||
gemini: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
};
|
||||
|
||||
const getFirstVisibleApp = (): AppId => {
|
||||
@@ -130,6 +161,7 @@ function App() {
|
||||
if (visibleApps.codex) return "codex";
|
||||
if (visibleApps.gemini) return "gemini";
|
||||
if (visibleApps.opencode) return "opencode";
|
||||
if (visibleApps.openclaw) return "openclaw";
|
||||
return "claude"; // fallback
|
||||
};
|
||||
|
||||
@@ -141,7 +173,11 @@ function App() {
|
||||
|
||||
// Fallback from sessions view when switching to an app without session support
|
||||
useEffect(() => {
|
||||
if (currentView === "sessions" && activeApp !== "claude" && activeApp !== "codex") {
|
||||
if (
|
||||
currentView === "sessions" &&
|
||||
activeApp !== "claude" &&
|
||||
activeApp !== "codex"
|
||||
) {
|
||||
setCurrentView("providers");
|
||||
}
|
||||
}, [activeApp, currentView]);
|
||||
@@ -192,6 +228,7 @@ function App() {
|
||||
switchProvider,
|
||||
deleteProvider,
|
||||
saveUsageScript,
|
||||
setAsDefaultModel,
|
||||
} = useProviderActions(activeApp);
|
||||
|
||||
const disableOmoMutation = useDisableCurrentOmo();
|
||||
@@ -262,6 +299,49 @@ function App() {
|
||||
};
|
||||
}, [queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let active = true;
|
||||
|
||||
const setupListener = async () => {
|
||||
try {
|
||||
const off = await listen(
|
||||
"webdav-sync-status-updated",
|
||||
async (event) => {
|
||||
const payload = (event.payload ?? {}) as WebDavSyncStatusUpdatedPayload;
|
||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
|
||||
if (payload.source !== "auto" || payload.status !== "error") {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(
|
||||
t("settings.webdavSync.autoSyncFailedToast", {
|
||||
error: payload.error || t("common.unknown"),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
if (!active) {
|
||||
off();
|
||||
return;
|
||||
}
|
||||
unsubscribe = off;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[App] Failed to subscribe webdav-sync-status-updated event",
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
void setupListener();
|
||||
return () => {
|
||||
active = false;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [queryClient, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkEnvOnStartup = async () => {
|
||||
try {
|
||||
@@ -419,10 +499,19 @@ function App() {
|
||||
const { provider, action } = confirmAction;
|
||||
|
||||
if (action === "remove") {
|
||||
// Remove from live config only (for additive mode apps like OpenCode/OpenClaw)
|
||||
// Does NOT delete from database - provider remains in the list
|
||||
await providersApi.removeFromLiveConfig(provider.id, activeApp);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["opencodeLiveProviderIds"],
|
||||
});
|
||||
// Invalidate queries to refresh the isInConfig state
|
||||
if (activeApp === "opencode") {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["opencodeLiveProviderIds"],
|
||||
});
|
||||
} else if (activeApp === "openclaw") {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: openclawKeys.liveProviderIds,
|
||||
});
|
||||
}
|
||||
toast.success(
|
||||
t("notifications.removeFromConfigSuccess", {
|
||||
defaultValue: "已从配置移除",
|
||||
@@ -582,7 +671,11 @@ function App() {
|
||||
return (
|
||||
<SkillsPage
|
||||
ref={skillsPageRef}
|
||||
initialApp={activeApp === "opencode" ? "claude" : activeApp}
|
||||
initialApp={
|
||||
activeApp === "opencode" || activeApp === "openclaw"
|
||||
? "claude"
|
||||
: activeApp
|
||||
}
|
||||
/>
|
||||
);
|
||||
case "mcp":
|
||||
@@ -605,6 +698,14 @@ 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">
|
||||
@@ -636,7 +737,7 @@ function App() {
|
||||
setConfirmAction({ provider, action: "delete" })
|
||||
}
|
||||
onRemoveFromConfig={
|
||||
activeApp === "opencode"
|
||||
activeApp === "opencode" || activeApp === "openclaw"
|
||||
? (provider) =>
|
||||
setConfirmAction({ provider, action: "remove" })
|
||||
: undefined
|
||||
@@ -651,6 +752,9 @@ function App() {
|
||||
activeApp === "claude" ? handleOpenTerminal : undefined
|
||||
}
|
||||
onCreate={() => setIsAddOpen(true)}
|
||||
onSetAsDefault={
|
||||
activeApp === "openclaw" ? setAsDefaultModel : undefined
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
@@ -760,6 +864,11 @@ function App() {
|
||||
defaultValue: "统一供应商",
|
||||
})}
|
||||
{currentView === "sessions" && t("sessionManager.title")}
|
||||
{currentView === "workspace" && t("workspace.title")}
|
||||
{currentView === "openclawEnv" && t("openclaw.env.title")}
|
||||
{currentView === "openclawTools" && t("openclaw.tools.title")}
|
||||
{currentView === "openclawAgents" &&
|
||||
t("openclaw.agents.title")}
|
||||
</h1>
|
||||
</div>
|
||||
) : (
|
||||
@@ -911,7 +1020,7 @@ function App() {
|
||||
)}
|
||||
{currentView === "providers" && (
|
||||
<>
|
||||
{activeApp !== "opencode" && (
|
||||
{activeApp !== "opencode" && activeApp !== "openclaw" && (
|
||||
<>
|
||||
<ProxyToggle activeApp={activeApp} />
|
||||
<div
|
||||
@@ -938,54 +1047,97 @@ function App() {
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
|
||||
<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>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -1001,7 +1153,7 @@ function App() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 min-h-0 flex flex-col animate-fade-in">
|
||||
<main className="flex-1 min-h-0 flex flex-col overflow-y-auto animate-fade-in">
|
||||
{renderContent()}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ interface AppSwitcherProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const ALL_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"];
|
||||
const ALL_APPS: AppId[] = ["claude", "codex", "gemini", "opencode", "openclaw"];
|
||||
const STORAGE_KEY = "cc-switch-last-app";
|
||||
|
||||
export function AppSwitcher({
|
||||
@@ -29,12 +29,14 @@ 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)
|
||||
|
||||
@@ -7,6 +7,7 @@ interface IconProps {
|
||||
import ClaudeSvg from "@/icons/extracted/claude.svg?url";
|
||||
import OpenAISvg from "@/icons/extracted/openai.svg?url";
|
||||
import GeminiSvg from "@/icons/extracted/gemini.svg?url";
|
||||
import OpenClawSvg from "@/icons/extracted/claw.svg?url";
|
||||
|
||||
export function ClaudeIcon({ size = 16, className = "" }: IconProps) {
|
||||
return (
|
||||
@@ -47,6 +48,19 @@ export function GeminiIcon({ size = 16, className = "" }: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenClawIcon({ size = 16, className = "" }: IconProps) {
|
||||
return (
|
||||
<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 (
|
||||
|
||||
@@ -66,6 +66,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
opencode: boolean;
|
||||
openclaw: boolean;
|
||||
}>(() => {
|
||||
if (initialData?.apps) {
|
||||
return { ...initialData.apps };
|
||||
@@ -75,6 +76,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
codex: defaultEnabledApps.includes("codex"),
|
||||
gemini: defaultEnabledApps.includes("gemini"),
|
||||
opencode: defaultEnabledApps.includes("opencode"),
|
||||
openclaw: defaultEnabledApps.includes("openclaw"),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -561,6 +563,22 @@ 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>
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
}, [serversMap]);
|
||||
|
||||
const enabledCounts = useMemo(() => {
|
||||
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 };
|
||||
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };
|
||||
serverEntries.forEach(([_, server]) => {
|
||||
for (const app of APP_IDS) {
|
||||
if (server.apps[app]) counts[app]++;
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useOpenClawAgentsDefaults,
|
||||
useSaveOpenClawAgentsDefaults,
|
||||
} from "@/hooks/useOpenClaw";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { OpenClawAgentsDefaults } from "@/types";
|
||||
|
||||
const AgentsDefaultsPanel: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: agentsData, isLoading } = useOpenClawAgentsDefaults();
|
||||
const saveAgentsMutation = useSaveOpenClawAgentsDefaults();
|
||||
const [defaults, setDefaults] = useState<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;
|
||||
@@ -0,0 +1,182 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2, Save, Eye, EyeOff } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useOpenClawEnv, useSaveOpenClawEnv } from "@/hooks/useOpenClaw";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { OpenClawEnvConfig } from "@/types";
|
||||
|
||||
interface EnvEntry {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
const EnvPanel: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: envData, isLoading } = useOpenClawEnv();
|
||||
const saveEnvMutation = useSaveOpenClawEnv();
|
||||
const [entries, setEntries] = useState<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;
|
||||
@@ -0,0 +1,221 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useOpenClawTools, useSaveOpenClawTools } from "@/hooks/useOpenClaw";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { OpenClawToolsConfig } from "@/types";
|
||||
|
||||
interface ListItem {
|
||||
id: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const PROFILE_OPTIONS = ["default", "strict", "permissive", "custom"];
|
||||
|
||||
const ToolsPanel: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: toolsData, isLoading } = useOpenClawTools();
|
||||
const saveToolsMutation = useSaveOpenClawTools();
|
||||
const [config, setConfig] = useState<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;
|
||||
@@ -30,13 +30,13 @@ const PromptFormModal: React.FC<PromptFormModalProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const appName = t(`apps.${appId}`);
|
||||
const filenameMap: Record<AppId, string> = {
|
||||
const filenameMap: Record<Exclude<AppId, "openclaw">, string> = {
|
||||
claude: "CLAUDE.md",
|
||||
codex: "AGENTS.md",
|
||||
gemini: "GEMINI.md",
|
||||
opencode: "AGENTS.md",
|
||||
};
|
||||
const filename = filenameMap[appId];
|
||||
const filename = filenameMap[appId as Exclude<AppId, "openclaw">];
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
@@ -29,6 +29,7 @@ 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("");
|
||||
|
||||
@@ -17,6 +17,7 @@ import { UniversalProviderPanel } from "@/components/universal";
|
||||
import { providerPresets } from "@/config/claudeProviderPresets";
|
||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
||||
import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets";
|
||||
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
||||
|
||||
interface AddProviderDialogProps {
|
||||
@@ -24,7 +25,10 @@ interface AddProviderDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
appId: AppId;
|
||||
onSubmit: (
|
||||
provider: Omit<Provider, "id"> & { providerKey?: string },
|
||||
provider: Omit<Provider, "id"> & {
|
||||
providerKey?: string;
|
||||
suggestedDefaults?: OpenClawSuggestedDefaults;
|
||||
},
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
|
||||
@@ -35,7 +39,8 @@ export function AddProviderDialog({
|
||||
onSubmit,
|
||||
}: AddProviderDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const showUniversalTab = appId !== "opencode";
|
||||
// OpenCode and OpenClaw don't support universal providers
|
||||
const showUniversalTab = appId !== "opencode" && appId !== "openclaw";
|
||||
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
|
||||
"app-specific",
|
||||
);
|
||||
@@ -82,7 +87,11 @@ export function AddProviderDialog({
|
||||
unknown
|
||||
>;
|
||||
|
||||
const providerData: Omit<Provider, "id"> & { providerKey?: string } = {
|
||||
// 构造基础提交数据
|
||||
const providerData: Omit<Provider, "id"> & {
|
||||
providerKey?: string;
|
||||
suggestedDefaults?: OpenClawSuggestedDefaults;
|
||||
} = {
|
||||
name: values.name.trim(),
|
||||
notes: values.notes?.trim() || undefined,
|
||||
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||
@@ -93,7 +102,11 @@ export function AddProviderDialog({
|
||||
...(values.meta ? { meta: values.meta } : {}),
|
||||
};
|
||||
|
||||
if (appId === "opencode" && values.providerKey) {
|
||||
// OpenCode/OpenClaw: pass providerKey for ID generation
|
||||
if (
|
||||
(appId === "opencode" || appId === "openclaw") &&
|
||||
values.providerKey
|
||||
) {
|
||||
providerData.providerKey = values.providerKey;
|
||||
}
|
||||
|
||||
@@ -185,6 +198,11 @@ export function AddProviderDialog({
|
||||
if (options?.baseURL) {
|
||||
addUrl(options.baseURL);
|
||||
}
|
||||
} else if (appId === "openclaw") {
|
||||
// OpenClaw uses baseUrl directly
|
||||
if (parsedConfig.baseUrl) {
|
||||
addUrl(parsedConfig.baseUrl as string);
|
||||
}
|
||||
}
|
||||
|
||||
const urls = Array.from(urlSet);
|
||||
@@ -206,6 +224,11 @@ export function AddProviderDialog({
|
||||
}
|
||||
}
|
||||
|
||||
// OpenClaw: pass suggestedDefaults for model registration
|
||||
if (appId === "openclaw" && values.suggestedDefaults) {
|
||||
providerData.suggestedDefaults = values.suggestedDefaults;
|
||||
}
|
||||
|
||||
await onSubmit(providerData);
|
||||
onOpenChange(false);
|
||||
},
|
||||
@@ -286,6 +309,7 @@ export function AddProviderDialog({
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
// OpenCode/OpenClaw: directly show form without tabs
|
||||
<ProviderForm
|
||||
appId={appId}
|
||||
submitLabel={t("common.add")}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Terminal,
|
||||
TestTube2,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -36,6 +37,9 @@ interface ProviderActionsProps {
|
||||
isAutoFailoverEnabled?: boolean;
|
||||
isInFailoverQueue?: boolean;
|
||||
onToggleFailover?: (enabled: boolean) => void;
|
||||
// OpenClaw: default model
|
||||
isDefaultModel?: boolean;
|
||||
onSetAsDefault?: () => void;
|
||||
}
|
||||
|
||||
export function ProviderActions({
|
||||
@@ -58,14 +62,20 @@ export function ProviderActions({
|
||||
isAutoFailoverEnabled = false,
|
||||
isInFailoverQueue = false,
|
||||
onToggleFailover,
|
||||
// OpenClaw: default model
|
||||
isDefaultModel = false,
|
||||
onSetAsDefault,
|
||||
}: ProviderActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const iconButtonClass = "h-8 w-8 p-1";
|
||||
|
||||
const isOpenCodeMode = appId === "opencode" && !isOmo;
|
||||
// 累加模式应用(OpenCode 非 OMO 和 OpenClaw)
|
||||
const isAdditiveMode =
|
||||
(appId === "opencode" && !isOmo) || appId === "openclaw";
|
||||
|
||||
// 故障转移模式下的按钮逻辑(累加模式和 OMO 应用不支持故障转移)
|
||||
const isFailoverMode =
|
||||
!isOpenCodeMode && !isOmo && isAutoFailoverEnabled && onToggleFailover;
|
||||
!isAdditiveMode && !isOmo && isAutoFailoverEnabled && onToggleFailover;
|
||||
|
||||
const handleMainButtonClick = () => {
|
||||
if (isOmo) {
|
||||
@@ -74,7 +84,8 @@ export function ProviderActions({
|
||||
} else {
|
||||
onSwitch();
|
||||
}
|
||||
} else if (isOpenCodeMode) {
|
||||
} else if (isAdditiveMode) {
|
||||
// 累加模式:切换配置状态(添加/移除)
|
||||
if (isInConfig) {
|
||||
if (onRemoveFromConfig) {
|
||||
onRemoveFromConfig();
|
||||
@@ -112,13 +123,16 @@ export function ProviderActions({
|
||||
};
|
||||
}
|
||||
|
||||
if (isOpenCodeMode) {
|
||||
// 累加模式(OpenCode 非 OMO / OpenClaw)
|
||||
if (isAdditiveMode) {
|
||||
if (isInConfig) {
|
||||
return {
|
||||
disabled: false,
|
||||
disabled: isDefaultModel === true,
|
||||
variant: "secondary" as const,
|
||||
className:
|
||||
className: cn(
|
||||
"bg-orange-100 text-orange-600 hover:bg-orange-200 dark:bg-orange-900/50 dark:text-orange-400 dark:hover:bg-orange-900/70",
|
||||
isDefaultModel && "opacity-40 cursor-not-allowed",
|
||||
),
|
||||
icon: <Minus className="h-4 w-4" />,
|
||||
text: t("provider.removeFromConfig", { defaultValue: "移除" }),
|
||||
};
|
||||
@@ -180,12 +194,32 @@ export function ProviderActions({
|
||||
|
||||
const canDelete = isOmo
|
||||
? !(isLastOmo && isCurrent)
|
||||
: isOpenCodeMode
|
||||
: isAdditiveMode
|
||||
? 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}
|
||||
|
||||
@@ -48,6 +48,9 @@ interface ProviderCardProps {
|
||||
isInFailoverQueue?: boolean; // 是否在故障转移队列中
|
||||
onToggleFailover?: (enabled: boolean) => void; // 切换故障转移队列
|
||||
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
|
||||
// OpenClaw: default model
|
||||
isDefaultModel?: boolean;
|
||||
onSetAsDefault?: () => void;
|
||||
}
|
||||
|
||||
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
||||
@@ -108,6 +111,9 @@ export function ProviderCard({
|
||||
isInFailoverQueue = false,
|
||||
onToggleFailover,
|
||||
activeProviderId,
|
||||
// OpenClaw: default model
|
||||
isDefaultModel,
|
||||
onSetAsDefault,
|
||||
}: ProviderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -133,7 +139,10 @@ export function ProviderCard({
|
||||
|
||||
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
||||
|
||||
const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent;
|
||||
// 获取用量数据以判断是否有多套餐
|
||||
// 累加模式应用(OpenCode/OpenClaw):使用 isInConfig 代替 isCurrent
|
||||
const shouldAutoQuery =
|
||||
appId === "opencode" || appId === "openclaw" ? isInConfig : isCurrent;
|
||||
const autoQueryInterval = shouldAutoQuery
|
||||
? provider.meta?.usage_script?.autoQueryInterval || 0
|
||||
: 0;
|
||||
@@ -176,9 +185,14 @@ export function ProviderCard({
|
||||
onOpenWebsite(displayUrl);
|
||||
};
|
||||
|
||||
// 判断是否是"当前使用中"的供应商
|
||||
// - OMO 供应商:使用 isCurrent
|
||||
// - 累加模式应用(OpenCode 非 OMO / OpenClaw):不存在"当前"概念,始终返回 false
|
||||
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
||||
// - 普通模式:isCurrent
|
||||
const isActiveProvider = isOmo
|
||||
? isCurrent
|
||||
: appId === "opencode"
|
||||
: appId === "opencode" || appId === "openclaw"
|
||||
? false
|
||||
: isAutoFailoverEnabled
|
||||
? activeProviderId === provider.id
|
||||
@@ -378,6 +392,9 @@ export function ProviderCard({
|
||||
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||||
isInFailoverQueue={isInFailoverQueue}
|
||||
onToggleFailover={onToggleFailover}
|
||||
// OpenClaw: default model
|
||||
isDefaultModel={isDefaultModel}
|
||||
onSetAsDefault={onSetAsDefault}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,11 @@ import type { Provider } from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { providersApi } from "@/lib/api/providers";
|
||||
import { useDragSort } from "@/hooks/useDragSort";
|
||||
import {
|
||||
useOpenClawLiveProviderIds,
|
||||
useOpenClawDefaultModel,
|
||||
} from "@/hooks/useOpenClaw";
|
||||
// import { useStreamCheck } from "@/hooks/useStreamCheck"; // 测试功能已隐藏
|
||||
import { ProviderCard } from "@/components/providers/ProviderCard";
|
||||
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
||||
import {
|
||||
@@ -51,6 +56,7 @@ interface ProviderListProps {
|
||||
isProxyRunning?: boolean; // 代理服务运行状态
|
||||
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管)
|
||||
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
|
||||
onSetAsDefault?: (provider: Provider) => void; // OpenClaw: set as default model
|
||||
}
|
||||
|
||||
export function ProviderList({
|
||||
@@ -71,6 +77,7 @@ export function ProviderList({
|
||||
isProxyRunning = false,
|
||||
isProxyTakeover = false,
|
||||
activeProviderId,
|
||||
onSetAsDefault,
|
||||
}: ProviderListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
|
||||
@@ -84,14 +91,39 @@ export function ProviderList({
|
||||
enabled: appId === "opencode",
|
||||
});
|
||||
|
||||
const isProviderInConfig = useCallback(
|
||||
(providerId: string): boolean => {
|
||||
if (appId !== "opencode") return true; // 非 OpenCode 应用始终返回 true
|
||||
return opencodeLiveIds?.includes(providerId) ?? false;
|
||||
},
|
||||
[appId, opencodeLiveIds],
|
||||
// OpenClaw: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig
|
||||
const { data: openclawLiveIds } = useOpenClawLiveProviderIds(
|
||||
appId === "openclaw",
|
||||
);
|
||||
|
||||
// 判断供应商是否已添加到配置(累加模式应用:OpenCode/OpenClaw)
|
||||
const isProviderInConfig = useCallback(
|
||||
(providerId: string): boolean => {
|
||||
if (appId === "opencode") {
|
||||
return opencodeLiveIds?.includes(providerId) ?? false;
|
||||
}
|
||||
if (appId === "openclaw") {
|
||||
return openclawLiveIds?.includes(providerId) ?? false;
|
||||
}
|
||||
return true; // 其他应用始终返回 true
|
||||
},
|
||||
[appId, opencodeLiveIds, openclawLiveIds],
|
||||
);
|
||||
|
||||
// OpenClaw: query default model to determine which provider is default
|
||||
const { data: openclawDefaultModel } = useOpenClawDefaultModel(
|
||||
appId === "openclaw",
|
||||
);
|
||||
|
||||
const isProviderDefaultModel = useCallback(
|
||||
(providerId: string): boolean => {
|
||||
if (appId !== "openclaw" || !openclawDefaultModel?.primary) return false;
|
||||
return openclawDefaultModel.primary.startsWith(providerId + "/");
|
||||
},
|
||||
[appId, openclawDefaultModel],
|
||||
);
|
||||
|
||||
// 故障转移相关
|
||||
const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId);
|
||||
const { data: failoverQueue } = useFailoverQueue(appId);
|
||||
const addToQueue = useAddToFailoverQueue();
|
||||
@@ -240,6 +272,11 @@ export function ProviderList({
|
||||
handleToggleFailover(provider.id, enabled)
|
||||
}
|
||||
activeProviderId={activeProviderId}
|
||||
// OpenClaw: default model
|
||||
isDefaultModel={isProviderDefaultModel(provider.id)}
|
||||
onSetAsDefault={
|
||||
onSetAsDefault ? () => onSetAsDefault(provider) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -352,6 +389,9 @@ interface SortableProviderCardProps {
|
||||
isInFailoverQueue: boolean;
|
||||
onToggleFailover: (enabled: boolean) => void;
|
||||
activeProviderId?: string;
|
||||
// OpenClaw: default model
|
||||
isDefaultModel?: boolean;
|
||||
onSetAsDefault?: () => void;
|
||||
}
|
||||
|
||||
function SortableProviderCard({
|
||||
@@ -379,6 +419,8 @@ function SortableProviderCard({
|
||||
isInFailoverQueue,
|
||||
onToggleFailover,
|
||||
activeProviderId,
|
||||
isDefaultModel,
|
||||
onSetAsDefault,
|
||||
}: SortableProviderCardProps) {
|
||||
const {
|
||||
setNodeRef,
|
||||
@@ -428,6 +470,9 @@ function SortableProviderCard({
|
||||
isInFailoverQueue={isInFailoverQueue}
|
||||
onToggleFailover={onToggleFailover}
|
||||
activeProviderId={activeProviderId}
|
||||
// OpenClaw: default model
|
||||
isDefaultModel={isDefaultModel}
|
||||
onSetAsDefault={onSetAsDefault}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ const ENDPOINT_TIMEOUT_SECS: Record<AppId, number> = {
|
||||
claude: 8,
|
||||
gemini: 8,
|
||||
opencode: 8,
|
||||
openclaw: 8,
|
||||
};
|
||||
|
||||
interface TestResult {
|
||||
|
||||
@@ -12,6 +12,19 @@ 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,
|
||||
@@ -21,6 +34,10 @@ import {
|
||||
Settings,
|
||||
FolderInput,
|
||||
Loader2,
|
||||
HelpCircle,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
@@ -43,6 +60,13 @@ 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>>;
|
||||
@@ -53,19 +77,149 @@ interface OmoFormFieldsProps {
|
||||
onOtherFieldsStrChange: (value: string) => void;
|
||||
}
|
||||
|
||||
type CustomModelItem = { key: string; model: string };
|
||||
export type CustomModelItem = {
|
||||
key: string;
|
||||
model: string;
|
||||
sourceKey?: string;
|
||||
};
|
||||
type BuiltinModelDef = Pick<
|
||||
OmoAgentDef | OmoCategoryDef,
|
||||
"key" | "display" | "descZh" | "descEn" | "recommended"
|
||||
"key" | "display" | "descKey" | "recommended" | "tooltipKey"
|
||||
>;
|
||||
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__";
|
||||
const UNAVAILABLE_VARIANT_VALUE = "__cc_switch_omo_variant_unavailable__";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function getAdvancedStr(config: Record<string, unknown> | undefined): string {
|
||||
if (!config) return "";
|
||||
@@ -86,24 +240,58 @@ function collectCustomModels(
|
||||
customs.push({
|
||||
key: k,
|
||||
model: ((v as Record<string, unknown>).model as string) || "",
|
||||
sourceKey: k,
|
||||
});
|
||||
}
|
||||
}
|
||||
return customs;
|
||||
}
|
||||
|
||||
function mergeCustomModelsIntoStore(
|
||||
export function mergeCustomModelsIntoStore(
|
||||
store: Record<string, Record<string, unknown>>,
|
||||
builtinKeys: Set<string>,
|
||||
customs: CustomModelItem[],
|
||||
modelVariantsMap: Record<string, string[]>,
|
||||
): Record<string, Record<string, unknown>> {
|
||||
const updated = { ...store };
|
||||
for (const key of Object.keys(updated)) {
|
||||
if (!builtinKeys.has(key)) delete updated[key];
|
||||
const updated: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(store)) {
|
||||
if (builtinKeys.has(key)) {
|
||||
updated[key] = { ...value };
|
||||
}
|
||||
}
|
||||
|
||||
for (const custom of customs) {
|
||||
if (custom.key.trim()) {
|
||||
updated[custom.key] = { ...updated[custom.key], model: custom.model };
|
||||
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];
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
@@ -112,6 +300,7 @@ function mergeCustomModelsIntoStore(
|
||||
export function OmoFormFields({
|
||||
modelOptions,
|
||||
modelVariantsMap = {},
|
||||
presetMetaMap: _presetMetaMap = {},
|
||||
agents,
|
||||
onAgentsChange,
|
||||
categories,
|
||||
@@ -119,8 +308,7 @@ export function OmoFormFields({
|
||||
otherFieldsStr,
|
||||
onOtherFieldsStrChange,
|
||||
}: OmoFormFieldsProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isZh = i18n.language?.startsWith("zh");
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [mainAgentsOpen, setMainAgentsOpen] = useState(true);
|
||||
const [subAgentsOpen, setSubAgentsOpen] = useState(true);
|
||||
@@ -159,19 +347,29 @@ export function OmoFormFields({
|
||||
const syncCustomAgents = useCallback(
|
||||
(customs: CustomModelItem[]) => {
|
||||
onAgentsChange(
|
||||
mergeCustomModelsIntoStore(agents, BUILTIN_AGENT_KEYS, customs),
|
||||
mergeCustomModelsIntoStore(
|
||||
agents,
|
||||
BUILTIN_AGENT_KEYS,
|
||||
customs,
|
||||
modelVariantsMap,
|
||||
),
|
||||
);
|
||||
},
|
||||
[agents, onAgentsChange],
|
||||
[agents, onAgentsChange, modelVariantsMap],
|
||||
);
|
||||
|
||||
const syncCustomCategories = useCallback(
|
||||
(customs: CustomModelItem[]) => {
|
||||
onCategoriesChange(
|
||||
mergeCustomModelsIntoStore(categories, BUILTIN_CATEGORY_KEYS, customs),
|
||||
mergeCustomModelsIntoStore(
|
||||
categories,
|
||||
BUILTIN_CATEGORY_KEYS,
|
||||
customs,
|
||||
modelVariantsMap,
|
||||
),
|
||||
);
|
||||
},
|
||||
[categories, onCategoriesChange],
|
||||
[categories, onCategoriesChange, modelVariantsMap],
|
||||
);
|
||||
|
||||
const buildEffectiveModelOptions = useCallback(
|
||||
@@ -212,43 +410,16 @@ export function OmoFormFields({
|
||||
const renderModelSelect = (
|
||||
currentModel: string,
|
||||
onChange: (value: string) => void,
|
||||
placeholder?: string,
|
||||
recommended?: string,
|
||||
) => {
|
||||
const options = buildEffectiveModelOptions(currentModel);
|
||||
return (
|
||||
<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>
|
||||
<ModelCombobox
|
||||
value={currentModel}
|
||||
options={options}
|
||||
recommended={recommended}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -268,11 +439,21 @@ 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);
|
||||
@@ -283,9 +464,8 @@ export function OmoFormFields({
|
||||
onValueChange={(value) =>
|
||||
onChange(value === EMPTY_VARIANT_VALUE ? "" : value)
|
||||
}
|
||||
disabled={!hasModel}
|
||||
>
|
||||
<SelectTrigger className="w-32 h-8 text-xs shrink-0">
|
||||
<SelectTrigger className="w-28 h-8 text-xs shrink-0">
|
||||
<SelectValue
|
||||
placeholder={t("omo.variantPlaceholder", {
|
||||
defaultValue: "variant",
|
||||
@@ -296,30 +476,16 @@ export function OmoFormFields({
|
||||
<SelectItem value={EMPTY_VARIANT_VALUE}>
|
||||
{t("omo.defaultWrapped", { defaultValue: "(Default)" })}
|
||||
</SelectItem>
|
||||
{!hasModel ? (
|
||||
<SelectItem value={UNAVAILABLE_VARIANT_VALUE} disabled>
|
||||
{t("omo.selectModelFirst", {
|
||||
defaultValue: "Select model first",
|
||||
})}
|
||||
{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>
|
||||
) : 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>
|
||||
);
|
||||
@@ -536,7 +702,7 @@ export function OmoFormFields({
|
||||
toast.warning(
|
||||
t("omo.noEnabledModelsWarning", {
|
||||
defaultValue:
|
||||
"No enabled models available. Configure and enable OpenCode models first.",
|
||||
"No configured models available. Configure OpenCode models first.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
@@ -641,9 +807,17 @@ 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="text-sm font-medium">{def.display}</div>
|
||||
<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-xs text-muted-foreground truncate">
|
||||
{isZh ? def.descZh : def.descEn}
|
||||
{t(def.descKey)}
|
||||
</div>
|
||||
</div>
|
||||
{renderModelSelect(
|
||||
@@ -727,16 +901,14 @@ export function OmoFormFields({
|
||||
className="border-b border-border/30 last:border-b-0"
|
||||
>
|
||||
<div className="flex items-center gap-2 py-1.5">
|
||||
<Input
|
||||
<DeferredKeyInput
|
||||
value={item.key}
|
||||
onChange={(e) => updateCustom({ key: e.target.value })}
|
||||
onCommit={(value) => updateCustom({ key: value })}
|
||||
placeholder={keyPlaceholder}
|
||||
className="w-32 shrink-0 h-8 text-sm text-primary"
|
||||
/>
|
||||
{renderModelSelect(
|
||||
item.model,
|
||||
(value) => updateCustom({ model: value }),
|
||||
t("omo.modelNamePlaceholder", { defaultValue: "model-name" }),
|
||||
{renderModelSelect(item.model, (value) =>
|
||||
updateCustom({ model: value }),
|
||||
)}
|
||||
{renderVariantSelect(item.model, currentVariant, (value) => {
|
||||
if (!item.key) return;
|
||||
@@ -877,11 +1049,17 @@ export function OmoFormFields({
|
||||
|
||||
const addCustomModel = (scope: AdvancedScope) => {
|
||||
if (scope === "agent") {
|
||||
setCustomAgents((prev) => [...prev, { key: "", model: "" }]);
|
||||
setCustomAgents((prev) => [
|
||||
...prev,
|
||||
{ key: "", model: "", sourceKey: "" },
|
||||
]);
|
||||
setSubAgentsOpen(true);
|
||||
return;
|
||||
}
|
||||
setCustomCategories((prev) => [...prev, { key: "", model: "" }]);
|
||||
setCustomCategories((prev) => [
|
||||
...prev,
|
||||
{ key: "", model: "", sourceKey: "" },
|
||||
]);
|
||||
setCategoriesOpen(true);
|
||||
};
|
||||
|
||||
@@ -931,7 +1109,7 @@ export function OmoFormFields({
|
||||
·{" "}
|
||||
{t("omo.enabledModelsCount", {
|
||||
count: modelOptions.length,
|
||||
defaultValue: "{{count}} enabled models available",
|
||||
defaultValue: "{{count}} configured models available",
|
||||
})}
|
||||
</span>
|
||||
{localFilePath && (
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { ApiKeySection } from "./shared";
|
||||
import { openclawApiProtocols } from "@/config/openclawProviderPresets";
|
||||
import type { ProviderCategory, OpenClawModel } from "@/types";
|
||||
|
||||
interface OpenClawFormFieldsProps {
|
||||
// Base URL
|
||||
baseUrl: string;
|
||||
onBaseUrlChange: (value: string) => void;
|
||||
|
||||
// API Key
|
||||
apiKey: string;
|
||||
onApiKeyChange: (value: string) => void;
|
||||
category?: ProviderCategory;
|
||||
shouldShowApiKeyLink: boolean;
|
||||
websiteUrl: string;
|
||||
isPartner?: boolean;
|
||||
partnerPromotionKey?: string;
|
||||
|
||||
// API Protocol
|
||||
api: string;
|
||||
onApiChange: (value: string) => void;
|
||||
|
||||
// Models
|
||||
models: OpenClawModel[];
|
||||
onModelsChange: (models: OpenClawModel[]) => void;
|
||||
}
|
||||
|
||||
export function OpenClawFormFields({
|
||||
baseUrl,
|
||||
onBaseUrlChange,
|
||||
apiKey,
|
||||
onApiKeyChange,
|
||||
category,
|
||||
shouldShowApiKeyLink,
|
||||
websiteUrl,
|
||||
isPartner,
|
||||
partnerPromotionKey,
|
||||
api,
|
||||
onApiChange,
|
||||
models,
|
||||
onModelsChange,
|
||||
}: OpenClawFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedModels, setExpandedModels] = useState<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
ClaudeApiFormat,
|
||||
OpenCodeModel,
|
||||
OpenCodeProviderConfig,
|
||||
OpenClawModel,
|
||||
} from "@/types";
|
||||
import {
|
||||
providerPresets,
|
||||
@@ -31,9 +32,16 @@ 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";
|
||||
@@ -53,7 +61,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 } from "@/types/omo";
|
||||
import { mergeOmoConfigPreview, parseOmoOtherFieldsObject } from "@/types/omo";
|
||||
import {
|
||||
ProviderAdvancedConfig,
|
||||
type PricingModelSourceOption,
|
||||
@@ -112,21 +120,25 @@ 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 {
|
||||
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>)
|
||||
: {},
|
||||
};
|
||||
return normalize(parsed);
|
||||
} catch {
|
||||
return {
|
||||
npm: OPENCODE_DEFAULT_NPM,
|
||||
@@ -136,6 +148,25 @@ 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> {
|
||||
@@ -148,13 +179,25 @@ function toOpencodeExtraOptions(
|
||||
return extra;
|
||||
}
|
||||
|
||||
const OPENCLAW_DEFAULT_CONFIG = JSON.stringify(
|
||||
{
|
||||
baseUrl: "",
|
||||
apiKey: "",
|
||||
api: "openai-completions",
|
||||
models: [],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
type PresetEntry = {
|
||||
id: string;
|
||||
preset:
|
||||
| ProviderPreset
|
||||
| CodexProviderPreset
|
||||
| GeminiProviderPreset
|
||||
| OpenCodeProviderPreset;
|
||||
| OpenCodeProviderPreset
|
||||
| OpenClawProviderPreset;
|
||||
};
|
||||
|
||||
interface ProviderFormProps {
|
||||
@@ -195,8 +238,10 @@ function buildOmoProfilePreview(
|
||||
}
|
||||
if (otherFieldsStr.trim()) {
|
||||
try {
|
||||
const other = JSON.parse(otherFieldsStr);
|
||||
Object.assign(profileOnly, other);
|
||||
const other = parseOmoOtherFieldsObject(otherFieldsStr);
|
||||
if (other) {
|
||||
Object.assign(profileOnly, other);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return profileOnly;
|
||||
@@ -233,6 +278,7 @@ export function ProviderForm({
|
||||
category?: ProviderCategory;
|
||||
isPartner?: boolean;
|
||||
partnerPromotionKey?: string;
|
||||
suggestedDefaults?: OpenClawSuggestedDefaults;
|
||||
} | null>(null);
|
||||
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
|
||||
const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] =
|
||||
@@ -311,7 +357,9 @@ export function ProviderForm({
|
||||
? GEMINI_DEFAULT_CONFIG
|
||||
: appId === "opencode"
|
||||
? OPENCODE_DEFAULT_CONFIG
|
||||
: CLAUDE_DEFAULT_CONFIG,
|
||||
: appId === "openclaw"
|
||||
? OPENCLAW_DEFAULT_CONFIG
|
||||
: CLAUDE_DEFAULT_CONFIG,
|
||||
icon: initialData?.icon ?? "",
|
||||
iconColor: initialData?.iconColor ?? "",
|
||||
}),
|
||||
@@ -438,6 +486,11 @@ 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}`,
|
||||
@@ -580,19 +633,24 @@ export function ProviderForm({
|
||||
);
|
||||
}, [opencodeProvidersData?.providers, providerId]);
|
||||
const [enabledOpencodeProviderIds, setEnabledOpencodeProviderIds] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
string[] | null
|
||||
>(null);
|
||||
const [omoLiveIdsLoadFailed, setOmoLiveIdsLoadFailed] = useState(false);
|
||||
const lastOmoModelSourceWarningRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
if (!isOmoCategory) {
|
||||
setEnabledOpencodeProviderIds([]);
|
||||
setEnabledOpencodeProviderIds(null);
|
||||
setOmoLiveIdsLoadFailed(false);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}
|
||||
|
||||
setEnabledOpencodeProviderIds(null);
|
||||
setOmoLiveIdsLoadFailed(false);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const ids = await providersApi.getOpenCodeLiveProviderIds();
|
||||
@@ -600,9 +658,13 @@ export function ProviderForm({
|
||||
setEnabledOpencodeProviderIds(ids);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load OpenCode live provider ids:", error);
|
||||
console.warn(
|
||||
"[OMO_MODEL_SOURCE_LIVE_IDS_FAILED] failed to load live provider ids",
|
||||
error,
|
||||
);
|
||||
if (active) {
|
||||
setEnabledOpencodeProviderIds([]);
|
||||
setOmoLiveIdsLoadFailed(true);
|
||||
setEnabledOpencodeProviderIds(null);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -612,23 +674,71 @@ export function ProviderForm({
|
||||
};
|
||||
}, [isOmoCategory]);
|
||||
|
||||
const omoModelOptions = useMemo(() => {
|
||||
if (!isOmoCategory) return [];
|
||||
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 allProviders = opencodeProvidersData?.providers;
|
||||
if (!allProviders) return [];
|
||||
if (!allProviders) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
const enabledSet = new Set(enabledOpencodeProviderIds);
|
||||
if (enabledSet.size === 0) return [];
|
||||
const shouldFilterByLive = !omoLiveIdsLoadFailed;
|
||||
if (shouldFilterByLive && enabledOpencodeProviderIds === null) {
|
||||
return empty;
|
||||
}
|
||||
const liveSet =
|
||||
shouldFilterByLive && enabledOpencodeProviderIds
|
||||
? new Set(enabledOpencodeProviderIds)
|
||||
: null;
|
||||
|
||||
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" || !enabledSet.has(providerKey)) {
|
||||
if (provider.category === "omo") {
|
||||
continue;
|
||||
}
|
||||
if (liveSet && !liveSet.has(providerKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedConfig = parseOpencodeConfig(provider.settingsConfig);
|
||||
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;
|
||||
}
|
||||
for (const [modelId, model] of Object.entries(
|
||||
parsedConfig.models || {},
|
||||
)) {
|
||||
@@ -645,63 +755,107 @@ export function ProviderForm({
|
||||
if (!dedupedOptions.has(value)) {
|
||||
dedupedOptions.set(value, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(dedupedOptions.entries())
|
||||
.map(([value, label]) => ({ value, label }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, "zh-CN"));
|
||||
}, [
|
||||
isOmoCategory,
|
||||
opencodeProvidersData?.providers,
|
||||
enabledOpencodeProviderIds,
|
||||
]);
|
||||
const omoModelVariantsMap = useMemo(() => {
|
||||
const variantsMap: Record<string, string[]> = {};
|
||||
if (!isOmoCategory) {
|
||||
return variantsMap;
|
||||
}
|
||||
|
||||
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)
|
||||
rawVariants &&
|
||||
typeof rawVariants === "object" &&
|
||||
!Array.isArray(rawVariants)
|
||||
) {
|
||||
continue;
|
||||
const variantKeys = Object.keys(rawVariants).filter(Boolean);
|
||||
if (variantKeys.length > 0) {
|
||||
variantsMap[value] = variantKeys;
|
||||
}
|
||||
}
|
||||
const variantKeys = Object.keys(rawVariants).filter(Boolean);
|
||||
if (variantKeys.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
variantsMap[`${providerKey}/${modelId}`] = variantKeys;
|
||||
}
|
||||
}
|
||||
|
||||
return variantsMap;
|
||||
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,
|
||||
};
|
||||
}, [
|
||||
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.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (fallback) {
|
||||
toast.warning(
|
||||
t("omo.modelSourceFallbackWarning", {
|
||||
defaultValue:
|
||||
"Failed to load live provider state. Falling back to configured providers.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [
|
||||
isOmoCategory,
|
||||
omoModelBuild.parseFailedProviders,
|
||||
omoModelBuild.usedFallbackSource,
|
||||
t,
|
||||
]);
|
||||
|
||||
const initialOmoSettings =
|
||||
@@ -719,6 +873,24 @@ export function ProviderForm({
|
||||
return providerId || "";
|
||||
});
|
||||
|
||||
// OpenClaw: query existing providers for duplicate key checking
|
||||
const { data: openclawProvidersData } = useProvidersQuery("openclaw");
|
||||
const existingOpenclawKeys = useMemo(() => {
|
||||
if (!openclawProvidersData?.providers) return [];
|
||||
// Exclude current provider ID when in edit mode
|
||||
return Object.keys(openclawProvidersData.providers).filter(
|
||||
(k) => k !== providerId,
|
||||
);
|
||||
}, [openclawProvidersData?.providers, providerId]);
|
||||
|
||||
// OpenClaw Provider Key state
|
||||
const [openclawProviderKey, setOpenclawProviderKey] = useState<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;
|
||||
@@ -860,6 +1032,128 @@ 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 {
|
||||
@@ -987,6 +1281,24 @@ export function ProviderForm({
|
||||
}
|
||||
}
|
||||
|
||||
// OpenClaw: validate provider key
|
||||
if (appId === "openclaw") {
|
||||
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
if (!openclawProviderKey.trim()) {
|
||||
toast.error(t("openclaw.providerKeyRequired"));
|
||||
return;
|
||||
}
|
||||
if (!keyPattern.test(openclawProviderKey)) {
|
||||
toast.error(t("openclaw.providerKeyInvalid"));
|
||||
return;
|
||||
}
|
||||
if (!isEditMode && existingOpenclawKeys.includes(openclawProviderKey)) {
|
||||
toast.error(t("openclaw.providerKeyDuplicate"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 非官方供应商必填校验:端点和 API Key
|
||||
if (category !== "official") {
|
||||
if (appId === "claude") {
|
||||
if (!baseUrl.trim()) {
|
||||
@@ -1078,7 +1390,19 @@ export function ProviderForm({
|
||||
}
|
||||
if (omoOtherFieldsStr.trim()) {
|
||||
try {
|
||||
omoConfig.otherFields = JSON.parse(omoOtherFieldsStr);
|
||||
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;
|
||||
} catch {
|
||||
toast.error(
|
||||
t("omo.invalidJson", {
|
||||
@@ -1108,6 +1432,8 @@ export function ProviderForm({
|
||||
} else {
|
||||
payload.providerKey = opencodeProviderKey;
|
||||
}
|
||||
} else if (appId === "openclaw") {
|
||||
payload.providerKey = openclawProviderKey;
|
||||
}
|
||||
|
||||
if (category === "omo" && !payload.presetCategory) {
|
||||
@@ -1122,6 +1448,10 @@ export function ProviderForm({
|
||||
if (activePreset.isPartner) {
|
||||
payload.isPartner = activePreset.isPartner;
|
||||
}
|
||||
// OpenClaw: 传递预设的 suggestedDefaults 到提交数据
|
||||
if (activePreset.suggestedDefaults) {
|
||||
payload.suggestedDefaults = activePreset.suggestedDefaults;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isEditMode && draftCustomEndpoints.length > 0) {
|
||||
@@ -1260,6 +1590,21 @@ export function ProviderForm({
|
||||
formWebsiteUrl: form.watch("websiteUrl") || "",
|
||||
});
|
||||
|
||||
// 使用 API Key 链接 hook (OpenClaw)
|
||||
const {
|
||||
shouldShowApiKeyLink: shouldShowOpenclawApiKeyLink,
|
||||
websiteUrl: openclawWebsiteUrl,
|
||||
isPartner: isOpenclawPartner,
|
||||
partnerPromotionKey: openclawPartnerPromotionKey,
|
||||
} = useApiKeyLink({
|
||||
appId: "openclaw",
|
||||
category,
|
||||
selectedPresetId,
|
||||
presetEntries,
|
||||
formWebsiteUrl: form.watch("websiteUrl") || "",
|
||||
});
|
||||
|
||||
// 使用端点测速候选 hook
|
||||
const speedTestEndpoints = useSpeedTestEndpoints({
|
||||
appId,
|
||||
selectedPresetId,
|
||||
@@ -1291,6 +1636,14 @@ export function ProviderForm({
|
||||
setOpencodeExtraOptions({});
|
||||
resetOmoDraftState();
|
||||
}
|
||||
// OpenClaw 自定义模式:重置为空配置
|
||||
if (appId === "openclaw") {
|
||||
setOpenclawProviderKey("");
|
||||
setOpenclawBaseUrl("");
|
||||
setOpenclawApiKey("");
|
||||
setOpenclawApi("openai-completions");
|
||||
setOpenclawModels([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1374,6 +1727,40 @@ export function ProviderForm({
|
||||
return;
|
||||
}
|
||||
|
||||
// OpenClaw preset handling
|
||||
if (appId === "openclaw") {
|
||||
const preset = entry.preset as OpenClawProviderPreset;
|
||||
const config = preset.settingsConfig;
|
||||
|
||||
// Update activePreset with suggestedDefaults for OpenClaw
|
||||
setActivePreset({
|
||||
id: value,
|
||||
category: preset.category,
|
||||
isPartner: preset.isPartner,
|
||||
partnerPromotionKey: preset.partnerPromotionKey,
|
||||
suggestedDefaults: preset.suggestedDefaults,
|
||||
});
|
||||
|
||||
// Clear provider key (user must enter their own unique key)
|
||||
setOpenclawProviderKey("");
|
||||
|
||||
// Update OpenClaw-specific states
|
||||
setOpenclawBaseUrl(config.baseUrl || "");
|
||||
setOpenclawApiKey(config.apiKey || "");
|
||||
setOpenclawApi(config.api || "openai-completions");
|
||||
setOpenclawModels(config.models || []);
|
||||
|
||||
// Update form fields
|
||||
form.reset({
|
||||
name: preset.name,
|
||||
websiteUrl: preset.websiteUrl ?? "",
|
||||
settingsConfig: JSON.stringify(config, null, 2),
|
||||
icon: preset.icon ?? "",
|
||||
iconColor: preset.iconColor ?? "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = entry.preset as ProviderPreset;
|
||||
const config = applyTemplateValues(
|
||||
preset.settingsConfig,
|
||||
@@ -1478,6 +1865,54 @@ 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
|
||||
}
|
||||
/>
|
||||
@@ -1603,6 +2038,7 @@ export function ProviderForm({
|
||||
<OmoFormFields
|
||||
modelOptions={omoModelOptions}
|
||||
modelVariantsMap={omoModelVariantsMap}
|
||||
presetMetaMap={omoPresetMetaMap}
|
||||
agents={omoAgents}
|
||||
onAgentsChange={setOmoAgents}
|
||||
categories={omoCategories}
|
||||
@@ -1612,6 +2048,26 @@ 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
|
||||
@@ -1688,6 +2144,34 @@ 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
|
||||
@@ -1737,5 +2221,6 @@ export type ProviderFormValues = ProviderFormData & {
|
||||
presetCategory?: ProviderCategory;
|
||||
isPartner?: boolean;
|
||||
meta?: ProviderMeta;
|
||||
providerKey?: string;
|
||||
providerKey?: string; // OpenCode/OpenClaw: user-defined provider key
|
||||
suggestedDefaults?: OpenClawSuggestedDefaults; // OpenClaw: suggested default model configuration
|
||||
};
|
||||
|
||||
@@ -4,9 +4,10 @@ import {
|
||||
setCodexBaseUrl as setCodexBaseUrlInConfig,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import type { ProviderCategory } from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
|
||||
interface UseBaseUrlStateProps {
|
||||
appType: "claude" | "codex" | "gemini" | "opencode";
|
||||
appType: AppId;
|
||||
category: ProviderCategory | undefined;
|
||||
settingsConfig: string;
|
||||
codexConfig?: string;
|
||||
|
||||
@@ -20,6 +20,7 @@ const APP_CONFIG: Array<{
|
||||
{ id: "codex", icon: "openai", nameKey: "apps.codex" },
|
||||
{ id: "gemini", icon: "gemini", nameKey: "apps.gemini" },
|
||||
{ id: "opencode", icon: "opencode", nameKey: "apps.opencode" },
|
||||
{ id: "openclaw", icon: "openclaw", nameKey: "apps.openclaw" },
|
||||
];
|
||||
|
||||
export function AppVisibilitySettings({
|
||||
@@ -33,6 +34,7 @@ export function AppVisibilitySettings({
|
||||
codex: true,
|
||||
gemini: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
};
|
||||
|
||||
// Count how many apps are currently visible
|
||||
|
||||
@@ -10,6 +10,7 @@ export function RectifierConfigPanel() {
|
||||
const [config, setConfig] = useState<RectifierConfig>({
|
||||
enabled: true,
|
||||
requestThinkingSignature: true,
|
||||
requestThinkingBudget: true,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
@@ -69,6 +70,21 @@ export function RectifierConfigPanel() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pl-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label>{t("settings.advanced.rectifier.thinkingBudget")}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.advanced.rectifier.thinkingBudgetDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.requestThinkingBudget}
|
||||
disabled={!config.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({ requestThinkingBudget: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,6 +39,7 @@ import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSe
|
||||
import { TerminalSettings } from "@/components/settings/TerminalSettings";
|
||||
import { DirectorySettings } from "@/components/settings/DirectorySettings";
|
||||
import { ImportExportSection } from "@/components/settings/ImportExportSection";
|
||||
import { WebdavSyncSection } from "@/components/settings/WebdavSyncSection";
|
||||
import { AboutSection } from "@/components/settings/AboutSection";
|
||||
import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings";
|
||||
import { ProxyPanel } from "@/components/proxy";
|
||||
@@ -595,6 +596,11 @@ export function SettingsPage({
|
||||
onExport={exportConfig}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
<div className="pt-6">
|
||||
<WebdavSyncSection
|
||||
config={settings?.webdavSync}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
|
||||
@@ -0,0 +1,820 @@
|
||||
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 { Switch } from "@/components/ui/switch";
|
||||
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",
|
||||
autoSync: config?.autoSync ?? false,
|
||||
}));
|
||||
|
||||
// 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",
|
||||
autoSync: config.autoSync ?? false,
|
||||
});
|
||||
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 handleAutoSyncChange = useCallback((checked: boolean) => {
|
||||
setForm((prev) => ({ ...prev, autoSync: checked }));
|
||||
setDirty(true);
|
||||
setJustSaved(false);
|
||||
if (justSavedTimerRef.current) {
|
||||
clearTimeout(justSavedTimerRef.current);
|
||||
justSavedTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
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",
|
||||
autoSync: form.autoSync,
|
||||
};
|
||||
}, [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;
|
||||
const lastError = config?.status?.lastError?.trim();
|
||||
const showAutoSyncError =
|
||||
!!lastError && config?.status?.lastErrorSource === "auto";
|
||||
|
||||
// ─── 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 className="flex items-start gap-4">
|
||||
<label className="w-40 text-xs font-medium text-foreground shrink-0">
|
||||
{t("settings.webdavSync.autoSync")}
|
||||
<span className="block text-[10px] font-normal text-muted-foreground">
|
||||
{t("settings.webdavSync.autoSyncHint")}
|
||||
</span>
|
||||
</label>
|
||||
<div className="pt-1">
|
||||
<Switch
|
||||
checked={form.autoSync}
|
||||
onCheckedChange={handleAutoSyncChange}
|
||||
aria-label={t("settings.webdavSync.autoSync")}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last sync time */}
|
||||
{lastSyncDisplay && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.webdavSync.lastSync", { time: lastSyncDisplay })}
|
||||
</p>
|
||||
)}
|
||||
{showAutoSyncError && (
|
||||
<div className="rounded-lg border border-red-300/70 bg-red-50/80 px-3 py-2 text-xs text-red-900 dark:border-red-500/50 dark:bg-red-950/30 dark:text-red-200">
|
||||
<p className="font-medium">
|
||||
{t("settings.webdavSync.autoSyncLastErrorTitle")}
|
||||
</p>
|
||||
<p className="mt-1 break-all whitespace-pre-wrap">{lastError}</p>
|
||||
<p className="mt-1 text-[11px] text-red-700/90 dark:text-red-300/80">
|
||||
{t("settings.webdavSync.autoSyncLastErrorHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
const installFromZipMutation = useInstallSkillsFromZip();
|
||||
|
||||
const enabledCounts = useMemo(() => {
|
||||
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 };
|
||||
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };
|
||||
if (!skills) return counts;
|
||||
skills.forEach((skill) => {
|
||||
for (const app of APP_IDS) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
@@ -0,0 +1,103 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
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 };
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { workspaceApi } from "@/lib/api/workspace";
|
||||
|
||||
interface WorkspaceFileEditorProps {
|
||||
filename: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const WorkspaceFileEditor: React.FC<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;
|
||||
@@ -0,0 +1,129 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
FileCode,
|
||||
Heart,
|
||||
User,
|
||||
IdCard,
|
||||
Wrench,
|
||||
Brain,
|
||||
Activity,
|
||||
Rocket,
|
||||
Power,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { workspaceApi } from "@/lib/api/workspace";
|
||||
import WorkspaceFileEditor from "./WorkspaceFileEditor";
|
||||
|
||||
interface WorkspaceFile {
|
||||
filename: string;
|
||||
icon: LucideIcon;
|
||||
descKey: string;
|
||||
}
|
||||
|
||||
const WORKSPACE_FILES: WorkspaceFile[] = [
|
||||
{ filename: "AGENTS.md", icon: FileCode, descKey: "workspace.files.agents" },
|
||||
{ filename: "SOUL.md", icon: Heart, descKey: "workspace.files.soul" },
|
||||
{ filename: "USER.md", icon: User, descKey: "workspace.files.user" },
|
||||
{
|
||||
filename: "IDENTITY.md",
|
||||
icon: IdCard,
|
||||
descKey: "workspace.files.identity",
|
||||
},
|
||||
{ filename: "TOOLS.md", icon: Wrench, descKey: "workspace.files.tools" },
|
||||
{ filename: "MEMORY.md", icon: Brain, descKey: "workspace.files.memory" },
|
||||
{
|
||||
filename: "HEARTBEAT.md",
|
||||
icon: Activity,
|
||||
descKey: "workspace.files.heartbeat",
|
||||
},
|
||||
{
|
||||
filename: "BOOTSTRAP.md",
|
||||
icon: Rocket,
|
||||
descKey: "workspace.files.bootstrap",
|
||||
},
|
||||
{ filename: "BOOT.md", icon: Power, descKey: "workspace.files.boot" },
|
||||
];
|
||||
|
||||
const WorkspaceFilesPanel: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [editingFile, setEditingFile] = useState<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;
|
||||
@@ -1,6 +1,11 @@
|
||||
import React from "react";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons";
|
||||
import {
|
||||
ClaudeIcon,
|
||||
CodexIcon,
|
||||
GeminiIcon,
|
||||
OpenClawIcon,
|
||||
} from "@/components/BrandIcons";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
|
||||
export interface AppConfig {
|
||||
@@ -10,7 +15,13 @@ export interface AppConfig {
|
||||
badgeClass: string;
|
||||
}
|
||||
|
||||
export const APP_IDS: AppId[] = ["claude", "codex", "gemini", "opencode"];
|
||||
export const APP_IDS: AppId[] = [
|
||||
"claude",
|
||||
"codex",
|
||||
"gemini",
|
||||
"opencode",
|
||||
"openclaw",
|
||||
];
|
||||
|
||||
export const APP_ICON_MAP: Record<AppId, AppConfig> = {
|
||||
claude: {
|
||||
@@ -52,4 +63,12 @@ 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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -99,8 +99,6 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "zhipu", // 促销信息 i18n key
|
||||
icon: "zhipu",
|
||||
iconColor: "#0F62FE",
|
||||
},
|
||||
@@ -119,8 +117,6 @@ 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
@@ -24,6 +24,384 @@ 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",
|
||||
@@ -67,8 +445,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "zhipu",
|
||||
icon: "zhipu",
|
||||
iconColor: "#0F62FE",
|
||||
templateValues: {
|
||||
@@ -101,8 +477,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "zhipu",
|
||||
icon: "zhipu",
|
||||
iconColor: "#0F62FE",
|
||||
templateValues: {
|
||||
@@ -470,7 +844,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -497,7 +871,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -524,7 +898,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4.5": { name: "Claude Sonnet 4.5" },
|
||||
"anthropic/claude-opus-4.5": { name: "Claude Opus 4.5" },
|
||||
"anthropic/claude-opus-4.6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -578,7 +952,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
@@ -606,7 +980,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
@@ -635,7 +1009,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4-5-20251101": { name: "Claude Opus 4.5" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
@@ -664,7 +1038,10 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"gpt-5.2": { name: "GPT-5.2" },
|
||||
"gpt-5.2-codex": { name: "GPT-5.2 Codex" },
|
||||
"gpt-5.2-codex": {
|
||||
name: "GPT-5.2 Codex",
|
||||
options: { include: ["reasoning.encrypted_content"], store: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
@@ -693,7 +1070,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4.5": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4.5": { name: "Claude Opus 4.5" },
|
||||
"claude-opus-4.6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { openclawApi } from "@/lib/api/openclaw";
|
||||
import { providersApi } from "@/lib/api/providers";
|
||||
import type {
|
||||
OpenClawEnvConfig,
|
||||
OpenClawToolsConfig,
|
||||
OpenClawAgentsDefaults,
|
||||
} from "@/types";
|
||||
|
||||
/**
|
||||
* Centralized query keys for all OpenClaw-related queries.
|
||||
* Import this from any file that needs to invalidate OpenClaw caches.
|
||||
*/
|
||||
export const openclawKeys = {
|
||||
all: ["openclaw"] as const,
|
||||
liveProviderIds: ["openclaw", "liveProviderIds"] as const,
|
||||
defaultModel: ["openclaw", "defaultModel"] as const,
|
||||
env: ["openclaw", "env"] as const,
|
||||
tools: ["openclaw", "tools"] as const,
|
||||
agentsDefaults: ["openclaw", "agentsDefaults"] as const,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Query hooks
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Query live provider IDs from openclaw.json config.
|
||||
* Used by ProviderList to show "In Config" badge.
|
||||
*/
|
||||
export function useOpenClawLiveProviderIds(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: openclawKeys.liveProviderIds,
|
||||
queryFn: () => providersApi.getOpenClawLiveProviderIds(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the default model from agents.defaults.model.
|
||||
* Used by ProviderList to show which provider is the default.
|
||||
*/
|
||||
export function useOpenClawDefaultModel(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: openclawKeys.defaultModel,
|
||||
queryFn: () => openclawApi.getDefaultModel(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query env section of openclaw.json.
|
||||
*/
|
||||
export function useOpenClawEnv() {
|
||||
return useQuery({
|
||||
queryKey: openclawKeys.env,
|
||||
queryFn: () => openclawApi.getEnv(),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query tools section of openclaw.json.
|
||||
*/
|
||||
export function useOpenClawTools() {
|
||||
return useQuery({
|
||||
queryKey: openclawKeys.tools,
|
||||
queryFn: () => openclawApi.getTools(),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query agents.defaults section of openclaw.json.
|
||||
*/
|
||||
export function useOpenClawAgentsDefaults() {
|
||||
return useQuery({
|
||||
queryKey: openclawKeys.agentsDefaults,
|
||||
queryFn: () => openclawApi.getAgentsDefaults(),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Mutation hooks
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Save env config. Invalidates env query on success.
|
||||
* Toast notifications are handled by the component.
|
||||
*/
|
||||
export function useSaveOpenClawEnv() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (env: OpenClawEnvConfig) => openclawApi.setEnv(env),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: openclawKeys.env });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save tools config. Invalidates tools query on success.
|
||||
* Toast notifications are handled by the component.
|
||||
*/
|
||||
export function useSaveOpenClawTools() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (tools: OpenClawToolsConfig) => openclawApi.setTools(tools),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: openclawKeys.tools });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save agents.defaults config. Invalidates both agentsDefaults and defaultModel
|
||||
* queries on success (since changing agents.defaults may affect the default model).
|
||||
* Toast notifications are handled by the component.
|
||||
*/
|
||||
export function useSaveOpenClawAgentsDefaults() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (defaults: OpenClawAgentsDefaults) =>
|
||||
openclawApi.setAgentsDefaults(defaults),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: openclawKeys.agentsDefaults });
|
||||
queryClient.invalidateQueries({ queryKey: openclawKeys.defaultModel });
|
||||
},
|
||||
});
|
||||
}
|
||||
+107
-11
@@ -2,8 +2,14 @@ import { useCallback } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { providersApi, settingsApi, type AppId } from "@/lib/api";
|
||||
import type { Provider, UsageScript } from "@/types";
|
||||
import { providersApi, settingsApi, openclawApi, type AppId } from "@/lib/api";
|
||||
import type {
|
||||
Provider,
|
||||
UsageScript,
|
||||
OpenClawProviderConfig,
|
||||
OpenClawDefaultModel,
|
||||
} from "@/types";
|
||||
import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets";
|
||||
import {
|
||||
useAddProviderMutation,
|
||||
useUpdateProviderMutation,
|
||||
@@ -11,6 +17,7 @@ import {
|
||||
useSwitchProviderMutation,
|
||||
} from "@/lib/query";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { openclawKeys } from "@/hooks/useOpenClaw";
|
||||
|
||||
/**
|
||||
* Hook for managing provider actions (add, update, delete, switch)
|
||||
@@ -54,10 +61,55 @@ export function useProviderActions(activeApp: AppId) {
|
||||
|
||||
// 添加供应商
|
||||
const addProvider = useCallback(
|
||||
async (provider: Omit<Provider, "id"> & { providerKey?: string }) => {
|
||||
async (
|
||||
provider: Omit<Provider, "id"> & {
|
||||
providerKey?: string;
|
||||
suggestedDefaults?: OpenClawSuggestedDefaults;
|
||||
},
|
||||
) => {
|
||||
await addProviderMutation.mutateAsync(provider);
|
||||
|
||||
// OpenClaw: register models to allowlist after adding provider
|
||||
if (activeApp === "openclaw" && provider.suggestedDefaults) {
|
||||
const { model, modelCatalog } = provider.suggestedDefaults;
|
||||
let modelsRegistered = false;
|
||||
|
||||
try {
|
||||
// 1. Merge model catalog (allowlist)
|
||||
if (modelCatalog && Object.keys(modelCatalog).length > 0) {
|
||||
const existingCatalog = (await openclawApi.getModelCatalog()) || {};
|
||||
const mergedCatalog = { ...existingCatalog, ...modelCatalog };
|
||||
await openclawApi.setModelCatalog(mergedCatalog);
|
||||
modelsRegistered = true;
|
||||
}
|
||||
|
||||
// 2. Set default model (only if not already set)
|
||||
if (model) {
|
||||
const existingDefault = await openclawApi.getDefaultModel();
|
||||
if (!existingDefault?.primary) {
|
||||
await openclawApi.setDefaultModel(model);
|
||||
}
|
||||
}
|
||||
|
||||
// Show success toast if models were registered
|
||||
if (modelsRegistered) {
|
||||
toast.success(
|
||||
t("notifications.openclawModelsRegistered", {
|
||||
defaultValue: "模型已注册到 /model 列表",
|
||||
}),
|
||||
{ closeButton: true },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Log warning but don't block main flow - provider config is already saved
|
||||
console.warn(
|
||||
"[OpenClaw] Failed to register models to allowlist:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[addProviderMutation],
|
||||
[addProviderMutation, activeApp, t],
|
||||
);
|
||||
|
||||
// 更新供应商
|
||||
@@ -104,13 +156,15 @@ export function useProviderActions(activeApp: AppId) {
|
||||
);
|
||||
} else {
|
||||
// 普通供应商:显示切换成功
|
||||
// OpenCode: show "added to config" message instead of "switched"
|
||||
const messageKey =
|
||||
activeApp === "opencode"
|
||||
? "notifications.addToConfigSuccess"
|
||||
: "notifications.switchSuccess";
|
||||
const defaultMessage =
|
||||
activeApp === "opencode" ? "已添加到配置" : "切换成功!";
|
||||
// OpenCode/OpenClaw: show "added to config" message instead of "switched"
|
||||
const isMultiProviderApp =
|
||||
activeApp === "opencode" || activeApp === "openclaw";
|
||||
const messageKey = isMultiProviderApp
|
||||
? "notifications.addToConfigSuccess"
|
||||
: "notifications.switchSuccess";
|
||||
const defaultMessage = isMultiProviderApp
|
||||
? "已添加到配置"
|
||||
: "切换成功!";
|
||||
|
||||
toast.success(t(messageKey, { defaultValue: defaultMessage }), {
|
||||
closeButton: true,
|
||||
@@ -170,12 +224,54 @@ export function useProviderActions(activeApp: AppId) {
|
||||
[activeApp, queryClient, t],
|
||||
);
|
||||
|
||||
// Set provider as default model (OpenClaw only)
|
||||
const setAsDefaultModel = useCallback(
|
||||
async (provider: Provider) => {
|
||||
const config = provider.settingsConfig as OpenClawProviderConfig;
|
||||
if (!config.models || config.models.length === 0) {
|
||||
toast.error(
|
||||
t("notifications.openclawNoModels", {
|
||||
defaultValue: "该供应商没有配置模型",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const model: OpenClawDefaultModel = {
|
||||
primary: `${provider.id}/${config.models[0].id}`,
|
||||
fallbacks: config.models.slice(1).map((m) => `${provider.id}/${m.id}`),
|
||||
};
|
||||
|
||||
try {
|
||||
await openclawApi.setDefaultModel(model);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: openclawKeys.defaultModel,
|
||||
});
|
||||
toast.success(
|
||||
t("notifications.openclawDefaultModelSet", {
|
||||
defaultValue: "已设为默认模型",
|
||||
}),
|
||||
{ closeButton: true },
|
||||
);
|
||||
} catch (error) {
|
||||
const detail =
|
||||
extractErrorMessage(error) ||
|
||||
t("notifications.openclawDefaultModelSetFailed", {
|
||||
defaultValue: "设置默认模型失败",
|
||||
});
|
||||
toast.error(detail);
|
||||
}
|
||||
},
|
||||
[queryClient, t],
|
||||
);
|
||||
|
||||
return {
|
||||
addProvider,
|
||||
updateProvider,
|
||||
switchProvider,
|
||||
deleteProvider,
|
||||
saveUsageScript,
|
||||
setAsDefaultModel,
|
||||
isLoading:
|
||||
addProviderMutation.isPending ||
|
||||
updateProviderMutation.isPending ||
|
||||
|
||||
@@ -135,9 +135,10 @@ export function useSettings(): UseSettingsResult {
|
||||
const sanitizedOpencodeDir = sanitizeDir(
|
||||
mergedSettings.opencodeConfigDir,
|
||||
);
|
||||
const { webdavSync: _ignoredWebdavSync, ...restSettings } = mergedSettings;
|
||||
|
||||
const payload: Settings = {
|
||||
...mergedSettings,
|
||||
...restSettings,
|
||||
claudeConfigDir: sanitizedClaudeDir,
|
||||
codexConfigDir: sanitizedCodexDir,
|
||||
geminiConfigDir: sanitizedGeminiDir,
|
||||
@@ -251,9 +252,10 @@ export function useSettings(): UseSettingsResult {
|
||||
const previousCodexDir = sanitizeDir(data?.codexConfigDir);
|
||||
const previousGeminiDir = sanitizeDir(data?.geminiConfigDir);
|
||||
const previousOpencodeDir = sanitizeDir(data?.opencodeConfigDir);
|
||||
const { webdavSync: _ignoredWebdavSync, ...restSettings } = mergedSettings;
|
||||
|
||||
const payload: Settings = {
|
||||
...mergedSettings,
|
||||
...restSettings,
|
||||
claudeConfigDir: sanitizedClaudeDir,
|
||||
codexConfigDir: sanitizedCodexDir,
|
||||
geminiConfigDir: sanitizedGeminiDir,
|
||||
|
||||
+278
-12
@@ -88,6 +88,8 @@
|
||||
"addOpenCodeProvider": "Add OpenCode Provider",
|
||||
"addToConfig": "Add",
|
||||
"removeFromConfig": "Remove",
|
||||
"setAsDefault": "Enable",
|
||||
"isDefault": "Default",
|
||||
"inConfig": "Added",
|
||||
"addProviderHint": "Fill in the information to quickly switch providers in the list.",
|
||||
"editClaudeProvider": "Edit Claude Code Provider",
|
||||
@@ -162,7 +164,11 @@
|
||||
"settingsSaved": "Settings saved",
|
||||
"settingsSaveFailed": "Failed to save settings: {{error}}",
|
||||
"openAIChatFormatHint": "This provider uses OpenAI Chat format and requires the proxy service to be enabled",
|
||||
"openLinkFailed": "Failed to open link"
|
||||
"openLinkFailed": "Failed to open link",
|
||||
"openclawModelsRegistered": "Models have been registered to /model list",
|
||||
"openclawDefaultModelSet": "Set as default model",
|
||||
"openclawDefaultModelSetFailed": "Failed to set default model",
|
||||
"openclawNoModels": "No models configured"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "Delete Provider",
|
||||
@@ -214,7 +220,9 @@
|
||||
"requestGroup": "Request Rectification",
|
||||
"responseGroup": "Response Rectification",
|
||||
"thinkingSignature": "Thinking Signature Rectification",
|
||||
"thinkingSignatureDescription": "Automatically fix Claude API errors caused by thinking signature validation failures"
|
||||
"thinkingSignatureDescription": "When an Anthropic-type provider returns thinking signature incompatibility or illegal request errors, automatically removes incompatible thinking-related blocks and retries once with the same provider",
|
||||
"thinkingBudget": "Thinking Budget Rectification",
|
||||
"thinkingBudgetDescription": "When an Anthropic-type provider returns budget_tokens constraint errors (such as at least 1024), automatically normalizes thinking to enabled, sets thinking budget to 32000, and raises max_tokens to 64000 if needed, then retries once"
|
||||
},
|
||||
"logConfig": {
|
||||
"title": "Log Management",
|
||||
@@ -264,6 +272,82 @@
|
||||
"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",
|
||||
"autoSync": "Auto Sync",
|
||||
"autoSyncHint": "When enabled, each database change triggers an automatic WebDAV upload.",
|
||||
"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}}",
|
||||
"autoSyncFailedToast": "Auto sync failed: {{error}}",
|
||||
"download": "Download from Cloud",
|
||||
"downloading": "Downloading...",
|
||||
"downloadSuccess": "Downloaded and restored from WebDAV",
|
||||
"downloadFailed": "Download failed: {{error}}",
|
||||
"lastSync": "Last sync: {{time}}",
|
||||
"autoSyncLastErrorTitle": "Last auto sync failed",
|
||||
"autoSyncLastErrorHint": "Please check network or WebDAV settings. Auto sync will retry on future changes.",
|
||||
"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",
|
||||
@@ -405,7 +489,8 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw"
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "Session Manager",
|
||||
@@ -488,7 +573,6 @@
|
||||
"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!)",
|
||||
@@ -659,6 +743,38 @@
|
||||
"modelOptionKeyPlaceholder": "provider",
|
||||
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
|
||||
},
|
||||
"openclaw": {
|
||||
"providerKey": "Provider Key",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
|
||||
"providerKeyRequired": "Provider key is required",
|
||||
"providerKeyDuplicate": "This key is already in use",
|
||||
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
|
||||
"apiProtocol": "API Protocol",
|
||||
"selectProtocol": "Select API Protocol",
|
||||
"apiProtocolHint": "Select the protocol type compatible with the provider's API. Most providers use OpenAI Completions format.",
|
||||
"baseUrl": "API Endpoint",
|
||||
"baseUrlHint": "The provider's API endpoint address.",
|
||||
"models": "Models",
|
||||
"addModel": "Add Model",
|
||||
"noModels": "No models configured. Click Add Model to configure available models.",
|
||||
"modelId": "Model ID",
|
||||
"modelIdPlaceholder": "claude-3-sonnet",
|
||||
"modelName": "Display Name",
|
||||
"modelNamePlaceholder": "Claude 3 Sonnet",
|
||||
"contextWindow": "Context Window",
|
||||
"maxTokens": "Max Output Tokens",
|
||||
"reasoning": "Reasoning Mode",
|
||||
"reasoningOn": "Enabled",
|
||||
"reasoningOff": "Disabled",
|
||||
"inputCost": "Input Cost ($/M tokens)",
|
||||
"outputCost": "Output Cost ($/M tokens)",
|
||||
"advancedOptions": "Advanced Options",
|
||||
"cacheReadCost": "Cache Read Cost ($/M tokens)",
|
||||
"cacheWriteCost": "Cache Write Cost ($/M tokens)",
|
||||
"cacheCostHint": "Cache costs are used to calculate Prompt Caching costs. Leave empty if not using caching.",
|
||||
"modelsHint": "Configure the models supported by this provider. Model ID is used for API calls, Display Name for the interface."
|
||||
},
|
||||
"providerPreset": {
|
||||
"label": "Provider Preset",
|
||||
"custom": "Custom Configuration",
|
||||
@@ -854,7 +970,8 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw"
|
||||
}
|
||||
},
|
||||
"userLevelPath": "User-level MCP path",
|
||||
@@ -1027,6 +1144,74 @@
|
||||
"deleteMessage": "Are you sure you want to delete prompt \"{{name}}\"?"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"title": "Workspace Files",
|
||||
"manage": "Workspace",
|
||||
"files": {
|
||||
"agents": "Agent instructions and rules",
|
||||
"soul": "Agent personality and communication style",
|
||||
"user": "User profile and preferences",
|
||||
"identity": "Agent name and avatar",
|
||||
"tools": "Local tool documentation",
|
||||
"memory": "Long-term memory and decisions",
|
||||
"heartbeat": "Heartbeat run checklist",
|
||||
"bootstrap": "First-run bootstrap ritual",
|
||||
"boot": "Gateway reboot checklist"
|
||||
},
|
||||
"editing": "Edit {{filename}}",
|
||||
"saveSuccess": "Saved successfully",
|
||||
"saveFailed": "Failed to save",
|
||||
"loadFailed": "Failed to load"
|
||||
},
|
||||
"openclaw": {
|
||||
"env": {
|
||||
"title": "Environment Variables",
|
||||
"description": "Manage environment variables in openclaw.json (API keys, custom variables, etc.)",
|
||||
"keyPlaceholder": "Variable name",
|
||||
"valuePlaceholder": "Value",
|
||||
"add": "Add Variable",
|
||||
"saveSuccess": "Environment variables saved",
|
||||
"saveFailed": "Failed to save environment variables",
|
||||
"loadFailed": "Failed to load environment variables",
|
||||
"duplicateKey": "Duplicate variable name detected: {{key}}"
|
||||
},
|
||||
"tools": {
|
||||
"title": "Tool Permissions",
|
||||
"description": "Manage tool permissions in openclaw.json (allow/deny lists)",
|
||||
"profile": "Permission Profile",
|
||||
"profiles": {
|
||||
"default": "Default",
|
||||
"strict": "Strict",
|
||||
"permissive": "Permissive",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"allowList": "Allow List",
|
||||
"denyList": "Deny List",
|
||||
"patternPlaceholder": "Tool name or pattern",
|
||||
"addAllow": "Add Allow",
|
||||
"addDeny": "Add Deny",
|
||||
"saveSuccess": "Tool permissions saved",
|
||||
"saveFailed": "Failed to save tool permissions",
|
||||
"loadFailed": "Failed to load tool permissions"
|
||||
},
|
||||
"agents": {
|
||||
"title": "Agents Config",
|
||||
"description": "Manage agents.defaults in openclaw.json (default model, runtime parameters, etc.)",
|
||||
"modelSection": "Model Configuration",
|
||||
"primaryModel": "Primary Model",
|
||||
"primaryModelHint": "Format: provider/model-id",
|
||||
"fallbackModels": "Fallback Models",
|
||||
"fallbackModelsHint": "Comma-separated, ordered by priority",
|
||||
"runtimeSection": "Runtime Parameters",
|
||||
"workspace": "Workspace Path",
|
||||
"timeout": "Timeout (seconds)",
|
||||
"contextTokens": "Context Tokens",
|
||||
"maxConcurrent": "Max Concurrent",
|
||||
"saveSuccess": "Agents config saved",
|
||||
"saveFailed": "Failed to save agents config",
|
||||
"loadFailed": "Failed to load agents config"
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"warning": {
|
||||
"title": "Environment Variable Conflicts Detected",
|
||||
@@ -1169,7 +1354,8 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw"
|
||||
},
|
||||
"installFromZip": {
|
||||
"button": "Install from ZIP",
|
||||
@@ -1572,16 +1758,21 @@
|
||||
"clearWrapped": "(Clear)",
|
||||
"defaultWrapped": "(Default)",
|
||||
"variantPlaceholder": "variant",
|
||||
"selectEnabledModel": "Select enabled model",
|
||||
"selectEnabledModel": "Select configured model",
|
||||
"selectModel": "Select configured model",
|
||||
"recommendedHint": "Recommended: {{model}}",
|
||||
"searchModel": "Search model...",
|
||||
"selectModelFirst": "Select model first",
|
||||
"noEnabledModels": "No enabled models",
|
||||
"noEnabledModels": "No configured models",
|
||||
"noVariantsForModel": "No variants for model",
|
||||
"currentValueNotEnabled": "{{value}} (current value, not enabled)",
|
||||
"currentValueNotEnabled": "{{value}} (current value, not configured)",
|
||||
"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 enabled models available. Configure and enable OpenCode models first.",
|
||||
"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.",
|
||||
"importLocalReplaceSuccess": "Imported local file and replaced Agents/Categories/Other Fields",
|
||||
"importLocalFailed": "Failed to read local file: {{error}}",
|
||||
"agentKeyPlaceholder": "agent key",
|
||||
@@ -1592,7 +1783,7 @@
|
||||
"modelConfiguration": "Model Configuration",
|
||||
"fillRecommended": "Fill Recommended",
|
||||
"configSummary": "{{agents}} agents, {{categories}} categories configured · Click ⚙ for advanced params",
|
||||
"enabledModelsCount": "{{count}} enabled models available",
|
||||
"enabledModelsCount": "{{count}} configured models available",
|
||||
"source": "from:",
|
||||
"otherFieldsJson": "Other Fields (JSON)",
|
||||
"searchOrType": "Search or type custom value...",
|
||||
@@ -1618,6 +1809,81 @@
|
||||
"advancedExperimental": "Experimental Features",
|
||||
"advancedBackgroundTask": "Background Tasks",
|
||||
"advancedBrowserAutomation": "Browser Automation",
|
||||
"advancedClaudeCode": "Claude Code"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
+278
-12
@@ -88,6 +88,8 @@
|
||||
"addOpenCodeProvider": "OpenCode プロバイダーを追加",
|
||||
"addToConfig": "追加",
|
||||
"removeFromConfig": "削除",
|
||||
"setAsDefault": "有効化",
|
||||
"isDefault": "デフォルト",
|
||||
"inConfig": "追加済み",
|
||||
"addProviderHint": "一覧にすばやく切り替えられるよう、ここに情報を入力してください。",
|
||||
"editClaudeProvider": "Claude Code プロバイダーを編集",
|
||||
@@ -162,7 +164,11 @@
|
||||
"settingsSaved": "設定を保存しました",
|
||||
"settingsSaveFailed": "設定の保存に失敗しました: {{error}}",
|
||||
"openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です",
|
||||
"openLinkFailed": "リンクを開けませんでした"
|
||||
"openLinkFailed": "リンクを開けませんでした",
|
||||
"openclawModelsRegistered": "モデルが /model リストに登録されました",
|
||||
"openclawDefaultModelSet": "デフォルトモデルに設定しました",
|
||||
"openclawDefaultModelSetFailed": "デフォルトモデルの設定に失敗しました",
|
||||
"openclawNoModels": "モデルが設定されていません"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "プロバイダーを削除",
|
||||
@@ -214,7 +220,9 @@
|
||||
"requestGroup": "リクエスト整流",
|
||||
"responseGroup": "レスポンス整流",
|
||||
"thinkingSignature": "Thinking 署名整流",
|
||||
"thinkingSignatureDescription": "Claude API の thinking 署名検証エラーを自動修正"
|
||||
"thinkingSignatureDescription": "Anthropic タイプのプロバイダーが thinking 署名の非互換性や不正なリクエストエラーを返した場合、互換性のない thinking 関連ブロックを自動削除し、同じプロバイダーで 1 回リトライします",
|
||||
"thinkingBudget": "Thinking Budget 整流",
|
||||
"thinkingBudgetDescription": "Anthropic タイプのプロバイダーが budget_tokens 制約エラー(例: 1024 以上)を返した場合、thinking を enabled に正規化し、thinking 予算を 32000 に設定し、必要に応じて max_tokens を 64000 に引き上げて 1 回リトライします"
|
||||
},
|
||||
"logConfig": {
|
||||
"title": "ログ管理",
|
||||
@@ -264,6 +272,82 @@
|
||||
"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": "同期プロファイル名",
|
||||
"autoSync": "自動同期",
|
||||
"autoSyncHint": "有効にすると、データベース変更のたびに WebDAV へ自動アップロードします。",
|
||||
"test": "接続テスト",
|
||||
"testing": "テスト中...",
|
||||
"testSuccess": "接続成功",
|
||||
"testFailed": "接続失敗:{{error}}",
|
||||
"save": "設定を保存",
|
||||
"saving": "保存中...",
|
||||
"saveFailed": "設定の保存に失敗しました:{{error}}",
|
||||
"upload": "クラウドにアップロード",
|
||||
"uploading": "アップロード中...",
|
||||
"uploadSuccess": "WebDAV にアップロードしました",
|
||||
"uploadFailed": "アップロードに失敗しました:{{error}}",
|
||||
"autoSyncFailedToast": "自動同期に失敗しました:{{error}}",
|
||||
"download": "クラウドからダウンロード",
|
||||
"downloading": "ダウンロード中...",
|
||||
"downloadSuccess": "WebDAV からダウンロード・復元しました",
|
||||
"downloadFailed": "ダウンロードに失敗しました:{{error}}",
|
||||
"lastSync": "前回の同期:{{time}}",
|
||||
"autoSyncLastErrorTitle": "前回の自動同期に失敗しました",
|
||||
"autoSyncLastErrorHint": "ネットワークまたは WebDAV 設定を確認してください。次回の変更時に自動再試行されます。",
|
||||
"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",
|
||||
@@ -405,7 +489,8 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw"
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "セッション管理",
|
||||
@@ -488,7 +573,6 @@
|
||||
"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 が月額 $2(80% OFF)",
|
||||
@@ -659,6 +743,38 @@
|
||||
"modelOptionKeyPlaceholder": "provider",
|
||||
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
|
||||
},
|
||||
"openclaw": {
|
||||
"providerKey": "プロバイダーキー",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。",
|
||||
"providerKeyRequired": "プロバイダーキーを入力してください",
|
||||
"providerKeyDuplicate": "このキーは既に使用されています",
|
||||
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。",
|
||||
"apiProtocol": "API プロトコル",
|
||||
"selectProtocol": "API プロトコルを選択",
|
||||
"apiProtocolHint": "プロバイダーの API と互換性のあるプロトコルタイプを選択してください。ほとんどのプロバイダーは OpenAI Completions 形式を使用します。",
|
||||
"baseUrl": "API エンドポイント",
|
||||
"baseUrlHint": "プロバイダーの API エンドポイントアドレス。",
|
||||
"models": "モデル一覧",
|
||||
"addModel": "モデルを追加",
|
||||
"noModels": "モデルが設定されていません。「モデルを追加」をクリックして利用可能なモデルを設定してください。",
|
||||
"modelId": "モデル ID",
|
||||
"modelIdPlaceholder": "claude-3-sonnet",
|
||||
"modelName": "表示名",
|
||||
"modelNamePlaceholder": "Claude 3 Sonnet",
|
||||
"contextWindow": "コンテキストウィンドウ",
|
||||
"maxTokens": "最大出力トークン数",
|
||||
"reasoning": "推論モード",
|
||||
"reasoningOn": "有効",
|
||||
"reasoningOff": "無効",
|
||||
"inputCost": "入力コスト ($/M トークン)",
|
||||
"outputCost": "出力コスト ($/M トークン)",
|
||||
"advancedOptions": "詳細オプション",
|
||||
"cacheReadCost": "キャッシュ読取コスト ($/M トークン)",
|
||||
"cacheWriteCost": "キャッシュ書込コスト ($/M トークン)",
|
||||
"cacheCostHint": "キャッシュコストは Prompt Caching のコスト計算に使用されます。キャッシュを使用しない場合は空欄のままにしてください。",
|
||||
"modelsHint": "このプロバイダーがサポートするモデルを設定します。モデル ID は API 呼び出しに、表示名はインターフェースに使用されます。"
|
||||
},
|
||||
"providerPreset": {
|
||||
"label": "プロバイダータイプ",
|
||||
"custom": "カスタム設定",
|
||||
@@ -854,7 +970,8 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw"
|
||||
}
|
||||
},
|
||||
"userLevelPath": "ユーザーレベルの MCP パス",
|
||||
@@ -1027,6 +1144,74 @@
|
||||
"deleteMessage": "プロンプト「{{name}}」を削除してもよろしいですか?"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"title": "ワークスペースファイル",
|
||||
"manage": "ワークスペース",
|
||||
"files": {
|
||||
"agents": "エージェントの操作指示とルール",
|
||||
"soul": "エージェントの人格とコミュニケーションスタイル",
|
||||
"user": "ユーザープロファイルと設定",
|
||||
"identity": "エージェントの名前とアバター",
|
||||
"tools": "ローカルツールドキュメント",
|
||||
"memory": "長期記憶と意思決定記録",
|
||||
"heartbeat": "ハートビート実行チェックリスト",
|
||||
"bootstrap": "初回実行ブートストラップ",
|
||||
"boot": "ゲートウェイ再起動チェックリスト"
|
||||
},
|
||||
"editing": "{{filename}} を編集",
|
||||
"saveSuccess": "保存しました",
|
||||
"saveFailed": "保存に失敗しました",
|
||||
"loadFailed": "読み込みに失敗しました"
|
||||
},
|
||||
"openclaw": {
|
||||
"env": {
|
||||
"title": "環境変数",
|
||||
"description": "openclaw.json の環境変数設定を管理(APIキー、カスタム変数など)",
|
||||
"keyPlaceholder": "変数名",
|
||||
"valuePlaceholder": "値",
|
||||
"add": "変数を追加",
|
||||
"saveSuccess": "環境変数を保存しました",
|
||||
"saveFailed": "環境変数の保存に失敗しました",
|
||||
"loadFailed": "環境変数の読み込みに失敗しました",
|
||||
"duplicateKey": "重複する変数名が検出されました: {{key}}"
|
||||
},
|
||||
"tools": {
|
||||
"title": "ツール権限",
|
||||
"description": "openclaw.json のツール権限設定を管理(許可/拒否リスト)",
|
||||
"profile": "権限プロファイル",
|
||||
"profiles": {
|
||||
"default": "デフォルト",
|
||||
"strict": "厳格",
|
||||
"permissive": "寛容",
|
||||
"custom": "カスタム"
|
||||
},
|
||||
"allowList": "許可リスト",
|
||||
"denyList": "拒否リスト",
|
||||
"patternPlaceholder": "ツール名またはパターン",
|
||||
"addAllow": "許可を追加",
|
||||
"addDeny": "拒否を追加",
|
||||
"saveSuccess": "ツール権限を保存しました",
|
||||
"saveFailed": "ツール権限の保存に失敗しました",
|
||||
"loadFailed": "ツール権限の読み込みに失敗しました"
|
||||
},
|
||||
"agents": {
|
||||
"title": "Agents 設定",
|
||||
"description": "openclaw.json の agents.defaults 設定を管理(デフォルトモデル、ランタイムパラメータなど)",
|
||||
"modelSection": "モデル設定",
|
||||
"primaryModel": "プライマリモデル",
|
||||
"primaryModelHint": "形式: provider/model-id",
|
||||
"fallbackModels": "フォールバックモデル",
|
||||
"fallbackModelsHint": "カンマ区切り、優先度順",
|
||||
"runtimeSection": "ランタイムパラメータ",
|
||||
"workspace": "ワークスペースパス",
|
||||
"timeout": "タイムアウト(秒)",
|
||||
"contextTokens": "コンテキストトークン数",
|
||||
"maxConcurrent": "最大同時実行数",
|
||||
"saveSuccess": "Agents 設定を保存しました",
|
||||
"saveFailed": "Agents 設定の保存に失敗しました",
|
||||
"loadFailed": "Agents 設定の読み込みに失敗しました"
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"warning": {
|
||||
"title": "競合する環境変数を検出しました",
|
||||
@@ -1167,7 +1352,8 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw"
|
||||
},
|
||||
"installFromZip": {
|
||||
"button": "ZIP からインストール",
|
||||
@@ -1553,16 +1739,21 @@
|
||||
"clearWrapped": "(クリア)",
|
||||
"defaultWrapped": "(デフォルト)",
|
||||
"variantPlaceholder": "variant",
|
||||
"selectEnabledModel": "有効なモデルを選択",
|
||||
"selectEnabledModel": "設定済みモデルを選択",
|
||||
"selectModel": "設定済みモデルを選択",
|
||||
"recommendedHint": "推奨: {{model}}",
|
||||
"searchModel": "モデルを検索...",
|
||||
"selectModelFirst": "先にモデルを選択",
|
||||
"noEnabledModels": "有効なモデルがありません",
|
||||
"noEnabledModels": "設定済みモデルがありません",
|
||||
"noVariantsForModel": "このモデルには思考レベルがありません",
|
||||
"currentValueNotEnabled": "{{value}} (現在値・未有効)",
|
||||
"currentValueNotEnabled": "{{value}} (現在値・未設定)",
|
||||
"currentValueUnavailable": "{{value}} (現在値・利用不可)",
|
||||
"advancedLabel": "詳細",
|
||||
"advancedJsonInvalid": "詳細 JSON が不正です",
|
||||
"advancedJsonHint": "temperature, top_p, budgetTokens, prompt_append, permission など。空欄でデフォルトを使用します",
|
||||
"noEnabledModelsWarning": "利用可能な有効モデルがありません。先に OpenCode モデルを有効化してください。",
|
||||
"noEnabledModelsWarning": "利用可能な設定済みモデルがありません。先に OpenCode モデルを設定してください。",
|
||||
"modelSourcePartialWarning": "一部プロバイダーのモデル設定が不正なため、候補から除外しました。",
|
||||
"modelSourceFallbackWarning": "live プロバイダー状態の取得に失敗したため、設定済みプロバイダーへフォールバックしました。",
|
||||
"importLocalReplaceSuccess": "ローカルファイルから読み込み、Agents/Categories/Other Fields を置き換えました",
|
||||
"importLocalFailed": "ローカルファイルの読み込みに失敗しました: {{error}}",
|
||||
"agentKeyPlaceholder": "agent キー",
|
||||
@@ -1573,7 +1764,7 @@
|
||||
"modelConfiguration": "モデル設定",
|
||||
"fillRecommended": "推奨を入力",
|
||||
"configSummary": "{{agents}} 個の Agent、{{categories}} 個の Category を設定済み · ⚙ で詳細を展開",
|
||||
"enabledModelsCount": "有効モデル {{count}} 件",
|
||||
"enabledModelsCount": "設定済みモデル {{count}} 件",
|
||||
"source": "出典:",
|
||||
"otherFieldsJson": "その他のフィールド (JSON)",
|
||||
"searchOrType": "検索またはカスタム値を入力...",
|
||||
@@ -1599,6 +1790,81 @@
|
||||
"advancedExperimental": "実験的機能",
|
||||
"advancedBackgroundTask": "バックグラウンドタスク",
|
||||
"advancedBrowserAutomation": "ブラウザ自動化",
|
||||
"advancedClaudeCode": "Claude Code"
|
||||
"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": "このプリセットの推奨デフォルトモデル設定を使用します"
|
||||
}
|
||||
}
|
||||
|
||||
+279
-13
@@ -88,6 +88,8 @@
|
||||
"addOpenCodeProvider": "添加 OpenCode 供应商",
|
||||
"addToConfig": "添加",
|
||||
"removeFromConfig": "移除",
|
||||
"setAsDefault": "启用",
|
||||
"isDefault": "默认",
|
||||
"inConfig": "已添加",
|
||||
"addProviderHint": "填写信息后即可在列表中快速切换供应商。",
|
||||
"editClaudeProvider": "编辑 Claude Code 供应商",
|
||||
@@ -162,7 +164,11 @@
|
||||
"settingsSaved": "设置已保存",
|
||||
"settingsSaveFailed": "保存设置失败:{{error}}",
|
||||
"openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用",
|
||||
"openLinkFailed": "链接打开失败"
|
||||
"openLinkFailed": "链接打开失败",
|
||||
"openclawModelsRegistered": "模型已注册到 /model 列表",
|
||||
"openclawDefaultModelSet": "已设为默认模型",
|
||||
"openclawDefaultModelSetFailed": "设置默认模型失败",
|
||||
"openclawNoModels": "该供应商没有配置模型"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "删除供应商",
|
||||
@@ -214,7 +220,9 @@
|
||||
"requestGroup": "请求整流",
|
||||
"responseGroup": "响应整流",
|
||||
"thinkingSignature": "Thinking 签名整流",
|
||||
"thinkingSignatureDescription": "自动修复 Claude API 中因 thinking 签名校验失败导致的请求错误"
|
||||
"thinkingSignatureDescription": "当 Anthropic 类型供应商返回 thinking 签名不兼容或非法请求等错误时,自动移除不兼容的 thinking 相关块并对同一供应商重试一次",
|
||||
"thinkingBudget": "Thinking Budget 整流",
|
||||
"thinkingBudgetDescription": "当 Anthropic 类型供应商返回 budget_tokens 约束错误(如至少 1024)时,自动将 thinking 规范为 enabled 并将 budget 设为 32000,同时在需要时将 max_tokens 设为 64000,然后重试一次"
|
||||
},
|
||||
"logConfig": {
|
||||
"title": "日志管理",
|
||||
@@ -264,6 +272,82 @@
|
||||
"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": "同步配置名",
|
||||
"autoSync": "自动同步",
|
||||
"autoSyncHint": "开启后每次数据库变更都会自动上传到 WebDAV。",
|
||||
"test": "测试连接",
|
||||
"testing": "测试中...",
|
||||
"testSuccess": "连接成功",
|
||||
"testFailed": "连接失败:{{error}}",
|
||||
"save": "保存配置",
|
||||
"saving": "保存中...",
|
||||
"saveFailed": "保存配置失败:{{error}}",
|
||||
"upload": "上传到云端",
|
||||
"uploading": "上传中...",
|
||||
"uploadSuccess": "已上传到 WebDAV",
|
||||
"uploadFailed": "上传失败:{{error}}",
|
||||
"autoSyncFailedToast": "自动同步失败:{{error}}",
|
||||
"download": "从云端下载",
|
||||
"downloading": "下载中...",
|
||||
"downloadSuccess": "已从 WebDAV 下载并恢复",
|
||||
"downloadFailed": "下载失败:{{error}}",
|
||||
"lastSync": "上次同步:{{time}}",
|
||||
"autoSyncLastErrorTitle": "上次自动同步失败",
|
||||
"autoSyncLastErrorHint": "请检查网络或 WebDAV 配置,系统会在后续变更时继续自动重试。",
|
||||
"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",
|
||||
@@ -405,7 +489,8 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw"
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "会话管理",
|
||||
@@ -488,7 +573,6 @@
|
||||
"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折优惠!)",
|
||||
@@ -659,6 +743,38 @@
|
||||
"modelOptionKeyPlaceholder": "provider",
|
||||
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
|
||||
},
|
||||
"openclaw": {
|
||||
"providerKey": "供应商标识",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
|
||||
"providerKeyRequired": "请填写供应商标识",
|
||||
"providerKeyDuplicate": "此标识已被使用,请更换",
|
||||
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
|
||||
"apiProtocol": "API 协议",
|
||||
"selectProtocol": "选择 API 协议",
|
||||
"apiProtocolHint": "选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。",
|
||||
"baseUrl": "API 端点",
|
||||
"baseUrlHint": "供应商的 API 端点地址。",
|
||||
"models": "模型列表",
|
||||
"addModel": "添加模型",
|
||||
"noModels": "暂无模型配置。点击添加模型来配置可用模型。",
|
||||
"modelId": "模型 ID",
|
||||
"modelIdPlaceholder": "claude-3-sonnet",
|
||||
"modelName": "显示名称",
|
||||
"modelNamePlaceholder": "Claude 3 Sonnet",
|
||||
"contextWindow": "上下文窗口",
|
||||
"maxTokens": "最大输出 Tokens",
|
||||
"reasoning": "推理模式",
|
||||
"reasoningOn": "启用",
|
||||
"reasoningOff": "关闭",
|
||||
"inputCost": "输入价格 ($/M tokens)",
|
||||
"outputCost": "输出价格 ($/M tokens)",
|
||||
"advancedOptions": "高级选项",
|
||||
"cacheReadCost": "缓存读取价格 ($/M tokens)",
|
||||
"cacheWriteCost": "缓存写入价格 ($/M tokens)",
|
||||
"cacheCostHint": "缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。",
|
||||
"modelsHint": "配置该供应商支持的模型。模型 ID 用于 API 调用,显示名称用于界面展示。"
|
||||
},
|
||||
"providerPreset": {
|
||||
"label": "预设供应商",
|
||||
"custom": "自定义配置",
|
||||
@@ -854,7 +970,8 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw"
|
||||
}
|
||||
},
|
||||
"userLevelPath": "用户级 MCP 配置路径",
|
||||
@@ -1027,6 +1144,74 @@
|
||||
"deleteMessage": "确定要删除提示词 \"{{name}}\" 吗?"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"title": "Workspace 文件管理",
|
||||
"manage": "Workspace",
|
||||
"files": {
|
||||
"agents": "Agent 操作指令和规则",
|
||||
"soul": "Agent 人格和沟通风格",
|
||||
"user": "用户档案和偏好",
|
||||
"identity": "Agent 名称和头像",
|
||||
"tools": "本地工具文档",
|
||||
"memory": "长期记忆和决策记录",
|
||||
"heartbeat": "心跳运行清单",
|
||||
"bootstrap": "首次运行仪式",
|
||||
"boot": "网关重启清单"
|
||||
},
|
||||
"editing": "编辑 {{filename}}",
|
||||
"saveSuccess": "保存成功",
|
||||
"saveFailed": "保存失败",
|
||||
"loadFailed": "读取失败"
|
||||
},
|
||||
"openclaw": {
|
||||
"env": {
|
||||
"title": "环境变量",
|
||||
"description": "管理 openclaw.json 中的环境变量配置(API Key、自定义变量等)",
|
||||
"keyPlaceholder": "变量名",
|
||||
"valuePlaceholder": "值",
|
||||
"add": "添加变量",
|
||||
"saveSuccess": "环境变量已保存",
|
||||
"saveFailed": "保存环境变量失败",
|
||||
"loadFailed": "读取环境变量失败",
|
||||
"duplicateKey": "检测到重复的变量名: {{key}}"
|
||||
},
|
||||
"tools": {
|
||||
"title": "工具权限",
|
||||
"description": "管理 openclaw.json 中的工具权限配置(允许/拒绝列表)",
|
||||
"profile": "权限模式",
|
||||
"profiles": {
|
||||
"default": "默认",
|
||||
"strict": "严格",
|
||||
"permissive": "宽松",
|
||||
"custom": "自定义"
|
||||
},
|
||||
"allowList": "允许列表",
|
||||
"denyList": "拒绝列表",
|
||||
"patternPlaceholder": "工具名称或模式",
|
||||
"addAllow": "添加允许",
|
||||
"addDeny": "添加拒绝",
|
||||
"saveSuccess": "工具权限已保存",
|
||||
"saveFailed": "保存工具权限失败",
|
||||
"loadFailed": "读取工具权限失败"
|
||||
},
|
||||
"agents": {
|
||||
"title": "Agents 配置",
|
||||
"description": "管理 openclaw.json 中的 agents.defaults 配置(默认模型、运行参数等)",
|
||||
"modelSection": "模型配置",
|
||||
"primaryModel": "主模型",
|
||||
"primaryModelHint": "格式:provider/model-id",
|
||||
"fallbackModels": "回退模型",
|
||||
"fallbackModelsHint": "逗号分隔,按优先级排列",
|
||||
"runtimeSection": "运行参数",
|
||||
"workspace": "工作区路径",
|
||||
"timeout": "超时时间(秒)",
|
||||
"contextTokens": "上下文 Token 数",
|
||||
"maxConcurrent": "最大并发数",
|
||||
"saveSuccess": "Agents 配置已保存",
|
||||
"saveFailed": "保存 Agents 配置失败",
|
||||
"loadFailed": "读取 Agents 配置失败"
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"warning": {
|
||||
"title": "检测到系统环境变量冲突",
|
||||
@@ -1169,7 +1354,8 @@
|
||||
"claude": "Claude",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
"opencode": "OpenCode",
|
||||
"openclaw": "OpenClaw"
|
||||
},
|
||||
"installFromZip": {
|
||||
"button": "从 ZIP 安装",
|
||||
@@ -1572,16 +1758,21 @@
|
||||
"clearWrapped": "(清空)",
|
||||
"defaultWrapped": "(默认)",
|
||||
"variantPlaceholder": "思考等级",
|
||||
"selectEnabledModel": "选择已启用模型",
|
||||
"selectEnabledModel": "选择已配置模型",
|
||||
"selectModel": "选择已配置模型",
|
||||
"recommendedHint": "推荐: {{model}}",
|
||||
"searchModel": "搜索模型...",
|
||||
"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 模型",
|
||||
"noEnabledModelsWarning": "当前没有可用的已配置模型,请先配置 OpenCode 模型",
|
||||
"modelSourcePartialWarning": "部分供应商模型配置无效,已自动跳过。",
|
||||
"modelSourceFallbackWarning": "读取 live 供应商状态失败,已回退到已配置供应商列表。",
|
||||
"importLocalReplaceSuccess": "已从本地文件导入并覆盖 Agent/Category/Other Fields",
|
||||
"importLocalFailed": "读取本地文件失败: {{error}}",
|
||||
"agentKeyPlaceholder": "agent 键名",
|
||||
@@ -1592,7 +1783,7 @@
|
||||
"modelConfiguration": "模型配置",
|
||||
"fillRecommended": "填充推荐",
|
||||
"configSummary": "已配置 {{agents}} 个 Agent,{{categories}} 个 Category · 点击 ⚙ 展开高级参数",
|
||||
"enabledModelsCount": "可选已启用模型 {{count}} 个",
|
||||
"enabledModelsCount": "可选已配置模型 {{count}} 个",
|
||||
"source": "来源:",
|
||||
"otherFieldsJson": "其他字段 (JSON)",
|
||||
"searchOrType": "搜索或输入自定义值...",
|
||||
@@ -1618,6 +1809,81 @@
|
||||
"advancedExperimental": "实验性功能",
|
||||
"advancedBackgroundTask": "后台任务",
|
||||
"advancedBrowserAutomation": "浏览器自动化",
|
||||
"advancedClaudeCode": "Claude Code"
|
||||
"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": "使用此预设推荐的默认模型配置"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
File diff suppressed because one or more lines are too long
@@ -247,6 +247,13 @@ 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",
|
||||
|
||||
@@ -7,7 +7,9 @@ export { skillsApi } from "./skills";
|
||||
export { usageApi } from "./usage";
|
||||
export { vscodeApi } from "./vscode";
|
||||
export { proxyApi } from "./proxy";
|
||||
export { openclawApi } from "./openclaw";
|
||||
export { sessionsApi } from "./sessions";
|
||||
export { workspaceApi } from "./workspace";
|
||||
export * as configApi from "./config";
|
||||
export type { ProviderSwitchEvent } from "./providers";
|
||||
export type { Prompt } from "./prompts";
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
OpenClawDefaultModel,
|
||||
OpenClawModelCatalogEntry,
|
||||
OpenClawAgentsDefaults,
|
||||
OpenClawEnvConfig,
|
||||
OpenClawToolsConfig,
|
||||
} from "@/types";
|
||||
|
||||
/**
|
||||
* OpenClaw configuration API
|
||||
*
|
||||
* Manages ~/.openclaw/openclaw.json sections:
|
||||
* - agents.defaults (model, catalog)
|
||||
* - env (environment variables)
|
||||
* - tools (permissions)
|
||||
*/
|
||||
export const openclawApi = {
|
||||
// ============================================================
|
||||
// Agents Configuration
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get default model configuration (agents.defaults.model)
|
||||
*/
|
||||
async getDefaultModel(): Promise<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 });
|
||||
},
|
||||
};
|
||||
@@ -98,6 +98,14 @@ 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");
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
+47
-1
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Settings } from "@/types";
|
||||
import type { Settings, WebDavSyncSettings, RemoteSnapshotInfo } from "@/types";
|
||||
import type { AppId } from "./types";
|
||||
|
||||
export interface ConfigTransferResult {
|
||||
@@ -9,6 +9,15 @@ export interface ConfigTransferResult {
|
||||
backupId?: string;
|
||||
}
|
||||
|
||||
export interface WebDavTestResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface WebDavSyncResult {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
async get(): Promise<Settings> {
|
||||
return await invoke("get_settings");
|
||||
@@ -93,6 +102,42 @@ export const settingsApi = {
|
||||
return await invoke("import_config_from_file", { filePath });
|
||||
},
|
||||
|
||||
// ─── WebDAV v2 sync ───────────────────────────────────────
|
||||
|
||||
async webdavTestConnection(
|
||||
settings: WebDavSyncSettings,
|
||||
preserveEmptyPassword = true,
|
||||
): Promise<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;
|
||||
@@ -155,6 +200,7 @@ export const settingsApi = {
|
||||
export interface RectifierConfig {
|
||||
enabled: boolean;
|
||||
requestThinkingSignature: boolean;
|
||||
requestThinkingBudget: boolean;
|
||||
}
|
||||
|
||||
export interface LogConfig {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
|
||||
// ========== 类型定义 ==========
|
||||
export type AppType = "claude" | "codex" | "gemini" | "opencode" | "openclaw";
|
||||
|
||||
/** Skill 应用启用状态 */
|
||||
export interface SkillApps {
|
||||
@@ -10,6 +10,7 @@ export interface SkillApps {
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
opencode: boolean;
|
||||
openclaw: boolean;
|
||||
}
|
||||
|
||||
/** 已安装的 Skill(v3.10.0+ 统一结构) */
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// 前端统一使用 AppId 作为应用标识(与后端命令参数 `app` 一致)
|
||||
export type AppId = "claude" | "codex" | "gemini" | "opencode";
|
||||
export type AppId = "claude" | "codex" | "gemini" | "opencode" | "openclaw";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user