mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ab9cd1371 | |||
| 0c6d429fdb | |||
| b9afcbf2b0 | |||
| bf0be82112 | |||
| c7b8e6debc | |||
| 6634a95c38 | |||
| 1580e87303 | |||
| ea29bc20a0 | |||
| 0ef8c127c5 | |||
| d66f196378 | |||
| 580b32dd67 | |||
| 51bf29582d | |||
| aa1231903f | |||
| b8a53f9e36 | |||
| 06cde78945 | |||
| 0d67fba524 | |||
| 3b61fab4b5 | |||
| 982f78af0f | |||
| 9776210da8 | |||
| 188c7af2e4 | |||
| de59facad6 | |||
| 7974650ad8 | |||
| 3f68f78331 | |||
| 40373538d3 | |||
| 11a9bb0c14 | |||
| 9e25ecf475 | |||
| 733605ae5c | |||
| 542d4635c4 | |||
| 4e4a445922 | |||
| d5d7c87fd6 | |||
| 1e1080c813 | |||
| e393dda68e | |||
| 0ecf6891c0 | |||
| cfab768f95 | |||
| 73fea48049 | |||
| e54e4d47ae | |||
| 403c3f0690 | |||
| b3b3c0732a | |||
| 2ff329f6c0 | |||
| 5817c9aa77 | |||
| 6425826e66 | |||
| 2f18764490 | |||
| 7ad5a76c7a | |||
| b1446d0227 | |||
| 3da25e59cd | |||
| 0938bd5e41 | |||
| 62523ee17e | |||
| 0e75193e84 | |||
| 078a3a6a53 | |||
| d52f8855ea | |||
| 628a659c0e | |||
| 86cfa452e9 | |||
| 06d69caf79 | |||
| 58f7be1517 | |||
| 5d48dd7908 | |||
| 0e7e04581d | |||
| 788e138ece | |||
| b692bb4053 |
@@ -15,11 +15,11 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANG
|
||||
|
||||
## ❤️Sponsor
|
||||
|
||||
[](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
|
||||
[](https://bit.ly/3Nue8mA)
|
||||
|
||||
MiniMax-M2.5 is a SOTA large language model designed for real-world productivity. Trained in a diverse range of complex real-world digital working environments, M2.5 builds upon the coding expertise of M2.1 to extend into general office work, reaching fluency in generating and operating Word, Excel, and Powerpoint files, context switching between diverse software environments, and working across different agent and human teams. Scoring 80.2% on SWE-Bench Verified, 51.3% on Multi-SWE-Bench, and 76.3% on BrowseComp, M2.5 is also more token efficient than previous generations, having been trained to optimize its actions and output through planning.
|
||||
MiniMax M2.1 is an open-source, SOTA model built for real-world development and agentic workflows. It delivers top-tier performance on major coding benchmarks such as SWE, VIBE, and Multi-SWE. Powered by a 10B active / 230B total MoE architecture, M2.1 enables faster inference, easier deployment, and even local execution. It excels at coding, navigating digital environments, and handling long, multi-step tasks at scale.
|
||||
|
||||
[Click](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link) to get an exclusive 12% off the MiniMax Coding Plan!
|
||||
[Click](https://bit.ly/3Nue8mA) to get an exclusive 12% off the MiniMax Coding Plan!
|
||||
|
||||
---
|
||||
|
||||
@@ -60,16 +60,6 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
|
||||
<td>Thanks to AICoding.sh for sponsoring this project! AICoding.sh — Global AI Model API Relay Service at Unbeatable Prices! Claude Code at 19% of original price, GPT at just 1%! Trusted by hundreds of enterprises for cost-effective AI services. Supports Claude Code, GPT, Gemini and major domestic models, with enterprise-grade high concurrency, fast invoicing, and 24/7 dedicated technical support. CC Switch users who register via <a href="https://aicoding.sh/i/CCSWITCH">this link</a> get 10% off their first top-up!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&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, and more. All models at 55% of official pricing with auto-failover, smart routing, and unlimited concurrency. Crazyrouter offers an exclusive deal for CC Switch users: register via <a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">this link</a> to get <strong>$2 free credit</strong> instantly, plus enter promo code `CCSWITCH` on your first top-up for an extra <strong>30% bonus credit</strong>! </td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
|
||||
<td>Thanks to SSSAiCode for sponsoring this project! SSSAiCode is a stable and reliable API relay service, dedicated to providing stable, reliable, and affordable Claude and Codex model services, <strong>offering high cost-effective official Claude service at just ¥0.5/$ equivalent</strong>, supporting monthly and pay-as-you-go billing plans with same-day fast invoicing. SSSAiCode offers a special deal for CC Switch users: register via <a href="https://www.sssaicode.com/register?ref=DCP0SM">this link</a> to enjoy $10 extra credit on every top-up!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## Screenshots
|
||||
|
||||
+3
-13
@@ -15,11 +15,11 @@
|
||||
|
||||
## ❤️スポンサー
|
||||
|
||||
[](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
|
||||
[](https://bit.ly/3Nue8mA)
|
||||
|
||||
MiniMax-M2.5 は、実際の生産性向上のために設計された最先端の大規模言語モデルです。多様で複雑な実環境のデジタルワークスペースでトレーニングされた M2.5 は、M2.1 のコーディング能力をベースに一般的なオフィス業務へと拡張し、Word・Excel・PowerPoint ファイルの生成と操作、多様なソフトウェア環境間のコンテキスト切り替え、異なるエージェントや人間チーム間での協働を流暢にこなします。SWE-Bench Verified で 80.2%、Multi-SWE-Bench で 51.3%、BrowseComp で 76.3% を達成し、計画的な行動と出力の最適化トレーニングにより、前世代よりもトークン効率に優れています。
|
||||
MiniMax M2.1 は、実務開発とエージェントワークフロー向けに構築されたオープンソースの最先端モデルです。100 億のアクティブパラメータ / 2,300 億の総パラメータを持つ MoE アーキテクチャにより、高速な推論、簡単なデプロイ、ローカル実行にも対応します。SWE、VIBE、Multi-SWE などの主要コーディングベンチマークでトップクラスの性能を発揮し、コーディング、デジタル環境のナビゲーション、大規模な多段階タスクの処理に優れています。
|
||||
|
||||
[こちら](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)から MiniMax Coding Plan の限定 12% オフを入手!
|
||||
[こちら](https://bit.ly/3Nue8mA)から MiniMax Coding Plan の限定 12% オフを入手!
|
||||
|
||||
---
|
||||
|
||||
@@ -60,16 +60,6 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
<td>AICoding.sh のご支援に感謝します!AICoding.sh —— グローバル AI モデル API 超お得な中継サービス!Claude Code 81% オフ、GPT 99% オフ!数百社の企業に高コストパフォーマンスの AI サービスを提供。Claude Code、GPT、Gemini および国内主要モデルに対応、エンタープライズ級の高同時接続、迅速な請求書発行、24 時間年中無休の専属テクニカルサポート。<a href="https://aicoding.sh/i/CCSWITCH">こちらのリンク</a>から登録した CC Switch ユーザーは、初回チャージ 10% オフ!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&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 など 300 以上のモデルにアクセス可能。全モデルが公式価格の 55% で利用でき、自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。CC Switch ユーザー向けの限定特典:<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">こちらのリンク</a>から登録すると <strong>$2 の無料クレジット</strong> を即時進呈。さらに初回チャージ時にプロモコード `CCSWITCH` を入力すると <strong>30% のボーナスクレジット</strong> が追加されます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
|
||||
<td>SSSAiCode のご支援に感謝します!SSSAiCode は安定性と信頼性に優れた API 中継サービスで、安定的で信頼性が高く、手頃な価格の Claude・Codex モデルサービスを提供しています。<strong>高コストパフォーマンスの公式 Claude サービスを 0.5¥/$ 換算で提供</strong>、月額制・Paygo など多様な課金方式に対応し、当日の迅速な請求書発行をサポート。CC Switch ユーザー向けの特別特典:<a href="https://www.sssaicode.com/register?ref=DCP0SM">こちらのリンク</a>から登録すると、毎回のチャージで $10 の追加ボーナスを受けられます!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## スクリーンショット
|
||||
|
||||
+1
-11
@@ -17,7 +17,7 @@
|
||||
|
||||
[](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)
|
||||
|
||||
MiniMax M2.5 在编程、工具调用与搜索、办公等核心生产力场景均达到或刷新行业 SOTA,拥有架构师级代码能力与高效任务拆解能力,推理速度较上一代提升 37%、token 消耗更优;100 token/s 连续工作一小时仅需 1 美金,让复杂 Agent 规模化部署经济可行,已在企业多职能场景深度落地,加速全民 Agent 时代到来。
|
||||
MiniMax M2.x 系列模型是面向实际开发与智能体工作流打造的编码模型,M2.1 基于 100 亿激活 / 2300 亿总参的混合专家架构打造,推理更快、部署更便捷且支持本地运行,在 SWE、VIBE、Multi-SWE 等主流代码评测基准中均表现顶尖,擅长代码开发、数字环境适配及规模化处理长链路多步骤任务。
|
||||
|
||||
[点击](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)即可领取 MiniMax Coding Plan 专属 88 折优惠!
|
||||
|
||||
@@ -61,16 +61,6 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
<td>感谢 AICoding.sh 赞助了本项目!AICoding.sh —— 全球大模型 API 超值中转服务!Claude Code 1.9 折,GPT 0.1 折,已为数百家企业提供高性价比 AI 服务。支持 Claude Code、GPT、Gemini 及国内主流模型,企业级高并发、极速开票、7×24 专属技术支持,通过<a href="https://aicoding.sh/i/CCSWITCH">此链接</a> 注册的 CC Switch 用户,首充可享受九折优惠!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&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 等。全部模型低至官方定价的 55%,支持自动故障转移、智能路由和无限并发。Crazyrouter 为 CC Switch 用户提供了专属优惠:通过<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">此链接</a>注册即可获得 <strong>$2 免费额度</strong>,首次充值时输入优惠码 `CCSWITCH` 还可获得额外 <strong>30% 奖励额度</strong>!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
|
||||
<td>感谢 SSSAiCode 赞助了本项目!SSSAiCode 是一家稳定可靠的API中转站,致力于提供稳定、可靠、平价的Claude、CodeX模型服务,<strong>提供高性价比折合0.5¥/$的官方Claude服务</strong>,支持包月、Paygo多种计费方式、支持当日快速开票,SSSAiCode为本软件的用户提供特别优惠,使用<a href="https://www.sssaicode.com/register?ref=DCP0SM">此链接</a>注册每次充值均可享受10$的额外奖励!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 界面预览
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 207 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 181 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 447 KiB |
@@ -54,7 +54,6 @@
|
||||
"@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",
|
||||
|
||||
Generated
-3
@@ -50,9 +50,6 @@ importers:
|
||||
'@radix-ui/react-checkbox':
|
||||
specifier: ^1.3.3
|
||||
version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-collapsible':
|
||||
specifier: ^1.1.12
|
||||
version: 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
||||
Generated
+1
-62
@@ -714,7 +714,6 @@ dependencies = [
|
||||
"futures",
|
||||
"hyper",
|
||||
"indexmap 2.11.4",
|
||||
"json5",
|
||||
"log",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
@@ -728,7 +727,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
@@ -2522,17 +2520,6 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "json5"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_derive",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonptr"
|
||||
version = "0.6.3"
|
||||
@@ -3417,49 +3404,6 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pest"
|
||||
version = "2.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"ucd-trie",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_derive"
|
||||
version = "2.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_generator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_generator"
|
||||
version = "2.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_meta",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_meta"
|
||||
version = "2.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.8.0"
|
||||
@@ -5690,6 +5634,7 @@ version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
|
||||
dependencies = [
|
||||
"indexmap 2.11.4",
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
@@ -5943,12 +5888,6 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "ucd-trie"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.1.0"
|
||||
|
||||
@@ -35,7 +35,7 @@ tauri-plugin-dialog = "2"
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
dirs = "5.0"
|
||||
toml = "0.8"
|
||||
toml = { version = "0.8", features = ["preserve_order"] }
|
||||
toml_edit = "0.22"
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
||||
@@ -57,12 +57,10 @@ url = "2.5"
|
||||
auto-launch = "0.5"
|
||||
once_cell = "1.21.3"
|
||||
base64 = "0.22"
|
||||
rusqlite = { version = "0.31", features = ["bundled", "backup", "hooks"] }
|
||||
rusqlite = { version = "0.31", features = ["bundled", "backup"] }
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
rust_decimal = "1.33"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
sha2 = "0.10"
|
||||
json5 = "0.4"
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
+76
-48
@@ -25,7 +25,6 @@ impl McpApps {
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::OpenClaw => false, // OpenClaw doesn't support MCP
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +35,6 @@ impl McpApps {
|
||||
AppType::Codex => self.codex = enabled,
|
||||
AppType::Gemini => self.gemini = enabled,
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +83,6 @@ impl SkillApps {
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::OpenClaw => false, // OpenClaw doesn't support Skills
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +93,6 @@ impl SkillApps {
|
||||
AppType::Codex => self.codex = enabled,
|
||||
AppType::Gemini => self.gemini = enabled,
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,20 +125,6 @@ impl SkillApps {
|
||||
apps.set_enabled_for(app, true);
|
||||
apps
|
||||
}
|
||||
|
||||
/// 从来源标签列表构建启用状态
|
||||
///
|
||||
/// 标签与 AppType::as_str() 一致时启用对应应用,
|
||||
/// 其他标签(如 "agents", "cc-switch")忽略。
|
||||
pub fn from_labels(labels: &[String]) -> Self {
|
||||
let mut apps = Self::default();
|
||||
for label in labels {
|
||||
if let Ok(app) = label.parse::<AppType>() {
|
||||
apps.set_enabled_for(&app, true);
|
||||
}
|
||||
}
|
||||
apps
|
||||
}
|
||||
}
|
||||
|
||||
/// 已安装的 Skill(v3.10.0+ 统一结构)
|
||||
@@ -189,8 +171,6 @@ pub struct UnmanagedSkill {
|
||||
pub description: Option<String>,
|
||||
/// 在哪些应用目录中发现(如 ["claude", "codex"])
|
||||
pub found_in: Vec<String>,
|
||||
/// 发现路径(首个匹配的完整路径)
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// MCP 服务器定义(v3.7.0 统一结构)
|
||||
@@ -242,9 +222,6 @@ pub struct McpRoot {
|
||||
/// OpenCode MCP 配置(v4.0.0+,实际使用 opencode.json)
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub opencode: McpConfig,
|
||||
/// OpenClaw MCP 配置(v4.1.0+,实际使用 openclaw.json)
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub openclaw: McpConfig,
|
||||
}
|
||||
|
||||
impl Default for McpRoot {
|
||||
@@ -257,7 +234,6 @@ impl Default for McpRoot {
|
||||
codex: McpConfig::default(),
|
||||
gemini: McpConfig::default(),
|
||||
opencode: McpConfig::default(),
|
||||
openclaw: McpConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,8 +256,6 @@ pub struct PromptRoot {
|
||||
pub gemini: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub opencode: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub openclaw: PromptConfig,
|
||||
}
|
||||
|
||||
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
||||
@@ -297,7 +271,6 @@ pub enum AppType {
|
||||
Codex,
|
||||
Gemini,
|
||||
OpenCode,
|
||||
OpenClaw,
|
||||
}
|
||||
|
||||
impl AppType {
|
||||
@@ -307,16 +280,15 @@ impl AppType {
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini",
|
||||
AppType::OpenCode => "opencode",
|
||||
AppType::OpenClaw => "openclaw",
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this app uses additive mode
|
||||
///
|
||||
/// - Switch mode (false): Only the current provider is written to live config (Claude, Codex, Gemini)
|
||||
/// - Additive mode (true): All providers are written to live config (OpenCode, OpenClaw)
|
||||
/// - Additive mode (true): All providers are written to live config (OpenCode)
|
||||
pub fn is_additive_mode(&self) -> bool {
|
||||
matches!(self, AppType::OpenCode | AppType::OpenClaw)
|
||||
matches!(self, AppType::OpenCode)
|
||||
}
|
||||
|
||||
/// Return an iterator over all app types
|
||||
@@ -326,7 +298,6 @@ impl AppType {
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::OpenCode,
|
||||
AppType::OpenClaw,
|
||||
]
|
||||
.into_iter()
|
||||
}
|
||||
@@ -342,16 +313,67 @@ impl FromStr for AppType {
|
||||
"codex" => Ok(AppType::Codex),
|
||||
"gemini" => Ok(AppType::Gemini),
|
||||
"opencode" => Ok(AppType::OpenCode),
|
||||
"openclaw" => Ok(AppType::OpenClaw),
|
||||
other => Err(AppError::localized(
|
||||
"unsupported_app",
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw."),
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode."),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 通用配置片段(按应用分治)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CommonConfigSnippets {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub claude: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub codex: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gemini: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opencode: Option<String>,
|
||||
}
|
||||
|
||||
impl CommonConfigSnippets {
|
||||
/// 检查是否所有字段都为空
|
||||
pub fn is_empty(&self) -> bool {
|
||||
let is_blank = |value: &Option<String>| {
|
||||
value
|
||||
.as_ref()
|
||||
.map(|snippet| snippet.trim().is_empty())
|
||||
.unwrap_or(true)
|
||||
};
|
||||
is_blank(&self.claude)
|
||||
&& is_blank(&self.codex)
|
||||
&& is_blank(&self.gemini)
|
||||
&& is_blank(&self.opencode)
|
||||
}
|
||||
|
||||
/// 获取指定应用的通用配置片段
|
||||
pub fn get(&self, app: &AppType) -> Option<&String> {
|
||||
match app {
|
||||
AppType::Claude => self.claude.as_ref(),
|
||||
AppType::Codex => self.codex.as_ref(),
|
||||
AppType::Gemini => self.gemini.as_ref(),
|
||||
AppType::OpenCode => self.opencode.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置指定应用的通用配置片段
|
||||
pub fn set(&mut self, app: &AppType, snippet: Option<String>) {
|
||||
match app {
|
||||
AppType::Claude => self.claude = snippet,
|
||||
AppType::Codex => self.codex = snippet,
|
||||
AppType::Gemini => self.gemini = snippet,
|
||||
AppType::OpenCode => self.opencode = snippet,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 多应用配置结构(向后兼容)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MultiAppConfig {
|
||||
@@ -369,6 +391,14 @@ pub struct MultiAppConfig {
|
||||
/// Claude Skills 配置
|
||||
#[serde(default)]
|
||||
pub skills: SkillStore,
|
||||
/// 通用配置片段(按应用分治)
|
||||
/// 注意:此字段主要用于从旧版 config.json 迁移数据到数据库
|
||||
/// 迁移成功后会被清空,空时不写入 config.json
|
||||
#[serde(default, skip_serializing_if = "CommonConfigSnippets::is_empty")]
|
||||
pub common_config_snippets: CommonConfigSnippets,
|
||||
/// Claude 通用配置片段(旧字段,用于向后兼容迁移)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub claude_common_config_snippet: Option<String>,
|
||||
}
|
||||
|
||||
fn default_version() -> u32 {
|
||||
@@ -382,7 +412,6 @@ impl Default for MultiAppConfig {
|
||||
apps.insert("codex".to_string(), ProviderManager::default());
|
||||
apps.insert("gemini".to_string(), ProviderManager::default());
|
||||
apps.insert("opencode".to_string(), ProviderManager::default());
|
||||
apps.insert("openclaw".to_string(), ProviderManager::default());
|
||||
|
||||
Self {
|
||||
version: 2,
|
||||
@@ -390,6 +419,8 @@ impl Default for MultiAppConfig {
|
||||
mcp: McpRoot::default(),
|
||||
prompts: PromptRoot::default(),
|
||||
skills: SkillStore::default(),
|
||||
common_config_snippets: CommonConfigSnippets::default(),
|
||||
claude_common_config_snippet: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -483,6 +514,15 @@ impl MultiAppConfig {
|
||||
updated = true;
|
||||
}
|
||||
|
||||
// 迁移通用配置片段:claude_common_config_snippet → common_config_snippets.claude
|
||||
if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() {
|
||||
log::info!(
|
||||
"迁移通用配置:claude_common_config_snippet → common_config_snippets.claude"
|
||||
);
|
||||
config.common_config_snippets.claude = Some(old_claude_snippet);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if updated {
|
||||
log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置...");
|
||||
config.save()?;
|
||||
@@ -531,7 +571,6 @@ impl MultiAppConfig {
|
||||
AppType::Codex => &self.mcp.codex,
|
||||
AppType::Gemini => &self.mcp.gemini,
|
||||
AppType::OpenCode => &self.mcp.opencode,
|
||||
AppType::OpenClaw => &self.mcp.openclaw,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,7 +581,6 @@ impl MultiAppConfig {
|
||||
AppType::Codex => &mut self.mcp.codex,
|
||||
AppType::Gemini => &mut self.mcp.gemini,
|
||||
AppType::OpenCode => &mut self.mcp.opencode,
|
||||
AppType::OpenClaw => &mut self.mcp.openclaw,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,7 +595,6 @@ impl MultiAppConfig {
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
@@ -578,7 +615,6 @@ impl MultiAppConfig {
|
||||
|| !self.prompts.codex.prompts.is_empty()
|
||||
|| !self.prompts.gemini.prompts.is_empty()
|
||||
|| !self.prompts.opencode.prompts.is_empty()
|
||||
|| !self.prompts.openclaw.prompts.is_empty()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -591,7 +627,6 @@ impl MultiAppConfig {
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::OpenCode,
|
||||
AppType::OpenClaw,
|
||||
] {
|
||||
// 复用已有的单应用导入逻辑
|
||||
if Self::auto_import_prompt_if_exists(self, app)? {
|
||||
@@ -662,7 +697,6 @@ impl MultiAppConfig {
|
||||
AppType::Codex => &mut config.prompts.codex.prompts,
|
||||
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
||||
AppType::OpenCode => &mut config.prompts.opencode.prompts,
|
||||
AppType::OpenClaw => &mut config.prompts.openclaw.prompts,
|
||||
};
|
||||
|
||||
prompts.insert(id, prompt);
|
||||
@@ -691,18 +725,12 @@ impl MultiAppConfig {
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
// 收集所有应用的 MCP
|
||||
for app in [
|
||||
AppType::Claude,
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::OpenCode,
|
||||
] {
|
||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||
let old_servers = match app {
|
||||
AppType::Claude => &self.mcp.claude.servers,
|
||||
AppType::Codex => &self.mcp.codex.servers,
|
||||
AppType::Gemini => &self.mcp.gemini.servers,
|
||||
AppType::OpenCode => &self.mcp.opencode.servers,
|
||||
AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip
|
||||
};
|
||||
|
||||
for (id, entry) in old_servers {
|
||||
|
||||
@@ -59,15 +59,6 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
||||
|
||||
Ok(ConfigStatus { exists, path })
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
let config_path = crate::openclaw_config::get_openclaw_config_path();
|
||||
let exists = config_path.exists();
|
||||
let path = crate::openclaw_config::get_openclaw_dir()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(ConfigStatus { exists, path })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +74,6 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
|
||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
|
||||
};
|
||||
|
||||
Ok(dir.to_string_lossy().to_string())
|
||||
@@ -96,7 +86,6 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
|
||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
|
||||
};
|
||||
|
||||
if !config_dir.exists() {
|
||||
@@ -164,6 +153,38 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_claude_common_config_snippet(
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
) -> Result<Option<String>, String> {
|
||||
state
|
||||
.db
|
||||
.get_config_snippet("claude")
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_claude_common_config_snippet(
|
||||
snippet: String,
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
) -> Result<(), String> {
|
||||
if !snippet.trim().is_empty() {
|
||||
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
|
||||
}
|
||||
|
||||
let value = if snippet.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(snippet)
|
||||
};
|
||||
|
||||
state
|
||||
.db
|
||||
.set_config_snippet("claude", value)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_common_config_snippet(
|
||||
app_type: String,
|
||||
@@ -183,11 +204,36 @@ pub async fn set_common_config_snippet(
|
||||
) -> Result<(), String> {
|
||||
if !snippet.trim().is_empty() {
|
||||
match app_type.as_str() {
|
||||
"claude" | "gemini" | "omo" | "omo-slim" => {
|
||||
"claude" => {
|
||||
// 验证 JSON 格式
|
||||
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||
.map_err(invalid_json_format_error)?;
|
||||
}
|
||||
"gemini" => {
|
||||
// 验证 ENV/JSON 格式并拒绝禁用键
|
||||
let validation = crate::config_merge::validate_gemini_common_snippet(&snippet);
|
||||
|
||||
// 如果有禁用键,返回错误
|
||||
if !validation.forbidden_keys_found.is_empty() {
|
||||
return Err(format!(
|
||||
"GEMINI_FORBIDDEN_KEYS:{}",
|
||||
validation.forbidden_keys_found.join(",")
|
||||
));
|
||||
}
|
||||
|
||||
// 如果非空但解析后无有效内容,返回错误
|
||||
if !validation.is_valid {
|
||||
return Err("GEMINI_INVALID_SNIPPET".to_string());
|
||||
}
|
||||
}
|
||||
"codex" => {
|
||||
// TOML 格式,暂不验证(前端验证)
|
||||
}
|
||||
"omo" => {
|
||||
// 验证 JSON 格式
|
||||
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||
.map_err(invalid_json_format_error)?;
|
||||
}
|
||||
"codex" => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -206,28 +252,35 @@ pub async fn set_common_config_snippet(
|
||||
if app_type == "omo"
|
||||
&& state
|
||||
.db
|
||||
.get_current_omo_provider("opencode", "omo")
|
||||
.get_current_omo_provider("opencode")
|
||||
.map_err(|e| e.to_string())?
|
||||
.is_some()
|
||||
{
|
||||
crate::services::OmoService::write_config_to_file(
|
||||
state.inner(),
|
||||
&crate::services::omo::STANDARD,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
if app_type == "omo-slim"
|
||||
&& state
|
||||
.db
|
||||
.get_current_omo_provider("opencode", "omo-slim")
|
||||
.map_err(|e| e.to_string())?
|
||||
.is_some()
|
||||
{
|
||||
crate::services::OmoService::write_config_to_file(
|
||||
state.inner(),
|
||||
&crate::services::omo::SLIM,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
crate::services::OmoService::write_config_to_file(state.inner())
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn extract_common_config_snippet(
|
||||
appType: String,
|
||||
settingsConfig: Option<String>,
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
) -> Result<String, String> {
|
||||
let app = AppType::from_str(&appType).map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(settings_config) = settingsConfig.filter(|s| !s.trim().is_empty()) {
|
||||
let settings: serde_json::Value =
|
||||
serde_json::from_str(&settings_config).map_err(invalid_json_format_error)?;
|
||||
|
||||
return crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(
|
||||
app,
|
||||
&settings,
|
||||
)
|
||||
.map_err(|e| e.to_string());
|
||||
}
|
||||
|
||||
crate::services::provider::ProviderService::extract_common_config_snippet(&state, app)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -5,17 +5,10 @@ use std::path::PathBuf;
|
||||
use tauri::State;
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
use crate::commands::sync_support::{
|
||||
post_sync_warning_from_result, run_post_import_sync, success_payload_with_warning,
|
||||
};
|
||||
use crate::database::backup::BackupEntry;
|
||||
use crate::database::Database;
|
||||
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(
|
||||
@@ -44,15 +37,27 @@ pub async fn import_config_from_file(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Value, String> {
|
||||
let db = state.db.clone();
|
||||
let db_for_sync = db.clone();
|
||||
let db_for_state = db.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let path_buf = PathBuf::from(&filePath);
|
||||
let backup_id = db.import_sql(&path_buf)?;
|
||||
let warning = post_sync_warning_from_result(Ok(run_post_import_sync(db_for_sync)));
|
||||
if let Some(msg) = warning.as_ref() {
|
||||
log::warn!("[Import] post-import sync warning: {msg}");
|
||||
|
||||
// 导入后同步当前供应商到各自的 live 配置
|
||||
let app_state = AppState::new(db_for_state);
|
||||
if let Err(err) = ProviderService::sync_current_to_live(&app_state) {
|
||||
log::warn!("导入后同步 live 配置失败: {err}");
|
||||
}
|
||||
Ok::<_, AppError>(success_payload_with_warning(backup_id, warning))
|
||||
|
||||
// 重新加载设置到内存缓存,确保导入的设置生效
|
||||
if let Err(err) = crate::settings::reload_settings() {
|
||||
log::warn!("导入后重载设置失败: {err}");
|
||||
}
|
||||
|
||||
Ok::<_, AppError>(json!({
|
||||
"success": true,
|
||||
"message": "SQL imported successfully",
|
||||
"backupId": backup_id
|
||||
}))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("导入配置失败: {e}"))?
|
||||
@@ -75,8 +80,6 @@ pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<V
|
||||
.map_err(|e: AppError| e.to_string())
|
||||
}
|
||||
|
||||
// ─── File dialogs ────────────────────────────────────────────
|
||||
|
||||
/// 保存文件对话框
|
||||
#[tauri::command]
|
||||
pub async fn save_file_dialog<R: tauri::Runtime>(
|
||||
@@ -120,33 +123,3 @@ pub async fn open_zip_file_dialog<R: tauri::Runtime>(
|
||||
|
||||
Ok(result.map(|p| p.to_string()))
|
||||
}
|
||||
|
||||
// ─── Database backup management ─────────────────────────────
|
||||
|
||||
/// List all database backup files
|
||||
#[tauri::command]
|
||||
pub fn list_db_backups() -> Result<Vec<BackupEntry>, String> {
|
||||
Database::list_backups().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Restore database from a backup file
|
||||
#[tauri::command]
|
||||
pub async fn restore_db_backup(
|
||||
state: State<'_, AppState>,
|
||||
filename: String,
|
||||
) -> Result<String, String> {
|
||||
let db = state.db.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || db.restore_from_backup(&filename))
|
||||
.await
|
||||
.map_err(|e| format!("Restore failed: {e}"))?
|
||||
.map_err(|e: AppError| e.to_string())
|
||||
}
|
||||
|
||||
/// Rename a database backup file
|
||||
#[tauri::command]
|
||||
pub fn rename_db_backup(
|
||||
#[allow(non_snake_case)] oldFilename: String,
|
||||
#[allow(non_snake_case)] newName: String,
|
||||
) -> Result<String, String> {
|
||||
Database::rename_backup(&oldFilename, &newName).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
+77
-422
@@ -5,7 +5,6 @@ use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
|
||||
use crate::services::ProviderService;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use tauri::AppHandle;
|
||||
@@ -86,122 +85,50 @@ pub struct ToolVersion {
|
||||
version: Option<String>,
|
||||
latest_version: Option<String>, // 新增字段:最新版本
|
||||
error: Option<String>,
|
||||
/// 工具运行环境: "windows", "wsl", "macos", "linux", "unknown"
|
||||
env_type: String,
|
||||
/// 当 env_type 为 "wsl" 时,返回该工具绑定的 WSL distro(用于按 distro 探测 shells)
|
||||
wsl_distro: Option<String>,
|
||||
}
|
||||
|
||||
const VALID_TOOLS: [&str; 4] = ["claude", "codex", "gemini", "opencode"];
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WslShellPreferenceInput {
|
||||
#[serde(default)]
|
||||
pub wsl_shell: Option<String>,
|
||||
#[serde(default)]
|
||||
pub wsl_shell_flag: Option<String>,
|
||||
}
|
||||
|
||||
// Keep platform-specific env detection in one place to avoid repeating cfg blocks.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn tool_env_type_and_wsl_distro(tool: &str) -> (String, Option<String>) {
|
||||
if let Some(distro) = wsl_distro_for_tool(tool) {
|
||||
("wsl".to_string(), Some(distro))
|
||||
} else {
|
||||
("windows".to_string(), None)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
|
||||
("macos".to_string(), None)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
|
||||
("linux".to_string(), None)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
|
||||
("unknown".to_string(), None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_tool_versions(
|
||||
tools: Option<Vec<String>>,
|
||||
wsl_shell_by_tool: Option<HashMap<String, WslShellPreferenceInput>>,
|
||||
) -> Result<Vec<ToolVersion>, String> {
|
||||
let requested: Vec<&str> = if let Some(tools) = tools.as_ref() {
|
||||
let set: std::collections::HashSet<&str> = tools.iter().map(|s| s.as_str()).collect();
|
||||
VALID_TOOLS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|t| set.contains(t))
|
||||
.collect()
|
||||
} else {
|
||||
VALID_TOOLS.to_vec()
|
||||
};
|
||||
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
||||
let tools = vec!["claude", "codex", "gemini", "opencode"];
|
||||
let mut results = Vec::new();
|
||||
|
||||
for tool in requested {
|
||||
let pref = wsl_shell_by_tool.as_ref().and_then(|m| m.get(tool));
|
||||
let tool_wsl_shell = pref.and_then(|p| p.wsl_shell.as_deref());
|
||||
let tool_wsl_shell_flag = pref.and_then(|p| p.wsl_shell_flag.as_deref());
|
||||
|
||||
results.push(get_single_tool_version_impl(tool, tool_wsl_shell, tool_wsl_shell_flag).await);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// 获取单个工具的版本信息(内部实现)
|
||||
async fn get_single_tool_version_impl(
|
||||
tool: &str,
|
||||
wsl_shell: Option<&str>,
|
||||
wsl_shell_flag: Option<&str>,
|
||||
) -> ToolVersion {
|
||||
debug_assert!(
|
||||
VALID_TOOLS.contains(&tool),
|
||||
"unexpected tool name in get_single_tool_version_impl: {tool}"
|
||||
);
|
||||
|
||||
// 判断该工具的运行环境 & WSL distro(如有)
|
||||
let (env_type, wsl_distro) = tool_env_type_and_wsl_distro(tool);
|
||||
|
||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
// 1. 获取本地版本
|
||||
let (local_version, local_error) = if let Some(distro) = wsl_distro.as_deref() {
|
||||
try_get_version_wsl(tool, distro, wsl_shell, wsl_shell_flag)
|
||||
} else {
|
||||
let direct_result = try_get_version(tool);
|
||||
if direct_result.0.is_some() {
|
||||
direct_result
|
||||
for tool in tools {
|
||||
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
|
||||
let (local_version, local_error) = if let Some(distro) = wsl_distro_for_tool(tool) {
|
||||
try_get_version_wsl(tool, &distro)
|
||||
} else {
|
||||
scan_cli_version(tool)
|
||||
}
|
||||
};
|
||||
// 先尝试直接执行
|
||||
let direct_result = try_get_version(tool);
|
||||
|
||||
// 2. 获取远程最新版本
|
||||
let latest_version = match tool {
|
||||
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
|
||||
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
|
||||
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
|
||||
"opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await,
|
||||
_ => None,
|
||||
};
|
||||
if direct_result.0.is_some() {
|
||||
direct_result
|
||||
} else {
|
||||
// 扫描常见的 npm 全局安装路径
|
||||
scan_cli_version(tool)
|
||||
}
|
||||
};
|
||||
|
||||
ToolVersion {
|
||||
name: tool.to_string(),
|
||||
version: local_version,
|
||||
latest_version,
|
||||
error: local_error,
|
||||
env_type,
|
||||
wsl_distro,
|
||||
// 2. 获取远程最新版本
|
||||
let latest_version = match tool {
|
||||
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
|
||||
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
|
||||
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
|
||||
"opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await,
|
||||
_ => None,
|
||||
};
|
||||
|
||||
results.push(ToolVersion {
|
||||
name: tool.to_string(),
|
||||
version: local_version,
|
||||
latest_version,
|
||||
error: local_error,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Helper function to fetch latest version from npm registry
|
||||
@@ -315,38 +242,8 @@ fn is_valid_wsl_distro_name(name: &str) -> bool {
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||||
}
|
||||
|
||||
/// Validate that the given shell name is one of the allowed shells.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn is_valid_shell(shell: &str) -> bool {
|
||||
matches!(
|
||||
shell.rsplit('/').next().unwrap_or(shell),
|
||||
"sh" | "bash" | "zsh" | "fish" | "dash"
|
||||
)
|
||||
}
|
||||
|
||||
/// Validate that the given shell flag is one of the allowed flags.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn is_valid_shell_flag(flag: &str) -> bool {
|
||||
matches!(flag, "-c" | "-lc" | "-lic")
|
||||
}
|
||||
|
||||
/// Return the default invocation flag for the given shell.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn default_flag_for_shell(shell: &str) -> &'static str {
|
||||
match shell.rsplit('/').next().unwrap_or(shell) {
|
||||
"dash" | "sh" => "-c",
|
||||
"fish" => "-lc",
|
||||
_ => "-lic",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn try_get_version_wsl(
|
||||
tool: &str,
|
||||
distro: &str,
|
||||
force_shell: Option<&str>,
|
||||
force_shell_flag: Option<&str>,
|
||||
) -> (Option<String>, Option<String>) {
|
||||
fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<String>) {
|
||||
use std::process::Command;
|
||||
|
||||
// 防御性断言:tool 只能是预定义的值
|
||||
@@ -360,47 +257,15 @@ fn try_get_version_wsl(
|
||||
return (None, Some(format!("[WSL:{distro}] invalid distro name")));
|
||||
}
|
||||
|
||||
// 构建 Shell 脚本检测逻辑
|
||||
let (shell, flag, cmd) = if let Some(shell) = force_shell {
|
||||
// Defensive validation: never allow an arbitrary executable name here.
|
||||
if !is_valid_shell(shell) {
|
||||
return (None, Some(format!("[WSL:{distro}] invalid shell: {shell}")));
|
||||
}
|
||||
let shell = shell.rsplit('/').next().unwrap_or(shell);
|
||||
let flag = if let Some(flag) = force_shell_flag {
|
||||
if !is_valid_shell_flag(flag) {
|
||||
return (
|
||||
None,
|
||||
Some(format!("[WSL:{distro}] invalid shell flag: {flag}")),
|
||||
);
|
||||
}
|
||||
flag
|
||||
} else {
|
||||
default_flag_for_shell(shell)
|
||||
};
|
||||
|
||||
(shell.to_string(), flag, format!("{tool} --version"))
|
||||
} else {
|
||||
let cmd = if let Some(flag) = force_shell_flag {
|
||||
if !is_valid_shell_flag(flag) {
|
||||
return (
|
||||
None,
|
||||
Some(format!("[WSL:{distro}] invalid shell flag: {flag}")),
|
||||
);
|
||||
}
|
||||
format!("\"${{SHELL:-sh}}\" {flag} '{tool} --version'")
|
||||
} else {
|
||||
// 兜底:自动尝试 -lic, -lc, -c
|
||||
format!(
|
||||
"\"${{SHELL:-sh}}\" -lic '{tool} --version' 2>/dev/null || \"${{SHELL:-sh}}\" -lc '{tool} --version' 2>/dev/null || \"${{SHELL:-sh}}\" -c '{tool} --version'"
|
||||
)
|
||||
};
|
||||
|
||||
("sh".to_string(), "-c", cmd)
|
||||
};
|
||||
|
||||
let output = Command::new("wsl.exe")
|
||||
.args(["-d", distro, "--", &shell, flag, &cmd])
|
||||
.args([
|
||||
"-d",
|
||||
distro,
|
||||
"--",
|
||||
"sh",
|
||||
"-lc",
|
||||
&format!("{tool} --version"),
|
||||
])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
|
||||
@@ -441,91 +306,13 @@ fn try_get_version_wsl(
|
||||
/// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。
|
||||
/// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn try_get_version_wsl(
|
||||
_tool: &str,
|
||||
_distro: &str,
|
||||
_force_shell: Option<&str>,
|
||||
_force_shell_flag: Option<&str>,
|
||||
) -> (Option<String>, Option<String>) {
|
||||
fn try_get_version_wsl(_tool: &str, _distro: &str) -> (Option<String>, Option<String>) {
|
||||
(
|
||||
None,
|
||||
Some("WSL check not supported on this platform".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -533,99 +320,88 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
|
||||
// 常见的安装路径(原生安装优先)
|
||||
let mut search_paths: Vec<std::path::PathBuf> = Vec::new();
|
||||
if !home.as_os_str().is_empty() {
|
||||
push_unique_path(&mut search_paths, home.join(".local/bin"));
|
||||
push_unique_path(&mut search_paths, home.join(".npm-global/bin"));
|
||||
push_unique_path(&mut search_paths, home.join("n/bin"));
|
||||
push_unique_path(&mut search_paths, home.join(".volta/bin"));
|
||||
}
|
||||
let mut search_paths: Vec<std::path::PathBuf> = vec![
|
||||
home.join(".local/bin"), // Native install (official recommended)
|
||||
home.join(".npm-global/bin"),
|
||||
home.join("n/bin"), // n version manager
|
||||
home.join(".volta/bin"), // Volta package manager
|
||||
];
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
push_unique_path(
|
||||
&mut search_paths,
|
||||
std::path::PathBuf::from("/opt/homebrew/bin"),
|
||||
);
|
||||
push_unique_path(
|
||||
&mut search_paths,
|
||||
std::path::PathBuf::from("/usr/local/bin"),
|
||||
);
|
||||
search_paths.push(std::path::PathBuf::from("/opt/homebrew/bin"));
|
||||
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
push_unique_path(
|
||||
&mut search_paths,
|
||||
std::path::PathBuf::from("/usr/local/bin"),
|
||||
);
|
||||
push_unique_path(&mut search_paths, std::path::PathBuf::from("/usr/bin"));
|
||||
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
|
||||
search_paths.push(std::path::PathBuf::from("/usr/bin"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(appdata) = dirs::data_dir() {
|
||||
push_unique_path(&mut search_paths, appdata.join("npm"));
|
||||
search_paths.push(appdata.join("npm"));
|
||||
}
|
||||
push_unique_path(
|
||||
&mut search_paths,
|
||||
std::path::PathBuf::from("C:\\Program Files\\nodejs"),
|
||||
);
|
||||
search_paths.push(std::path::PathBuf::from("C:\\Program Files\\nodejs"));
|
||||
}
|
||||
|
||||
// 添加 fnm 路径支持
|
||||
let fnm_base = home.join(".local/state/fnm_multishells");
|
||||
if fnm_base.exists() {
|
||||
if let Ok(entries) = std::fs::read_dir(&fnm_base) {
|
||||
for entry in entries.flatten() {
|
||||
let bin_path = entry.path().join("bin");
|
||||
if bin_path.exists() {
|
||||
push_unique_path(&mut search_paths, bin_path);
|
||||
search_paths.push(bin_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 扫描 nvm 目录下的所有 node 版本
|
||||
let nvm_base = home.join(".nvm/versions/node");
|
||||
if nvm_base.exists() {
|
||||
if let Ok(entries) = std::fs::read_dir(&nvm_base) {
|
||||
for entry in entries.flatten() {
|
||||
let bin_path = entry.path().join("bin");
|
||||
if bin_path.exists() {
|
||||
push_unique_path(&mut search_paths, bin_path);
|
||||
search_paths.push(bin_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 Go 路径支持 (opencode 使用 go install 安装)
|
||||
if tool == "opencode" {
|
||||
let extra_paths = opencode_extra_search_paths(
|
||||
&home,
|
||||
std::env::var_os("OPENCODE_INSTALL_DIR"),
|
||||
std::env::var_os("XDG_BIN_DIR"),
|
||||
std::env::var_os("GOPATH"),
|
||||
);
|
||||
|
||||
for path in extra_paths {
|
||||
push_unique_path(&mut search_paths, path);
|
||||
search_paths.push(home.join("go/bin")); // go install 默认路径
|
||||
if let Ok(gopath) = std::env::var("GOPATH") {
|
||||
search_paths.push(std::path::PathBuf::from(gopath).join("bin"));
|
||||
}
|
||||
}
|
||||
|
||||
let current_path = std::env::var("PATH").unwrap_or_default();
|
||||
|
||||
// 在每个路径中查找工具
|
||||
for path in &search_paths {
|
||||
#[cfg(target_os = "windows")]
|
||||
let new_path = format!("{};{}", path.display(), current_path);
|
||||
let tool_path = if cfg!(target_os = "windows") {
|
||||
path.join(format!("{tool}.cmd"))
|
||||
} else {
|
||||
path.join(tool)
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let new_path = format!("{}:{}", path.display(), current_path);
|
||||
if tool_path.exists() {
|
||||
// 构建 PATH 环境变量,确保 node 可被找到
|
||||
let current_path = std::env::var("PATH").unwrap_or_default();
|
||||
|
||||
for tool_path in tool_executable_candidates(tool, path) {
|
||||
if !tool_path.exists() {
|
||||
continue;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
let new_path = format!("{};{}", path.display(), current_path);
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let new_path = format!("{}:{}", path.display(), current_path);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let output = {
|
||||
// 使用 cmd /C 包装执行,确保子进程也在隐藏的控制台中运行
|
||||
Command::new("cmd")
|
||||
.args(["/C", &format!("\"{}\" --version", tool_path.display())])
|
||||
.env("PATH", &new_path)
|
||||
@@ -1195,124 +971,3 @@ pub async fn set_window_theme(window: tauri::Window, theme: String) -> Result<()
|
||||
|
||||
window.set_theme(tauri_theme).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn test_extract_version() {
|
||||
assert_eq!(extract_version("claude 1.0.20"), "1.0.20");
|
||||
assert_eq!(extract_version("v2.3.4-beta.1"), "2.3.4-beta.1");
|
||||
assert_eq!(extract_version("no version here"), "no version here");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod wsl_helpers {
|
||||
use super::super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_shell() {
|
||||
assert!(is_valid_shell("bash"));
|
||||
assert!(is_valid_shell("zsh"));
|
||||
assert!(is_valid_shell("sh"));
|
||||
assert!(is_valid_shell("fish"));
|
||||
assert!(is_valid_shell("dash"));
|
||||
assert!(is_valid_shell("/usr/bin/bash"));
|
||||
assert!(is_valid_shell("/bin/zsh"));
|
||||
assert!(!is_valid_shell("powershell"));
|
||||
assert!(!is_valid_shell("cmd"));
|
||||
assert!(!is_valid_shell(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_shell_flag() {
|
||||
assert!(is_valid_shell_flag("-c"));
|
||||
assert!(is_valid_shell_flag("-lc"));
|
||||
assert!(is_valid_shell_flag("-lic"));
|
||||
assert!(!is_valid_shell_flag("-x"));
|
||||
assert!(!is_valid_shell_flag(""));
|
||||
assert!(!is_valid_shell_flag("--login"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_flag_for_shell() {
|
||||
assert_eq!(default_flag_for_shell("sh"), "-c");
|
||||
assert_eq!(default_flag_for_shell("dash"), "-c");
|
||||
assert_eq!(default_flag_for_shell("/bin/dash"), "-c");
|
||||
assert_eq!(default_flag_for_shell("fish"), "-lc");
|
||||
assert_eq!(default_flag_for_shell("bash"), "-lic");
|
||||
assert_eq!(default_flag_for_shell("zsh"), "-lic");
|
||||
assert_eq!(default_flag_for_shell("/usr/bin/zsh"), "-lic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_wsl_distro_name() {
|
||||
assert!(is_valid_wsl_distro_name("Ubuntu"));
|
||||
assert!(is_valid_wsl_distro_name("Ubuntu-22.04"));
|
||||
assert!(is_valid_wsl_distro_name("my_distro"));
|
||||
assert!(!is_valid_wsl_distro_name(""));
|
||||
assert!(!is_valid_wsl_distro_name("distro with spaces"));
|
||||
assert!(!is_valid_wsl_distro_name(&"a".repeat(65)));
|
||||
}
|
||||
}
|
||||
|
||||
#[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,7 +9,6 @@ mod import_export;
|
||||
mod mcp;
|
||||
mod misc;
|
||||
mod omo;
|
||||
mod openclaw;
|
||||
mod plugin;
|
||||
mod prompt;
|
||||
mod provider;
|
||||
@@ -18,10 +17,7 @@ mod session_manager;
|
||||
mod settings;
|
||||
pub mod skill;
|
||||
mod stream_check;
|
||||
mod sync_support;
|
||||
mod usage;
|
||||
mod webdav_sync;
|
||||
mod workspace;
|
||||
|
||||
pub use config::*;
|
||||
pub use deeplink::*;
|
||||
@@ -32,7 +28,6 @@ pub use import_export::*;
|
||||
pub use mcp::*;
|
||||
pub use misc::*;
|
||||
pub use omo::*;
|
||||
pub use openclaw::*;
|
||||
pub use plugin::*;
|
||||
pub use prompt::*;
|
||||
pub use provider::*;
|
||||
@@ -42,5 +37,3 @@ pub use settings::*;
|
||||
pub use skill::*;
|
||||
pub use stream_check::*;
|
||||
pub use usage::*;
|
||||
pub use webdav_sync::*;
|
||||
pub use workspace::*;
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::services::omo::{OmoLocalFileData, SLIM, STANDARD};
|
||||
use crate::services::omo::OmoLocalFileData;
|
||||
use crate::services::OmoService;
|
||||
use crate::store::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_omo_local_file() -> Result<OmoLocalFileData, String> {
|
||||
OmoService::read_local_file(&STANDARD).map_err(|e| e.to_string())
|
||||
OmoService::read_local_file().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_current_omo_provider_id(state: State<'_, AppState>) -> Result<String, String> {
|
||||
let provider = state
|
||||
.db
|
||||
.get_current_omo_provider("opencode", "omo")
|
||||
.get_current_omo_provider("opencode")
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(provider.map(|p| p.id).unwrap_or_default())
|
||||
}
|
||||
@@ -28,11 +28,11 @@ pub async fn disable_current_omo(state: State<'_, AppState>) -> Result<(), Strin
|
||||
if p.category.as_deref() == Some("omo") {
|
||||
state
|
||||
.db
|
||||
.clear_omo_provider_current("opencode", id, "omo")
|
||||
.clear_omo_provider_current("opencode", id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
OmoService::delete_config_file(&STANDARD).map_err(|e| e.to_string())?;
|
||||
OmoService::delete_config_file().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -48,52 +48,3 @@ pub async fn get_omo_provider_count(state: State<'_, AppState>) -> Result<usize,
|
||||
.count();
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
// ── OMO Slim commands ───────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_omo_slim_local_file() -> Result<OmoLocalFileData, String> {
|
||||
OmoService::read_local_file(&SLIM).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_current_omo_slim_provider_id(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
let provider = state
|
||||
.db
|
||||
.get_current_omo_provider("opencode", "omo-slim")
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(provider.map(|p| p.id).unwrap_or_default())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disable_current_omo_slim(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let providers = state
|
||||
.db
|
||||
.get_all_providers("opencode")
|
||||
.map_err(|e| e.to_string())?;
|
||||
for (id, p) in &providers {
|
||||
if p.category.as_deref() == Some("omo-slim") {
|
||||
state
|
||||
.db
|
||||
.clear_omo_provider_current("opencode", id, "omo-slim")
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
OmoService::delete_config_file(&SLIM).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_omo_slim_provider_count(state: State<'_, AppState>) -> Result<usize, String> {
|
||||
let providers = state
|
||||
.db
|
||||
.get_all_providers("opencode")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let count = providers
|
||||
.values()
|
||||
.filter(|p| p.category.as_deref() == Some("omo-slim"))
|
||||
.count();
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use tauri::State;
|
||||
|
||||
use crate::openclaw_config;
|
||||
use crate::store::AppState;
|
||||
|
||||
// ============================================================================
|
||||
// OpenClaw Provider Commands (migrated from provider.rs)
|
||||
// ============================================================================
|
||||
|
||||
/// Import providers from OpenClaw live config to database.
|
||||
///
|
||||
/// OpenClaw uses additive mode — users may already have providers
|
||||
/// configured in openclaw.json.
|
||||
#[tauri::command]
|
||||
pub fn import_openclaw_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
|
||||
crate::services::provider::import_openclaw_providers_from_live(state.inner())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Get provider IDs in the OpenClaw live config.
|
||||
#[tauri::command]
|
||||
pub fn get_openclaw_live_provider_ids() -> Result<Vec<String>, String> {
|
||||
openclaw_config::get_providers()
|
||||
.map(|providers| providers.keys().cloned().collect())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agents Configuration Commands
|
||||
// ============================================================================
|
||||
|
||||
/// Get OpenClaw default model config (agents.defaults.model)
|
||||
#[tauri::command]
|
||||
pub fn get_openclaw_default_model() -> Result<Option<openclaw_config::OpenClawDefaultModel>, String>
|
||||
{
|
||||
openclaw_config::get_default_model().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Set OpenClaw default model config (agents.defaults.model)
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_default_model(
|
||||
model: openclaw_config::OpenClawDefaultModel,
|
||||
) -> Result<(), String> {
|
||||
openclaw_config::set_default_model(&model).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Get OpenClaw model catalog/allowlist (agents.defaults.models)
|
||||
#[tauri::command]
|
||||
pub fn get_openclaw_model_catalog(
|
||||
) -> Result<Option<HashMap<String, openclaw_config::OpenClawModelCatalogEntry>>, String> {
|
||||
openclaw_config::get_model_catalog().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Set OpenClaw model catalog/allowlist (agents.defaults.models)
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_model_catalog(
|
||||
catalog: HashMap<String, openclaw_config::OpenClawModelCatalogEntry>,
|
||||
) -> Result<(), String> {
|
||||
openclaw_config::set_model_catalog(&catalog).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Get full agents.defaults config (all fields)
|
||||
#[tauri::command]
|
||||
pub fn get_openclaw_agents_defaults(
|
||||
) -> Result<Option<openclaw_config::OpenClawAgentsDefaults>, String> {
|
||||
openclaw_config::get_agents_defaults().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Set full agents.defaults config (all fields)
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_agents_defaults(
|
||||
defaults: openclaw_config::OpenClawAgentsDefaults,
|
||||
) -> Result<(), String> {
|
||||
openclaw_config::set_agents_defaults(&defaults).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Env Configuration Commands
|
||||
// ============================================================================
|
||||
|
||||
/// Get OpenClaw env config (env section of openclaw.json)
|
||||
#[tauri::command]
|
||||
pub fn get_openclaw_env() -> Result<openclaw_config::OpenClawEnvConfig, String> {
|
||||
openclaw_config::get_env_config().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Set OpenClaw env config (env section of openclaw.json)
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_env(env: openclaw_config::OpenClawEnvConfig) -> Result<(), String> {
|
||||
openclaw_config::set_env_config(&env).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tools Configuration Commands
|
||||
// ============================================================================
|
||||
|
||||
/// Get OpenClaw tools config (tools section of openclaw.json)
|
||||
#[tauri::command]
|
||||
pub fn get_openclaw_tools() -> Result<openclaw_config::OpenClawToolsConfig, String> {
|
||||
openclaw_config::get_tools_config().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Set OpenClaw tools config (tools section of openclaw.json)
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_tools(tools: openclaw_config::OpenClawToolsConfig) -> Result<(), String> {
|
||||
openclaw_config::set_tools_config(&tools).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -4,9 +4,7 @@ use tauri::State;
|
||||
use crate::app_config::AppType;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::services::{
|
||||
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
|
||||
};
|
||||
use crate::services::{EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService};
|
||||
use crate::store::AppState;
|
||||
use std::str::FromStr;
|
||||
|
||||
@@ -69,11 +67,7 @@ pub fn remove_provider_from_live_config(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn switch_provider_internal(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
id: &str,
|
||||
) -> Result<SwitchResult, AppError> {
|
||||
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||
ProviderService::switch(state, app_type, id)
|
||||
}
|
||||
|
||||
@@ -82,7 +76,7 @@ pub fn switch_provider_test_hook(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
id: &str,
|
||||
) -> Result<SwitchResult, AppError> {
|
||||
) -> Result<(), AppError> {
|
||||
switch_provider_internal(state, app_type, id)
|
||||
}
|
||||
|
||||
@@ -91,15 +85,15 @@ pub fn switch_provider(
|
||||
state: State<'_, AppState>,
|
||||
app: String,
|
||||
id: String,
|
||||
) -> Result<SwitchResult, String> {
|
||||
) -> Result<bool, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
switch_provider_internal(&state, app_type, &id).map_err(|e| e.to_string())
|
||||
switch_provider_internal(&state, app_type, &id)
|
||||
.map(|_| true)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
||||
let imported = ProviderService::import_default_config(state, app_type)?;
|
||||
|
||||
Ok(imported)
|
||||
ProviderService::import_default_config(state, app_type)
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
||||
@@ -167,12 +161,6 @@ pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, Str
|
||||
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn patch_claude_live_settings(patch: serde_json::Value) -> Result<bool, String> {
|
||||
ProviderService::patch_claude_live(patch).map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn test_api_endpoints(
|
||||
urls: Vec<String>,
|
||||
@@ -330,7 +318,3 @@ pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
|
||||
.map(|providers| providers.keys().cloned().collect())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OpenClaw 专属命令 → 已迁移至 commands/openclaw.rs
|
||||
// ============================================================================
|
||||
|
||||
@@ -2,28 +2,16 @@
|
||||
|
||||
use tauri::AppHandle;
|
||||
|
||||
fn merge_settings_for_save(
|
||||
mut incoming: crate::settings::AppSettings,
|
||||
existing: &crate::settings::AppSettings,
|
||||
) -> crate::settings::AppSettings {
|
||||
if incoming.webdav_sync.is_none() {
|
||||
incoming.webdav_sync = existing.webdav_sync.clone();
|
||||
}
|
||||
incoming
|
||||
}
|
||||
|
||||
/// 获取设置
|
||||
#[tauri::command]
|
||||
pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
|
||||
Ok(crate::settings::get_settings_for_frontend())
|
||||
Ok(crate::settings::get_settings())
|
||||
}
|
||||
|
||||
/// 保存设置
|
||||
#[tauri::command]
|
||||
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
|
||||
let existing = crate::settings::get_settings();
|
||||
let merged = merge_settings_for_save(settings, &existing);
|
||||
crate::settings::update_settings(merged).map_err(|e| e.to_string())?;
|
||||
crate::settings::update_settings(settings).map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -66,58 +54,6 @@ pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::merge_settings_for_save;
|
||||
use crate::settings::{AppSettings, WebDavSyncSettings};
|
||||
|
||||
#[test]
|
||||
fn save_settings_should_preserve_existing_webdav_when_payload_omits_it() {
|
||||
let mut existing = AppSettings::default();
|
||||
existing.webdav_sync = Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "secret".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
|
||||
let incoming = AppSettings::default();
|
||||
let merged = merge_settings_for_save(incoming, &existing);
|
||||
|
||||
assert!(merged.webdav_sync.is_some());
|
||||
assert_eq!(
|
||||
merged.webdav_sync.as_ref().map(|v| v.base_url.as_str()),
|
||||
Some("https://dav.example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_settings_should_keep_incoming_webdav_when_present() {
|
||||
let mut existing = AppSettings::default();
|
||||
existing.webdav_sync = Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.old.example.com".to_string(),
|
||||
username: "old".to_string(),
|
||||
password: "old-pass".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
|
||||
let mut incoming = AppSettings::default();
|
||||
incoming.webdav_sync = Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.new.example.com".to_string(),
|
||||
username: "new".to_string(),
|
||||
password: "new-pass".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
|
||||
let merged = merge_settings_for_save(incoming, &existing);
|
||||
|
||||
assert_eq!(
|
||||
merged.webdav_sync.as_ref().map(|v| v.base_url.as_str()),
|
||||
Some("https://dav.new.example.com")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取开机自启状态
|
||||
#[tauri::command]
|
||||
pub async fn get_auto_launch_status() -> Result<bool, String> {
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use crate::services::provider::ProviderService;
|
||||
use crate::settings;
|
||||
use crate::store::AppState;
|
||||
|
||||
pub(crate) fn run_post_import_sync(db: Arc<Database>) -> Result<(), AppError> {
|
||||
let app_state = AppState::new(db);
|
||||
ProviderService::sync_current_to_live(&app_state)?;
|
||||
settings::reload_settings()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn post_sync_warning<E: std::fmt::Display>(err: E) -> String {
|
||||
AppError::localized(
|
||||
"sync.post_operation_sync_failed",
|
||||
format!("后置同步状态失败: {err}"),
|
||||
format!("Post-operation synchronization failed: {err}"),
|
||||
)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn post_sync_warning_from_result(
|
||||
result: Result<Result<(), AppError>, String>,
|
||||
) -> Option<String> {
|
||||
match result {
|
||||
Ok(Ok(())) => None,
|
||||
Ok(Err(err)) => Some(post_sync_warning(err)),
|
||||
Err(err) => Some(post_sync_warning(err)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn attach_warning(mut value: Value, warning: Option<String>) -> Value {
|
||||
if let Some(message) = warning {
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
obj.insert("warning".to_string(), Value::String(message));
|
||||
}
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
pub(crate) fn success_payload_with_warning(backup_id: String, warning: Option<String>) -> Value {
|
||||
attach_warning(
|
||||
json!({
|
||||
"success": true,
|
||||
"message": "SQL imported successfully",
|
||||
"backupId": backup_id
|
||||
}),
|
||||
warning,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{attach_warning, post_sync_warning_from_result};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn post_sync_warning_from_result_returns_none_on_success() {
|
||||
let warning = post_sync_warning_from_result(Ok(Ok(())));
|
||||
assert!(warning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_sync_warning_from_result_returns_some_on_sync_error() {
|
||||
let warning =
|
||||
post_sync_warning_from_result(Ok(Err(crate::error::AppError::Config("boom".into()))));
|
||||
assert!(warning.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_sync_warning_from_result_returns_some_on_join_error() {
|
||||
let handle = tokio::spawn(async move {
|
||||
panic!("forced join error");
|
||||
});
|
||||
let join_err = handle.await.expect_err("task should panic");
|
||||
let warning = post_sync_warning_from_result(Err(join_err.to_string()));
|
||||
assert!(warning.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_warning_adds_warning_without_dropping_existing_fields() {
|
||||
let payload = json!({ "status": "downloaded" });
|
||||
let updated = attach_warning(payload, Some("post sync warning".to_string()));
|
||||
assert_eq!(
|
||||
updated.get("status").and_then(|v| v.as_str()),
|
||||
Some("downloaded")
|
||||
);
|
||||
assert_eq!(
|
||||
updated.get("warning").and_then(|v| v.as_str()),
|
||||
Some("post sync warning")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
#![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/");
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
// --- Daily memory files (memory/YYYY-MM-DD.md) ---
|
||||
|
||||
static DAILY_MEMORY_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^\d{4}-\d{2}-\d{2}\.md$").unwrap());
|
||||
|
||||
fn validate_daily_memory_filename(filename: &str) -> Result<(), String> {
|
||||
if !DAILY_MEMORY_RE.is_match(filename) {
|
||||
return Err(format!(
|
||||
"Invalid daily memory filename: {filename}. Expected: YYYY-MM-DD.md"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DailyMemoryFileInfo {
|
||||
pub filename: String,
|
||||
pub date: String,
|
||||
pub size_bytes: u64,
|
||||
pub modified_at: u64,
|
||||
pub preview: String,
|
||||
}
|
||||
|
||||
// --- Daily memory commands ---
|
||||
|
||||
/// List all daily memory files under `workspace/memory/`.
|
||||
#[tauri::command]
|
||||
pub async fn list_daily_memory_files() -> Result<Vec<DailyMemoryFileInfo>, String> {
|
||||
let memory_dir = get_openclaw_dir().join("workspace").join("memory");
|
||||
|
||||
if !memory_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut files: Vec<DailyMemoryFileInfo> = Vec::new();
|
||||
|
||||
let entries = std::fs::read_dir(&memory_dir)
|
||||
.map_err(|e| format!("Failed to read memory directory: {e}"))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.ends_with(".md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let meta = match entry.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let date = name.trim_end_matches(".md").to_string();
|
||||
|
||||
let size_bytes = meta.len();
|
||||
let modified_at = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
let preview = std::fs::read_to_string(entry.path())
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.take(200)
|
||||
.collect::<String>();
|
||||
|
||||
files.push(DailyMemoryFileInfo {
|
||||
filename: name,
|
||||
date,
|
||||
size_bytes,
|
||||
modified_at,
|
||||
preview,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by filename descending (newest date first, YYYY-MM-DD.md)
|
||||
files.sort_by(|a, b| b.filename.cmp(&a.filename));
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Read a daily memory file.
|
||||
#[tauri::command]
|
||||
pub async fn read_daily_memory_file(filename: String) -> Result<Option<String>, String> {
|
||||
validate_daily_memory_filename(&filename)?;
|
||||
|
||||
let path = get_openclaw_dir()
|
||||
.join("workspace")
|
||||
.join("memory")
|
||||
.join(&filename);
|
||||
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
std::fs::read_to_string(&path)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("Failed to read daily memory file {filename}: {e}"))
|
||||
}
|
||||
|
||||
/// Write a daily memory file (atomic write).
|
||||
#[tauri::command]
|
||||
pub async fn write_daily_memory_file(filename: String, content: String) -> Result<(), String> {
|
||||
validate_daily_memory_filename(&filename)?;
|
||||
|
||||
let memory_dir = get_openclaw_dir().join("workspace").join("memory");
|
||||
|
||||
std::fs::create_dir_all(&memory_dir)
|
||||
.map_err(|e| format!("Failed to create memory directory: {e}"))?;
|
||||
|
||||
let path = memory_dir.join(&filename);
|
||||
|
||||
write_text_file(&path, &content)
|
||||
.map_err(|e| format!("Failed to write daily memory file {filename}: {e}"))
|
||||
}
|
||||
|
||||
/// Delete a daily memory file (idempotent).
|
||||
#[tauri::command]
|
||||
pub async fn delete_daily_memory_file(filename: String) -> Result<(), String> {
|
||||
validate_daily_memory_filename(&filename)?;
|
||||
|
||||
let path = get_openclaw_dir()
|
||||
.join("workspace")
|
||||
.join("memory")
|
||||
.join(&filename);
|
||||
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)
|
||||
.map_err(|e| format!("Failed to delete daily memory file {filename}: {e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Workspace file commands ---
|
||||
|
||||
/// 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}"))
|
||||
}
|
||||
@@ -0,0 +1,889 @@
|
||||
//! Configuration merge utilities for the common config redesign.
|
||||
//!
|
||||
//! This module provides functions for:
|
||||
//! - `compute_final_config`: Merges common config (base) with custom config (override)
|
||||
//! - `extract_difference`: Extracts custom parts from live config by comparing with common config
|
||||
//!
|
||||
//! Supports JSON (Claude, Gemini) and TOML (Codex) formats.
|
||||
|
||||
use serde_json::{Map, Value as JsonValue};
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
// ============================================================================
|
||||
// JSON Configuration Merge Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Deep merge two JSON objects where `source` overrides `target`.
|
||||
///
|
||||
/// Merge rules:
|
||||
/// - Nested objects: Recursive merge
|
||||
/// - Arrays: Source completely replaces target (no element-level merge)
|
||||
/// - Primitives: Source overrides target
|
||||
/// - Null values: Do not override
|
||||
fn deep_merge_json(target: &mut JsonValue, source: &JsonValue) {
|
||||
// First check if both are objects without destructuring
|
||||
let both_objects =
|
||||
matches!(target, JsonValue::Object(_)) && matches!(source, JsonValue::Object(_));
|
||||
|
||||
if both_objects {
|
||||
// Safe to destructure now since we know both are objects
|
||||
if let (JsonValue::Object(target_map), JsonValue::Object(source_map)) = (target, source) {
|
||||
for (key, source_value) in source_map {
|
||||
if source_value.is_null() {
|
||||
// Null doesn't override
|
||||
continue;
|
||||
}
|
||||
match target_map.get_mut(key) {
|
||||
Some(target_value) if target_value.is_object() && source_value.is_object() => {
|
||||
// Nested object: recursive merge
|
||||
deep_merge_json(target_value, source_value);
|
||||
}
|
||||
_ => {
|
||||
// Other cases: source overrides
|
||||
target_map.insert(key.clone(), source_value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-object: source overrides
|
||||
*target = source.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute final JSON config.
|
||||
///
|
||||
/// Common config as base, custom config overrides (custom takes priority).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `custom_config` - Provider's custom configuration
|
||||
/// * `common_config` - Common configuration snippet
|
||||
/// * `enabled` - Whether common config is enabled
|
||||
///
|
||||
/// # Returns
|
||||
/// The merged final configuration as JSON value
|
||||
pub fn compute_final_json_config(
|
||||
custom_config: &JsonValue,
|
||||
common_config: &JsonValue,
|
||||
enabled: bool,
|
||||
) -> JsonValue {
|
||||
if !enabled {
|
||||
return custom_config.clone();
|
||||
}
|
||||
|
||||
// Validate both are objects
|
||||
let common_obj = match common_config {
|
||||
JsonValue::Object(m) if !m.is_empty() => m,
|
||||
_ => return custom_config.clone(),
|
||||
};
|
||||
|
||||
let custom_obj = match custom_config {
|
||||
JsonValue::Object(_) => custom_config,
|
||||
_ => return custom_config.clone(),
|
||||
};
|
||||
|
||||
// Start with common config as base
|
||||
let mut result = JsonValue::Object(common_obj.clone());
|
||||
|
||||
// Merge custom config on top (custom overrides common)
|
||||
deep_merge_json(&mut result, custom_obj);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Check if two JSON values are deeply equal.
|
||||
fn json_deep_equal(a: &JsonValue, b: &JsonValue) -> bool {
|
||||
match (a, b) {
|
||||
(JsonValue::Null, JsonValue::Null) => true,
|
||||
(JsonValue::Bool(a), JsonValue::Bool(b)) => a == b,
|
||||
(JsonValue::Number(a), JsonValue::Number(b)) => a == b,
|
||||
(JsonValue::String(a), JsonValue::String(b)) => a == b,
|
||||
(JsonValue::Array(a), JsonValue::Array(b)) => {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.iter().zip(b.iter()).all(|(x, y)| json_deep_equal(x, y))
|
||||
}
|
||||
(JsonValue::Object(a), JsonValue::Object(b)) => {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.iter()
|
||||
.all(|(k, v)| b.get(k).is_some_and(|bv| json_deep_equal(v, bv)))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract difference between live config and common config.
|
||||
///
|
||||
/// Extraction rules:
|
||||
/// - Keys not in common config → include in custom config
|
||||
/// - Keys in common config but with different values → include in custom config (user override)
|
||||
/// - Keys in common config with same values → skip (avoid redundant storage)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `live_config` - Configuration read from live file
|
||||
/// * `common_config` - Common configuration snippet
|
||||
///
|
||||
/// # Returns
|
||||
/// Tuple of (custom_config, has_common_keys)
|
||||
pub fn extract_json_difference(
|
||||
live_config: &JsonValue,
|
||||
common_config: &JsonValue,
|
||||
) -> (JsonValue, bool) {
|
||||
let live_obj = match live_config {
|
||||
JsonValue::Object(m) => m,
|
||||
_ => return (live_config.clone(), false),
|
||||
};
|
||||
|
||||
let common_obj = match common_config {
|
||||
JsonValue::Object(m) => m,
|
||||
_ => return (live_config.clone(), false),
|
||||
};
|
||||
|
||||
let mut custom_config = Map::new();
|
||||
let mut has_common_keys = false;
|
||||
|
||||
fn extract_recursive(
|
||||
live: &Map<String, JsonValue>,
|
||||
common: &Map<String, JsonValue>,
|
||||
target: &mut Map<String, JsonValue>,
|
||||
has_common: &mut bool,
|
||||
) {
|
||||
for (key, live_value) in live {
|
||||
match common.get(key) {
|
||||
None => {
|
||||
// Case 1: Key not in common config, keep it
|
||||
target.insert(key.clone(), live_value.clone());
|
||||
}
|
||||
Some(common_value) => {
|
||||
// Check if both are objects for nested handling
|
||||
match (live_value, common_value) {
|
||||
(JsonValue::Object(live_map), JsonValue::Object(common_map)) => {
|
||||
// Case 2: Nested object, recurse
|
||||
let mut nested = Map::new();
|
||||
extract_recursive(live_map, common_map, &mut nested, has_common);
|
||||
if !nested.is_empty() {
|
||||
target.insert(key.clone(), JsonValue::Object(nested));
|
||||
} else {
|
||||
// Nested object matches common config
|
||||
*has_common = true;
|
||||
}
|
||||
}
|
||||
_ if !json_deep_equal(live_value, common_value) => {
|
||||
// Case 3: Value different, keep it (user override)
|
||||
target.insert(key.clone(), live_value.clone());
|
||||
}
|
||||
_ => {
|
||||
// Case 4: Value same, skip (avoid redundancy)
|
||||
*has_common = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extract_recursive(
|
||||
live_obj,
|
||||
common_obj,
|
||||
&mut custom_config,
|
||||
&mut has_common_keys,
|
||||
);
|
||||
|
||||
(JsonValue::Object(custom_config), has_common_keys)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TOML Configuration Merge Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Deep merge TOML tables while preserving target's key order (custom first, then common).
|
||||
///
|
||||
/// - Keeps target's existing values (target = custom config has priority)
|
||||
/// - Only adds keys from source that don't exist in target (common-only keys)
|
||||
/// - For nested tables, recursively merges while preserving target's keys first
|
||||
fn deep_merge_toml_preserve_order(target: &mut TomlValue, source: &TomlValue) {
|
||||
let both_tables =
|
||||
matches!(target, TomlValue::Table(_)) && matches!(source, TomlValue::Table(_));
|
||||
|
||||
if both_tables {
|
||||
if let (TomlValue::Table(target_map), TomlValue::Table(source_map)) = (target, source) {
|
||||
for (key, source_value) in source_map {
|
||||
match target_map.get_mut(key) {
|
||||
Some(target_value) if target_value.is_table() && source_value.is_table() => {
|
||||
// Nested table: recursive merge (preserving target's keys first)
|
||||
deep_merge_toml_preserve_order(target_value, source_value);
|
||||
}
|
||||
Some(_) => {
|
||||
// Key exists in target: keep target's value (custom overrides common)
|
||||
}
|
||||
None => {
|
||||
// Key only in source: add to target (common-only keys appear after)
|
||||
target_map.insert(key.clone(), source_value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Non-table case: keep target as-is (custom has priority)
|
||||
}
|
||||
|
||||
/// Compute final TOML config.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `custom_config` - Provider's custom TOML configuration
|
||||
/// * `common_config` - Common TOML configuration snippet
|
||||
/// * `enabled` - Whether common config is enabled
|
||||
///
|
||||
/// # Returns
|
||||
/// Tuple of (final_config_toml, error_message)
|
||||
pub fn compute_final_toml_config_str(
|
||||
custom_toml: &str,
|
||||
common_toml: &str,
|
||||
enabled: bool,
|
||||
) -> (String, Option<String>) {
|
||||
if !enabled || common_toml.trim().is_empty() {
|
||||
return (custom_toml.to_string(), None);
|
||||
}
|
||||
|
||||
// Check if common TOML has actual content (not just comments)
|
||||
let common_has_content = common_toml.lines().any(|line| {
|
||||
let trimmed = line.trim();
|
||||
!trimmed.is_empty() && !trimmed.starts_with('#')
|
||||
});
|
||||
|
||||
if !common_has_content {
|
||||
return (custom_toml.to_string(), None);
|
||||
}
|
||||
|
||||
// Parse custom TOML
|
||||
let custom_config: TomlValue = match custom_toml.parse() {
|
||||
Ok(v) => v,
|
||||
Err(_) if custom_toml.trim().is_empty() => TomlValue::Table(toml::map::Map::new()),
|
||||
Err(e) => {
|
||||
return (
|
||||
custom_toml.to_string(),
|
||||
Some(format!("Failed to parse custom TOML: {e}")),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Parse common TOML
|
||||
let common_config: TomlValue = match common_toml.parse() {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return (
|
||||
custom_toml.to_string(),
|
||||
Some(format!("Failed to parse common TOML: {e}")),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Start with custom config (so custom keys appear first in output)
|
||||
let mut result = custom_config;
|
||||
|
||||
// Add common config keys that don't exist in custom (these appear after custom keys)
|
||||
// Custom values always take priority (they're already in result)
|
||||
deep_merge_toml_preserve_order(&mut result, &common_config);
|
||||
|
||||
// Serialize back to TOML string
|
||||
match toml::to_string_pretty(&result) {
|
||||
Ok(s) => (s, None),
|
||||
Err(e) => (
|
||||
custom_toml.to_string(),
|
||||
Some(format!("Failed to serialize TOML: {e}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if two TOML values are deeply equal.
|
||||
fn toml_deep_equal(a: &TomlValue, b: &TomlValue) -> bool {
|
||||
match (a, b) {
|
||||
(TomlValue::String(a), TomlValue::String(b)) => a == b,
|
||||
(TomlValue::Integer(a), TomlValue::Integer(b)) => a == b,
|
||||
(TomlValue::Float(a), TomlValue::Float(b)) => (a - b).abs() < f64::EPSILON,
|
||||
(TomlValue::Boolean(a), TomlValue::Boolean(b)) => a == b,
|
||||
(TomlValue::Datetime(a), TomlValue::Datetime(b)) => a == b,
|
||||
(TomlValue::Array(a), TomlValue::Array(b)) => {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.iter().zip(b.iter()).all(|(x, y)| toml_deep_equal(x, y))
|
||||
}
|
||||
(TomlValue::Table(a), TomlValue::Table(b)) => {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.iter()
|
||||
.all(|(k, v)| b.get(k).is_some_and(|bv| toml_deep_equal(v, bv)))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract difference between live TOML config and common config.
|
||||
///
|
||||
/// # Returns
|
||||
/// Tuple of (custom_toml, has_common_keys, error_message)
|
||||
pub fn extract_toml_difference_str(
|
||||
live_toml: &str,
|
||||
common_toml: &str,
|
||||
) -> (String, bool, Option<String>) {
|
||||
if common_toml.trim().is_empty() {
|
||||
return (live_toml.to_string(), false, None);
|
||||
}
|
||||
|
||||
// Check if common TOML has actual content
|
||||
let common_has_content = common_toml.lines().any(|line| {
|
||||
let trimmed = line.trim();
|
||||
!trimmed.is_empty() && !trimmed.starts_with('#')
|
||||
});
|
||||
|
||||
if !common_has_content {
|
||||
return (live_toml.to_string(), false, None);
|
||||
}
|
||||
|
||||
// Parse live TOML
|
||||
let live_config: TomlValue = match live_toml.parse() {
|
||||
Ok(v) => v,
|
||||
Err(_) if live_toml.trim().is_empty() => TomlValue::Table(toml::map::Map::new()),
|
||||
Err(e) => {
|
||||
return (
|
||||
live_toml.to_string(),
|
||||
false,
|
||||
Some(format!("Failed to parse live TOML: {e}")),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Parse common TOML
|
||||
let common_config: TomlValue = match common_toml.parse() {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return (
|
||||
live_toml.to_string(),
|
||||
false,
|
||||
Some(format!("Failed to parse common TOML: {e}")),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let live_table = match &live_config {
|
||||
TomlValue::Table(m) => m,
|
||||
_ => return (live_toml.to_string(), false, None),
|
||||
};
|
||||
|
||||
let common_table = match &common_config {
|
||||
TomlValue::Table(m) => m,
|
||||
_ => return (live_toml.to_string(), false, None),
|
||||
};
|
||||
|
||||
let mut custom_table = toml::map::Map::new();
|
||||
let mut has_common_keys = false;
|
||||
|
||||
fn extract_recursive_toml(
|
||||
live: &toml::map::Map<String, TomlValue>,
|
||||
common: &toml::map::Map<String, TomlValue>,
|
||||
target: &mut toml::map::Map<String, TomlValue>,
|
||||
has_common: &mut bool,
|
||||
) {
|
||||
for (key, live_value) in live {
|
||||
match common.get(key) {
|
||||
None => {
|
||||
target.insert(key.clone(), live_value.clone());
|
||||
}
|
||||
Some(common_value) => match (live_value, common_value) {
|
||||
(TomlValue::Table(live_map), TomlValue::Table(common_map)) => {
|
||||
let mut nested = toml::map::Map::new();
|
||||
extract_recursive_toml(live_map, common_map, &mut nested, has_common);
|
||||
if !nested.is_empty() {
|
||||
target.insert(key.clone(), TomlValue::Table(nested));
|
||||
} else {
|
||||
*has_common = true;
|
||||
}
|
||||
}
|
||||
_ if !toml_deep_equal(live_value, common_value) => {
|
||||
target.insert(key.clone(), live_value.clone());
|
||||
}
|
||||
_ => {
|
||||
*has_common = true;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extract_recursive_toml(
|
||||
live_table,
|
||||
common_table,
|
||||
&mut custom_table,
|
||||
&mut has_common_keys,
|
||||
);
|
||||
|
||||
let custom_config = TomlValue::Table(custom_table);
|
||||
|
||||
match toml::to_string_pretty(&custom_config) {
|
||||
Ok(s) if s.trim().is_empty() => (String::new(), has_common_keys, None),
|
||||
Ok(s) => (s, has_common_keys, None),
|
||||
Err(e) => (
|
||||
live_toml.to_string(),
|
||||
false,
|
||||
Some(format!("Failed to serialize TOML: {e}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Live Config Merge for Provider Sync
|
||||
// ============================================================================
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::provider::{Provider, ProviderMeta};
|
||||
|
||||
/// Result of merging common config with provider's custom config.
|
||||
#[derive(Debug)]
|
||||
pub struct MergeResult {
|
||||
/// The final merged configuration
|
||||
pub config: JsonValue,
|
||||
/// Warning message if any (e.g., parse errors that were recovered from)
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
/// Check if common config is enabled for a provider and app type.
|
||||
///
|
||||
/// Priority:
|
||||
/// 1. `meta.common_config_enabled_by_app.{app_type}` (per-app setting)
|
||||
/// 2. `meta.common_config_enabled` (global setting)
|
||||
/// 3. `false` (default)
|
||||
pub fn is_common_config_enabled(meta: Option<&ProviderMeta>, app_type: &AppType) -> bool {
|
||||
meta.and_then(|m| {
|
||||
m.common_config_enabled_by_app
|
||||
.as_ref()
|
||||
.and_then(|by_app| match app_type {
|
||||
AppType::Claude => by_app.claude,
|
||||
AppType::Codex => by_app.codex,
|
||||
AppType::Gemini => by_app.gemini,
|
||||
AppType::OpenCode => by_app.opencode,
|
||||
})
|
||||
.or(m.common_config_enabled)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Merge common config with provider's custom config for live file writing.
|
||||
///
|
||||
/// This is the single source of truth for common config merging logic.
|
||||
/// Used by both `live.rs` and `proxy.rs`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `app_type` - The application type (Claude, Codex, Gemini, OpenCode)
|
||||
/// * `provider` - The provider whose config is being merged
|
||||
/// * `common_snippet` - The common config snippet from database (may be empty)
|
||||
///
|
||||
/// # Returns
|
||||
/// `MergeResult` containing the final config and optional warning
|
||||
pub fn merge_config_for_live(
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
common_snippet: Option<&str>,
|
||||
) -> MergeResult {
|
||||
// Check if common config is enabled
|
||||
let enabled = is_common_config_enabled(provider.meta.as_ref(), app_type);
|
||||
|
||||
// If not enabled or snippet is empty, return original config
|
||||
let snippet = match common_snippet {
|
||||
Some(s) if enabled && !s.trim().is_empty() => s,
|
||||
_ => {
|
||||
return MergeResult {
|
||||
config: provider.settings_config.clone(),
|
||||
warning: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Perform merge based on app type
|
||||
match app_type {
|
||||
AppType::Claude => merge_claude_config(&provider.settings_config, snippet),
|
||||
AppType::Codex => merge_codex_config(&provider.settings_config, snippet),
|
||||
AppType::Gemini => merge_gemini_config(&provider.settings_config, snippet),
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support common config merge
|
||||
MergeResult {
|
||||
config: provider.settings_config.clone(),
|
||||
warning: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge Claude config (JSON format).
|
||||
fn merge_claude_config(custom_config: &JsonValue, common_snippet: &str) -> MergeResult {
|
||||
let common_value: JsonValue = match serde_json::from_str(common_snippet) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
// Return warning without logging - caller will log if needed
|
||||
return MergeResult {
|
||||
config: custom_config.clone(),
|
||||
warning: Some(format!("COMMON_CONFIG_PARSE_ERROR: {e}")),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
MergeResult {
|
||||
config: compute_final_json_config(custom_config, &common_value, true),
|
||||
warning: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge Codex config (TOML for config field, JSON for auth field).
|
||||
fn merge_codex_config(custom_config: &JsonValue, common_snippet: &str) -> MergeResult {
|
||||
let mut merged_config = custom_config.clone();
|
||||
let mut warning = None;
|
||||
|
||||
if let Some(obj) = merged_config.as_object_mut() {
|
||||
if let Some(config_str) = obj.get("config").and_then(|v| v.as_str()) {
|
||||
let (merged_toml, error) =
|
||||
compute_final_toml_config_str(config_str, common_snippet, true);
|
||||
if let Some(e) = error {
|
||||
// Return warning without logging - caller will log if needed
|
||||
warning = Some(format!("CODEX_TOML_MERGE_ERROR: {e}"));
|
||||
}
|
||||
obj.insert("config".to_string(), JsonValue::String(merged_toml));
|
||||
}
|
||||
}
|
||||
|
||||
MergeResult {
|
||||
config: merged_config,
|
||||
warning,
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge Gemini config (JSON format for env field).
|
||||
///
|
||||
/// Gemini common config can be stored in three formats:
|
||||
/// - ENV format: KEY=VALUE lines (one per line)
|
||||
/// - Flat JSON: `{"KEY": "VALUE", ...}`
|
||||
/// - Wrapped JSON: `{"env": {"KEY": "VALUE", ...}}`
|
||||
///
|
||||
/// This function supports all formats for backward compatibility.
|
||||
fn merge_gemini_config(custom_config: &JsonValue, common_snippet: &str) -> MergeResult {
|
||||
// Parse and validate common config (filters forbidden keys)
|
||||
let validation = validate_gemini_common_snippet(common_snippet);
|
||||
let common_env = validation.env;
|
||||
|
||||
// Generate warning if forbidden keys were found
|
||||
let warning = if !validation.forbidden_keys_found.is_empty() {
|
||||
Some(format!(
|
||||
"GEMINI_FORBIDDEN_KEYS:{}",
|
||||
validation.forbidden_keys_found.join(",")
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if common_env.is_empty() {
|
||||
return MergeResult {
|
||||
config: custom_config.clone(),
|
||||
warning,
|
||||
};
|
||||
}
|
||||
|
||||
let mut merged_config = custom_config.clone();
|
||||
|
||||
// Merge only the env field
|
||||
// If custom config has env, merge common into it; otherwise initialize with common env
|
||||
if let Some(merged_obj) = merged_config.as_object_mut() {
|
||||
if let Some(merged_env) = merged_obj.get_mut("env") {
|
||||
if let Some(merged_env_obj) = merged_env.as_object_mut() {
|
||||
// Common env as base, custom env overrides
|
||||
let mut final_env = common_env;
|
||||
for (k, v) in merged_env_obj.iter() {
|
||||
final_env.insert(k.clone(), v.clone());
|
||||
}
|
||||
*merged_env = JsonValue::Object(final_env);
|
||||
}
|
||||
} else if !common_env.is_empty() {
|
||||
// Custom config has no env field - initialize with common env
|
||||
merged_obj.insert("env".to_string(), JsonValue::Object(common_env));
|
||||
}
|
||||
}
|
||||
|
||||
MergeResult {
|
||||
config: merged_config,
|
||||
warning,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse Gemini common config snippet supporting multiple formats.
|
||||
///
|
||||
/// Formats supported:
|
||||
/// - ENV format: KEY=VALUE lines
|
||||
/// - Flat JSON: {"KEY": "VALUE", ...}
|
||||
/// - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
|
||||
pub(crate) fn parse_gemini_common_snippet(snippet: &str) -> serde_json::Map<String, JsonValue> {
|
||||
let trimmed = snippet.trim();
|
||||
if trimmed.is_empty() {
|
||||
return serde_json::Map::new();
|
||||
}
|
||||
|
||||
// Try JSON first
|
||||
if let Ok(parsed) = serde_json::from_str::<JsonValue>(trimmed) {
|
||||
if let Some(obj) = parsed.as_object() {
|
||||
// Check if it's wrapped format {"env": {...}}
|
||||
if let Some(env_value) = obj.get("env").and_then(|v| v.as_object()) {
|
||||
return env_value.clone();
|
||||
}
|
||||
// Flat format
|
||||
return obj.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// Parse as ENV format (KEY=VALUE lines)
|
||||
let mut result = serde_json::Map::new();
|
||||
for line in trimmed.lines() {
|
||||
let line_trimmed = line.trim();
|
||||
if line_trimmed.is_empty() || line_trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some(equal_index) = line_trimmed.find('=') {
|
||||
let key = line_trimmed[..equal_index].trim();
|
||||
let raw_value = line_trimmed[equal_index + 1..].trim();
|
||||
// Strip surrounding quotes (single or double) from value
|
||||
// e.g., KEY="value" or KEY='value' -> value
|
||||
let value = strip_env_quotes(raw_value);
|
||||
if !key.is_empty() {
|
||||
result.insert(key.to_string(), JsonValue::String(value.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Gemini common config forbidden keys - these should never be in common config
|
||||
/// as they are provider-specific credentials/endpoints.
|
||||
const GEMINI_FORBIDDEN_KEYS: &[&str] = &["GOOGLE_GEMINI_BASE_URL", "GEMINI_API_KEY"];
|
||||
|
||||
/// Result of validating Gemini common config snippet
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GeminiSnippetValidation {
|
||||
/// Parsed environment variables (with forbidden keys filtered out)
|
||||
pub env: serde_json::Map<String, JsonValue>,
|
||||
/// List of forbidden keys that were found and filtered
|
||||
pub forbidden_keys_found: Vec<String>,
|
||||
/// Whether the snippet is valid (parseable and non-empty after filtering)
|
||||
pub is_valid: bool,
|
||||
}
|
||||
|
||||
/// Parse and validate Gemini common config snippet.
|
||||
///
|
||||
/// This function:
|
||||
/// 1. Parses the snippet (supports ENV/JSON formats)
|
||||
/// 2. Filters out forbidden keys (GOOGLE_GEMINI_BASE_URL, GEMINI_API_KEY)
|
||||
/// 3. Returns validation result with filtered env and forbidden keys found
|
||||
pub fn validate_gemini_common_snippet(snippet: &str) -> GeminiSnippetValidation {
|
||||
let raw_env = parse_gemini_common_snippet(snippet);
|
||||
let mut filtered_env = serde_json::Map::new();
|
||||
let mut forbidden_keys_found = Vec::new();
|
||||
|
||||
for (key, value) in raw_env {
|
||||
if GEMINI_FORBIDDEN_KEYS.contains(&key.as_str()) {
|
||||
forbidden_keys_found.push(key);
|
||||
} else {
|
||||
filtered_env.insert(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
GeminiSnippetValidation {
|
||||
is_valid: !filtered_env.is_empty() || snippet.trim().is_empty(),
|
||||
env: filtered_env,
|
||||
forbidden_keys_found,
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip surrounding quotes from ENV value.
|
||||
/// Supports both single and double quotes: "value" -> value, 'value' -> value
|
||||
fn strip_env_quotes(s: &str) -> &str {
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() >= 2 {
|
||||
let first = bytes[0];
|
||||
let last = bytes[bytes.len() - 1];
|
||||
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
|
||||
return &s[1..s.len() - 1];
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Unit Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_compute_final_json_config_disabled() {
|
||||
let custom = json!({"a": 1, "b": 2});
|
||||
let common = json!({"c": 3});
|
||||
|
||||
let result = compute_final_json_config(&custom, &common, false);
|
||||
assert_eq!(result, custom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_final_json_config_enabled() {
|
||||
let custom = json!({"a": 1, "b": 2});
|
||||
let common = json!({"b": 99, "c": 3});
|
||||
|
||||
let result = compute_final_json_config(&custom, &common, true);
|
||||
|
||||
// custom overrides common, so b should be 2
|
||||
assert_eq!(result["a"], 1);
|
||||
assert_eq!(result["b"], 2); // custom wins
|
||||
assert_eq!(result["c"], 3); // from common
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_final_json_config_nested() {
|
||||
let custom = json!({
|
||||
"env": {
|
||||
"API_KEY": "custom-key",
|
||||
"CUSTOM_VAR": "value"
|
||||
}
|
||||
});
|
||||
let common = json!({
|
||||
"env": {
|
||||
"API_KEY": "common-key",
|
||||
"SHARED_VAR": "shared"
|
||||
},
|
||||
"includeCoAuthoredBy": false
|
||||
});
|
||||
|
||||
let result = compute_final_json_config(&custom, &common, true);
|
||||
|
||||
assert_eq!(result["env"]["API_KEY"], "custom-key"); // custom wins
|
||||
assert_eq!(result["env"]["CUSTOM_VAR"], "value"); // from custom
|
||||
assert_eq!(result["env"]["SHARED_VAR"], "shared"); // from common
|
||||
assert_eq!(result["includeCoAuthoredBy"], false); // from common
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_json_difference() {
|
||||
let live = json!({
|
||||
"env": {
|
||||
"API_KEY": "my-key",
|
||||
"SHARED_VAR": "shared"
|
||||
},
|
||||
"includeCoAuthoredBy": false,
|
||||
"custom_field": true
|
||||
});
|
||||
let common = json!({
|
||||
"env": {
|
||||
"SHARED_VAR": "shared"
|
||||
},
|
||||
"includeCoAuthoredBy": false
|
||||
});
|
||||
|
||||
let (custom, has_common) = extract_json_difference(&live, &common);
|
||||
|
||||
// Should keep API_KEY (not in common) and custom_field
|
||||
assert_eq!(custom["env"]["API_KEY"], "my-key");
|
||||
assert_eq!(custom["custom_field"], true);
|
||||
// Should NOT have SHARED_VAR or includeCoAuthoredBy (same as common)
|
||||
assert!(custom["env"].get("SHARED_VAR").is_none());
|
||||
assert!(custom.get("includeCoAuthoredBy").is_none());
|
||||
assert!(has_common);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_json_difference_with_override() {
|
||||
let live = json!({
|
||||
"includeCoAuthoredBy": true, // Different from common!
|
||||
"shared": "value"
|
||||
});
|
||||
let common = json!({
|
||||
"includeCoAuthoredBy": false,
|
||||
"shared": "value"
|
||||
});
|
||||
|
||||
let (custom, has_common) = extract_json_difference(&live, &common);
|
||||
|
||||
// Should keep includeCoAuthoredBy because value is different
|
||||
assert_eq!(custom["includeCoAuthoredBy"], true);
|
||||
// Should NOT have shared (same as common)
|
||||
assert!(custom.get("shared").is_none());
|
||||
assert!(has_common);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_final_toml_config() {
|
||||
let custom = r#"
|
||||
model = "custom-model"
|
||||
[custom_section]
|
||||
key = "value"
|
||||
"#;
|
||||
let common = r#"
|
||||
model = "common-model"
|
||||
shared_key = "shared"
|
||||
"#;
|
||||
|
||||
let (result, error) = compute_final_toml_config_str(custom, common, true);
|
||||
|
||||
assert!(error.is_none());
|
||||
assert!(result.contains("custom-model")); // custom wins
|
||||
assert!(result.contains("shared_key")); // from common
|
||||
|
||||
// With preserve_order feature enabled, verify key ordering via parsing
|
||||
// instead of relying on string position (which is fragile)
|
||||
let parsed: TomlValue = result.parse().expect("result should be valid TOML");
|
||||
let table = parsed.as_table().expect("result should be a table");
|
||||
let keys: Vec<&String> = table.keys().collect();
|
||||
|
||||
// Custom keys should appear before common-only keys
|
||||
let model_idx = keys.iter().position(|k| *k == "model");
|
||||
let custom_section_idx = keys.iter().position(|k| *k == "custom_section");
|
||||
let shared_key_idx = keys.iter().position(|k| *k == "shared_key");
|
||||
|
||||
assert!(model_idx.is_some(), "model key should exist in result");
|
||||
assert!(
|
||||
custom_section_idx.is_some(),
|
||||
"custom_section key should exist in result"
|
||||
);
|
||||
assert!(
|
||||
shared_key_idx.is_some(),
|
||||
"shared_key key should exist in result"
|
||||
);
|
||||
|
||||
// With preserve_order, custom keys (model, custom_section) should come before common-only keys (shared_key)
|
||||
assert!(
|
||||
model_idx.unwrap() < shared_key_idx.unwrap(),
|
||||
"custom 'model' should appear before common-only 'shared_key' (got model_idx={:?}, shared_key_idx={:?})",
|
||||
model_idx, shared_key_idx
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_toml_difference() {
|
||||
let live = r#"
|
||||
model = "my-model"
|
||||
shared_key = "shared"
|
||||
[custom_section]
|
||||
key = "value"
|
||||
"#;
|
||||
let common = r#"
|
||||
shared_key = "shared"
|
||||
"#;
|
||||
|
||||
let (custom, has_common, error) = extract_toml_difference_str(live, common);
|
||||
|
||||
assert!(error.is_none());
|
||||
assert!(custom.contains("model")); // not in common
|
||||
assert!(custom.contains("custom_section")); // not in common
|
||||
assert!(!custom.contains("shared_key")); // same as common
|
||||
assert!(has_common);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! 提供 SQL 导出/导入和二进制快照备份功能。
|
||||
|
||||
use super::{lock_conn, Database};
|
||||
use super::{lock_conn, Database, DB_BACKUP_RETAIN};
|
||||
use crate::config::get_app_config_dir;
|
||||
use crate::error::AppError;
|
||||
use chrono::Utc;
|
||||
@@ -15,25 +15,11 @@ use tempfile::NamedTempFile;
|
||||
|
||||
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
|
||||
|
||||
/// A database backup entry for the UI
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackupEntry {
|
||||
pub filename: String,
|
||||
pub size_bytes: u64,
|
||||
pub created_at: String, // ISO 8601
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// 导出为 SQLite 兼容的 SQL 文本(内存字符串)
|
||||
pub fn export_sql_string(&self) -> Result<String, AppError> {
|
||||
let snapshot = self.snapshot_to_memory()?;
|
||||
Self::dump_sql(&snapshot)
|
||||
}
|
||||
|
||||
/// 导出为 SQLite 兼容的 SQL 文本
|
||||
pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
|
||||
let dump = self.export_sql_string()?;
|
||||
let snapshot = self.snapshot_to_memory()?;
|
||||
let dump = Self::dump_sql(&snapshot)?;
|
||||
|
||||
if let Some(parent) = target_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
@@ -52,12 +38,6 @@ impl Database {
|
||||
}
|
||||
|
||||
let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?;
|
||||
let sql_content = sql_raw.trim_start_matches('\u{feff}');
|
||||
self.import_sql_string(sql_content)
|
||||
}
|
||||
|
||||
/// 从 SQL 字符串导入,返回生成的备份 ID(若无备份则为空字符串)
|
||||
pub fn import_sql_string(&self, sql_raw: &str) -> Result<String, AppError> {
|
||||
let sql_content = sql_raw.trim_start_matches('\u{feff}');
|
||||
Self::validate_cc_switch_sql_export(sql_content)?;
|
||||
|
||||
@@ -129,47 +109,8 @@ impl Database {
|
||||
))
|
||||
}
|
||||
|
||||
/// Periodic backup: create a new backup if the latest one is older than the configured interval
|
||||
pub(crate) fn periodic_backup_if_needed(&self) -> Result<(), AppError> {
|
||||
let interval_hours = crate::settings::effective_backup_interval_hours();
|
||||
if interval_hours == 0 {
|
||||
return Ok(()); // Auto-backup disabled
|
||||
}
|
||||
|
||||
let backup_dir = get_app_config_dir().join("backups");
|
||||
if !backup_dir.exists() {
|
||||
self.backup_database_file()?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let latest = fs::read_dir(&backup_dir).ok().and_then(|entries| {
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
|
||||
.filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()))
|
||||
.max()
|
||||
});
|
||||
|
||||
let interval_secs = u64::from(interval_hours) * 3600;
|
||||
let needs_backup = match latest {
|
||||
None => true,
|
||||
Some(last_modified) => {
|
||||
last_modified.elapsed().unwrap_or_default()
|
||||
> std::time::Duration::from_secs(interval_secs)
|
||||
}
|
||||
};
|
||||
|
||||
if needs_backup {
|
||||
log::info!(
|
||||
"Periodic backup: latest backup is older than {interval_hours} hours, creating new backup"
|
||||
);
|
||||
self.backup_database_file()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
|
||||
pub(crate) fn backup_database_file(&self) -> Result<Option<PathBuf>, AppError> {
|
||||
fn backup_database_file(&self) -> Result<Option<PathBuf>, AppError> {
|
||||
let db_path = get_app_config_dir().join("cc-switch.db");
|
||||
if !db_path.exists() {
|
||||
return Ok(None);
|
||||
@@ -209,7 +150,6 @@ impl Database {
|
||||
|
||||
/// 清理旧的数据库备份,保留最新的 N 个
|
||||
fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> {
|
||||
let retain = crate::settings::effective_backup_retain_count();
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(iter) => iter
|
||||
.filter_map(|entry| entry.ok())
|
||||
@@ -224,11 +164,11 @@ impl Database {
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
if entries.len() <= retain {
|
||||
if entries.len() <= DB_BACKUP_RETAIN {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let remove_count = entries.len().saturating_sub(retain);
|
||||
let remove_count = entries.len().saturating_sub(DB_BACKUP_RETAIN);
|
||||
let mut sorted = entries;
|
||||
sorted.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok());
|
||||
|
||||
@@ -382,153 +322,4 @@ impl Database {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List all database backup files, sorted by creation time (newest first)
|
||||
pub fn list_backups() -> Result<Vec<BackupEntry>, AppError> {
|
||||
let backup_dir = get_app_config_dir().join("backups");
|
||||
if !backup_dir.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut entries: Vec<BackupEntry> = fs::read_dir(&backup_dir)
|
||||
.map_err(|e| AppError::io(&backup_dir, e))?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
|
||||
.filter_map(|e| {
|
||||
let metadata = e.metadata().ok()?;
|
||||
let filename = e.file_name().to_string_lossy().to_string();
|
||||
let size_bytes = metadata.len();
|
||||
let created_at = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.map(|t| {
|
||||
let dt: chrono::DateTime<Utc> = t.into();
|
||||
dt.to_rfc3339()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Some(BackupEntry {
|
||||
filename,
|
||||
size_bytes,
|
||||
created_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by created_at descending (newest first)
|
||||
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Restore database from a backup file. Returns the safety backup ID.
|
||||
pub fn restore_from_backup(&self, filename: &str) -> Result<String, AppError> {
|
||||
// Security: validate filename to prevent path traversal
|
||||
if filename.contains("..")
|
||||
|| filename.contains('/')
|
||||
|| filename.contains('\\')
|
||||
|| !filename.ends_with(".db")
|
||||
{
|
||||
return Err(AppError::InvalidInput(
|
||||
"Invalid backup filename".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let backup_dir = get_app_config_dir().join("backups");
|
||||
let backup_path = backup_dir.join(filename);
|
||||
|
||||
if !backup_path.exists() {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Backup file not found: {filename}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Step 1: Create safety backup of current database
|
||||
let safety_backup = self.backup_database_file()?;
|
||||
let safety_id = safety_backup
|
||||
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Step 2: Open the backup file and restore it to the main database
|
||||
let source_conn =
|
||||
Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
{
|
||||
let mut main_conn = lock_conn!(self.conn);
|
||||
let backup = Backup::new(&source_conn, &mut main_conn)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
backup
|
||||
.step(-1)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
}
|
||||
|
||||
// Step 3: Run schema migrations (backup may be from an older version)
|
||||
self.create_tables()?;
|
||||
self.apply_schema_migrations()?;
|
||||
self.ensure_model_pricing_seeded()?;
|
||||
|
||||
log::info!("Database restored from backup: {filename}, safety backup: {safety_id}");
|
||||
Ok(safety_id)
|
||||
}
|
||||
|
||||
/// Rename a backup file. Returns the new filename.
|
||||
pub fn rename_backup(old_filename: &str, new_name: &str) -> Result<String, AppError> {
|
||||
// Validate old filename (path traversal + .db suffix)
|
||||
if old_filename.contains("..")
|
||||
|| old_filename.contains('/')
|
||||
|| old_filename.contains('\\')
|
||||
|| !old_filename.ends_with(".db")
|
||||
{
|
||||
return Err(AppError::InvalidInput(
|
||||
"Invalid backup filename".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Clean new name
|
||||
let trimmed = new_name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"New name cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Length limit (without .db suffix)
|
||||
let name_part = trimmed.strip_suffix(".db").unwrap_or(trimmed);
|
||||
if name_part.len() > 100 {
|
||||
return Err(AppError::InvalidInput(
|
||||
"Name too long (max 100 characters)".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Prevent path traversal in new name
|
||||
if name_part.contains("..")
|
||||
|| name_part.contains('/')
|
||||
|| name_part.contains('\\')
|
||||
|| name_part.contains('\0')
|
||||
{
|
||||
return Err(AppError::InvalidInput(
|
||||
"Invalid characters in new name".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let new_filename = format!("{name_part}.db");
|
||||
|
||||
let backup_dir = get_app_config_dir().join("backups");
|
||||
let old_path = backup_dir.join(old_filename);
|
||||
let new_path = backup_dir.join(&new_filename);
|
||||
|
||||
if !old_path.exists() {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Backup file not found: {old_filename}"
|
||||
)));
|
||||
}
|
||||
|
||||
if new_path.exists() {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"A backup named '{new_filename}' already exists"
|
||||
)));
|
||||
}
|
||||
|
||||
fs::rename(&old_path, &new_path).map_err(|e| AppError::io(&old_path, e))?;
|
||||
log::info!("Renamed backup: {old_filename} -> {new_filename}");
|
||||
Ok(new_filename)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,23 +55,19 @@ impl Default for OmoGlobalConfig {
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn get_omo_global_config(&self, key: &str) -> Result<OmoGlobalConfig, AppError> {
|
||||
let json_str = self.get_setting(key)?;
|
||||
pub fn get_omo_global_config(&self) -> Result<OmoGlobalConfig, AppError> {
|
||||
let json_str = self.get_setting("common_config_omo")?;
|
||||
match json_str {
|
||||
Some(s) => serde_json::from_str::<OmoGlobalConfig>(&s)
|
||||
.map_err(|e| AppError::Config(format!("Failed to parse {key}: {e}"))),
|
||||
.map_err(|e| AppError::Config(format!("Failed to parse common_config_omo: {e}"))),
|
||||
None => Ok(OmoGlobalConfig::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_omo_global_config(
|
||||
&self,
|
||||
key: &str,
|
||||
config: &OmoGlobalConfig,
|
||||
) -> Result<(), AppError> {
|
||||
pub fn save_omo_global_config(&self, config: &OmoGlobalConfig) -> Result<(), AppError> {
|
||||
let json_str = serde_json::to_string(config)
|
||||
.map_err(|e| AppError::Config(format!("JSON serialization failed: {e}")))?;
|
||||
self.set_setting(key, &json_str)?;
|
||||
self.set_setting("common_config_omo", &json_str)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,26 +364,25 @@ impl Database {
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider_id: &str,
|
||||
category: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
tx.execute(
|
||||
"UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = ?2",
|
||||
params![app_type, category],
|
||||
"UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = 'omo'",
|
||||
params![app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
let updated = tx
|
||||
.execute(
|
||||
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2 AND category = ?3",
|
||||
params![provider_id, app_type, category],
|
||||
)
|
||||
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
|
||||
params![provider_id, app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
if updated != 1 {
|
||||
return Err(AppError::Database(format!(
|
||||
"Failed to set {category} provider current: provider '{provider_id}' not found in app '{app_type}'"
|
||||
"Failed to set OMO provider current: provider '{provider_id}' not found in app '{app_type}'"
|
||||
)));
|
||||
}
|
||||
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -394,13 +393,12 @@ impl Database {
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider_id: &str,
|
||||
category: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
match conn.query_row(
|
||||
"SELECT is_current FROM providers
|
||||
WHERE id = ?1 AND app_type = ?2 AND category = ?3",
|
||||
params![provider_id, app_type, category],
|
||||
WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
|
||||
params![provider_id, app_type],
|
||||
|row| row.get(0),
|
||||
) {
|
||||
Ok(is_current) => Ok(is_current),
|
||||
@@ -413,30 +411,25 @@ impl Database {
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider_id: &str,
|
||||
category: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"UPDATE providers SET is_current = 0
|
||||
WHERE id = ?1 AND app_type = ?2 AND category = ?3",
|
||||
params![provider_id, app_type, category],
|
||||
WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
|
||||
params![provider_id, app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_current_omo_provider(
|
||||
&self,
|
||||
app_type: &str,
|
||||
category: &str,
|
||||
) -> Result<Option<Provider>, AppError> {
|
||||
pub fn get_current_omo_provider(&self, app_type: &str) -> Result<Option<Provider>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let row_data: Result<OmoProviderRow, rusqlite::Error> = conn.query_row(
|
||||
"SELECT id, name, settings_config, category, created_at, sort_index, notes, meta
|
||||
FROM providers
|
||||
WHERE app_type = ?1 AND category = ?2 AND is_current = 1
|
||||
WHERE app_type = ?1 AND category = 'omo' AND is_current = 1
|
||||
LIMIT 1",
|
||||
params![app_type, category],
|
||||
params![app_type],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
@@ -451,7 +444,7 @@ impl Database {
|
||||
},
|
||||
);
|
||||
|
||||
let (id, name, settings_config_str, _row_category, created_at, sort_index, notes, meta_str) =
|
||||
let (id, name, settings_config_str, category, created_at, sort_index, notes, meta_str) =
|
||||
match row_data {
|
||||
Ok(v) => v,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
|
||||
@@ -460,7 +453,7 @@ impl Database {
|
||||
|
||||
let settings_config = serde_json::from_str(&settings_config_str).map_err(|e| {
|
||||
AppError::Database(format!(
|
||||
"Failed to parse {category} provider settings_config (provider_id={id}): {e}"
|
||||
"Failed to parse OMO provider settings_config (provider_id={id}): {e}"
|
||||
))
|
||||
})?;
|
||||
let meta: crate::provider::ProviderMeta = if meta_str.trim().is_empty() {
|
||||
@@ -468,7 +461,7 @@ impl Database {
|
||||
} else {
|
||||
serde_json::from_str(&meta_str).map_err(|e| {
|
||||
AppError::Database(format!(
|
||||
"Failed to parse {category} provider meta (provider_id={id}): {e}"
|
||||
"Failed to parse OMO provider meta (provider_id={id}): {e}"
|
||||
))
|
||||
})?
|
||||
};
|
||||
@@ -478,7 +471,7 @@ impl Database {
|
||||
name,
|
||||
settings_config,
|
||||
website_url: None,
|
||||
category: Some(category.to_string()),
|
||||
category,
|
||||
created_at,
|
||||
sort_index,
|
||||
notes,
|
||||
|
||||
@@ -58,6 +58,9 @@ impl Database {
|
||||
// 4. 迁移 Skills
|
||||
Self::migrate_skills(tx, config)?;
|
||||
|
||||
// 5. 迁移 Common Config
|
||||
Self::migrate_common_config(tx, config)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -209,4 +212,34 @@ impl Database {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 迁移通用配置片段
|
||||
fn migrate_common_config(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
config: &MultiAppConfig,
|
||||
) -> Result<(), AppError> {
|
||||
if let Some(snippet) = &config.common_config_snippets.claude {
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["common_config_claude", snippet],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
||||
}
|
||||
if let Some(snippet) = &config.common_config_snippets.codex {
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["common_config_codex", snippet],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
||||
}
|
||||
if let Some(snippet) = &config.common_config_snippets.gemini {
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["common_config_gemini", snippet],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
//! └── settings.rs
|
||||
//! ```
|
||||
|
||||
pub(crate) mod backup;
|
||||
mod backup;
|
||||
mod dao;
|
||||
mod migration;
|
||||
mod schema;
|
||||
@@ -37,12 +37,15 @@ pub use dao::OmoGlobalConfig;
|
||||
|
||||
use crate::config::get_app_config_dir;
|
||||
use crate::error::AppError;
|
||||
use rusqlite::{hooks::Action, Connection};
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// DAO 方法通过 impl Database 提供,无需额外导出
|
||||
|
||||
/// 数据库备份保留数量
|
||||
const DB_BACKUP_RETAIN: usize = 10;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 5;
|
||||
@@ -73,17 +76,6 @@ 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 {
|
||||
/// 初始化数据库连接并创建表
|
||||
///
|
||||
@@ -101,28 +93,11 @@ 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),
|
||||
};
|
||||
db.create_tables()?;
|
||||
|
||||
// Pre-migration backup: only when upgrading from an existing database
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
let version = Self::get_user_version(&conn)?;
|
||||
drop(conn);
|
||||
if version > 0 && version < SCHEMA_VERSION {
|
||||
log::info!(
|
||||
"Creating pre-migration database backup (v{version} → v{SCHEMA_VERSION})"
|
||||
);
|
||||
if let Err(e) = db.backup_database_file() {
|
||||
log::warn!("Pre-migration backup failed, continuing migration: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.apply_schema_migrations()?;
|
||||
db.ensure_model_pricing_seeded()?;
|
||||
|
||||
@@ -136,7 +111,6 @@ 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,15 +297,6 @@ fn schema_migration_v4_adds_pricing_model_columns() {
|
||||
r#"
|
||||
CREATE TABLE proxy_config (app_type TEXT PRIMARY KEY);
|
||||
CREATE TABLE proxy_request_logs (request_id TEXT PRIMARY KEY, model TEXT NOT NULL);
|
||||
CREATE TABLE mcp_servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
server_config TEXT NOT NULL,
|
||||
enabled_claude INTEGER NOT NULL DEFAULT 0,
|
||||
enabled_codex INTEGER NOT NULL DEFAULT 0,
|
||||
enabled_gemini INTEGER NOT NULL DEFAULT 0,
|
||||
enabled_opencode INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.expect("seed v4 schema");
|
||||
@@ -513,6 +504,8 @@ fn schema_dry_run_does_not_write_to_disk() {
|
||||
mcp: Default::default(),
|
||||
prompts: Default::default(),
|
||||
skills: Default::default(),
|
||||
common_config_snippets: Default::default(),
|
||||
claude_common_config_snippet: None,
|
||||
};
|
||||
|
||||
// Dry-run should succeed without any file I/O errors
|
||||
@@ -561,6 +554,8 @@ fn dry_run_validates_schema_compatibility() {
|
||||
mcp: Default::default(),
|
||||
prompts: Default::default(),
|
||||
skills: Default::default(),
|
||||
common_config_snippets: Default::default(),
|
||||
claude_common_config_snippet: None,
|
||||
};
|
||||
|
||||
// Dry-run should validate the full migration path
|
||||
|
||||
@@ -175,10 +175,6 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
||||
"codex" => apps.codex = true,
|
||||
"gemini" => apps.gemini = true,
|
||||
"opencode" => apps.opencode = true,
|
||||
"openclaw" => {
|
||||
// OpenClaw doesn't support MCP, ignore silently
|
||||
log::debug!("OpenClaw doesn't support MCP, ignoring in apps parameter");
|
||||
}
|
||||
other => {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app in 'apps': {other}"
|
||||
|
||||
@@ -146,7 +146,6 @@ pub(crate) fn build_provider_from_request(
|
||||
AppType::Codex => build_codex_settings(request),
|
||||
AppType::Gemini => build_gemini_settings(request),
|
||||
AppType::OpenCode => build_opencode_settings(request),
|
||||
AppType::OpenClaw => build_openclaw_settings(request),
|
||||
};
|
||||
|
||||
// Build usage script configuration if provided
|
||||
@@ -181,58 +180,68 @@ fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String {
|
||||
}
|
||||
|
||||
/// Build provider meta with usage script configuration
|
||||
///
|
||||
/// Note: Deeplink imported providers have common config disabled by default
|
||||
/// to avoid unexpected configuration merging.
|
||||
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
|
||||
// Check if any usage script fields are provided
|
||||
if request.usage_script.is_none()
|
||||
&& request.usage_enabled.is_none()
|
||||
&& request.usage_api_key.is_none()
|
||||
&& request.usage_base_url.is_none()
|
||||
&& request.usage_access_token.is_none()
|
||||
&& request.usage_user_id.is_none()
|
||||
&& request.usage_auto_interval.is_none()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let has_usage_fields = request.usage_script.is_some()
|
||||
|| request.usage_enabled.is_some()
|
||||
|| request.usage_api_key.is_some()
|
||||
|| request.usage_base_url.is_some()
|
||||
|| request.usage_access_token.is_some()
|
||||
|| request.usage_user_id.is_some()
|
||||
|| request.usage_auto_interval.is_some();
|
||||
|
||||
// Decode usage script code if provided
|
||||
let code = if let Some(script_b64) = &request.usage_script {
|
||||
let decoded = decode_base64_param("usage_script", script_b64)?;
|
||||
String::from_utf8(decoded)
|
||||
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in usage_script: {e}")))?
|
||||
// Build usage script if fields are provided
|
||||
let usage_script = if has_usage_fields {
|
||||
// Decode usage script code if provided
|
||||
let code = if let Some(script_b64) = &request.usage_script {
|
||||
let decoded = decode_base64_param("usage_script", script_b64)?;
|
||||
String::from_utf8(decoded).map_err(|e| {
|
||||
AppError::InvalidInput(format!("Invalid UTF-8 in usage_script: {e}"))
|
||||
})?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Determine enabled state: explicit param > has code > false
|
||||
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
||||
|
||||
// Build UsageScript - use provider's API key and endpoint as defaults
|
||||
// Note: use primary endpoint only (first one if comma-separated)
|
||||
Some(UsageScript {
|
||||
enabled,
|
||||
language: "javascript".to_string(),
|
||||
code,
|
||||
timeout: Some(10),
|
||||
api_key: request
|
||||
.usage_api_key
|
||||
.clone()
|
||||
.or_else(|| request.api_key.clone()),
|
||||
base_url: request.usage_base_url.clone().or_else(|| {
|
||||
let primary = get_primary_endpoint(request);
|
||||
if primary.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(primary)
|
||||
}
|
||||
}),
|
||||
access_token: request.usage_access_token.clone(),
|
||||
user_id: request.usage_user_id.clone(),
|
||||
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
||||
auto_query_interval: request.usage_auto_interval,
|
||||
})
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Determine enabled state: explicit param > has code > false
|
||||
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
||||
|
||||
// Build UsageScript - use provider's API key and endpoint as defaults
|
||||
// Note: use primary endpoint only (first one if comma-separated)
|
||||
let usage_script = UsageScript {
|
||||
enabled,
|
||||
language: "javascript".to_string(),
|
||||
code,
|
||||
timeout: Some(10),
|
||||
api_key: request
|
||||
.usage_api_key
|
||||
.clone()
|
||||
.or_else(|| request.api_key.clone()),
|
||||
base_url: request.usage_base_url.clone().or_else(|| {
|
||||
let primary = get_primary_endpoint(request);
|
||||
if primary.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(primary)
|
||||
}
|
||||
}),
|
||||
access_token: request.usage_access_token.clone(),
|
||||
user_id: request.usage_user_id.clone(),
|
||||
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
||||
auto_query_interval: request.usage_auto_interval,
|
||||
None
|
||||
};
|
||||
|
||||
// Always return a ProviderMeta with common_config_enabled = false for deeplink imports
|
||||
// This ensures the imported provider uses its own settings_config directly
|
||||
// without merging with the global common config snippet
|
||||
Ok(Some(ProviderMeta {
|
||||
usage_script: Some(usage_script),
|
||||
usage_script,
|
||||
common_config_enabled: Some(false),
|
||||
..Default::default()
|
||||
}))
|
||||
}
|
||||
@@ -392,35 +401,6 @@ fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value
|
||||
})
|
||||
}
|
||||
|
||||
fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
let endpoint = get_primary_endpoint(request);
|
||||
|
||||
// Build OpenClaw provider config
|
||||
// Format: { baseUrl, apiKey, api, models }
|
||||
let mut config = serde_json::Map::new();
|
||||
|
||||
if !endpoint.is_empty() {
|
||||
config.insert("baseUrl".to_string(), json!(endpoint));
|
||||
}
|
||||
|
||||
if let Some(api_key) = &request.api_key {
|
||||
config.insert("apiKey".to_string(), json!(api_key));
|
||||
}
|
||||
|
||||
// Default to OpenAI-compatible API
|
||||
config.insert("api".to_string(), json!("openai-completions"));
|
||||
|
||||
// Build models array
|
||||
if let Some(model) = &request.model {
|
||||
config.insert(
|
||||
"models".to_string(),
|
||||
json!([{ "id": model, "name": model }]),
|
||||
);
|
||||
}
|
||||
|
||||
json!(config)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Config Merge Logic
|
||||
// =============================================================================
|
||||
@@ -482,10 +462,6 @@ pub fn parse_and_merge_config(
|
||||
"claude" => merge_claude_config(&mut merged, &config_value)?,
|
||||
"codex" => merge_codex_config(&mut merged, &config_value)?,
|
||||
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
|
||||
// Additive mode apps use JSON config directly; pass through as-is
|
||||
"openclaw" | "opencode" => {
|
||||
merge_additive_config(&mut merged, &config_value)?;
|
||||
}
|
||||
"" => {
|
||||
// No app specified, skip merging
|
||||
return Ok(merged);
|
||||
@@ -657,47 +633,6 @@ fn merge_gemini_config(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Merge configuration for additive mode apps (OpenClaw, OpenCode)
|
||||
///
|
||||
/// These apps use JSON config directly, so we only extract common fields
|
||||
/// (api_key, endpoint, model) from the config if not already set in URL params.
|
||||
fn merge_additive_config(
|
||||
request: &mut DeepLinkImportRequest,
|
||||
config: &serde_json::Value,
|
||||
) -> Result<(), AppError> {
|
||||
// Extract api_key from config if not provided in URL
|
||||
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(api_key) = config
|
||||
.get("apiKey")
|
||||
.or_else(|| config.get("api_key"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
request.api_key = Some(api_key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Extract endpoint from config if not provided in URL
|
||||
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(base_url) = config
|
||||
.get("baseUrl")
|
||||
.or_else(|| config.get("base_url"))
|
||||
.or_else(|| config.get("options").and_then(|o| o.get("baseURL")))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
request.endpoint = Some(base_url.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-fill homepage from endpoint
|
||||
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
|
||||
request.homepage = infer_homepage_from_endpoint(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract base_url from Codex TOML config
|
||||
fn extract_codex_base_url(toml_value: &toml::Value) -> Option<String> {
|
||||
// Try to find base_url in model_providers section
|
||||
|
||||
+52
-103
@@ -6,6 +6,7 @@ mod claude_plugin;
|
||||
mod codex_config;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod config_merge;
|
||||
mod database;
|
||||
mod deeplink;
|
||||
mod error;
|
||||
@@ -13,7 +14,6 @@ mod gemini_config;
|
||||
mod gemini_mcp;
|
||||
mod init_status;
|
||||
mod mcp;
|
||||
mod openclaw_config;
|
||||
mod opencode_config;
|
||||
mod panic_hook;
|
||||
mod prompt;
|
||||
@@ -448,7 +448,52 @@ pub fn run() {
|
||||
Err(e) => log::warn!("✗ Failed to read skills migration flag: {e}"),
|
||||
}
|
||||
|
||||
// 2. OpenCode 供应商导入(累加式模式,需特殊处理)
|
||||
// 2. 导入供应商配置(已有内置检查:该应用已有供应商则跳过)
|
||||
for app in [
|
||||
crate::app_config::AppType::Claude,
|
||||
crate::app_config::AppType::Codex,
|
||||
crate::app_config::AppType::Gemini,
|
||||
] {
|
||||
match crate::services::provider::ProviderService::import_default_config(
|
||||
&app_state,
|
||||
app.clone(),
|
||||
) {
|
||||
Ok(true) => {
|
||||
log::info!("✓ Imported default provider for {}", app.as_str());
|
||||
|
||||
// 首次运行:自动提取通用配置片段(仅当通用配置为空时)
|
||||
if app_state
|
||||
.db
|
||||
.get_config_snippet(app.as_str())
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_none()
|
||||
{
|
||||
match crate::services::provider::ProviderService::extract_common_config_snippet(&app_state, app.clone()) {
|
||||
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
|
||||
if let Err(e) = app_state.db.set_config_snippet(app.as_str(), Some(snippet)) {
|
||||
log::warn!("✗ Failed to save common config snippet for {}: {e}", app.as_str());
|
||||
} else {
|
||||
log::info!("✓ Extracted common config snippet for {}", app.as_str());
|
||||
}
|
||||
}
|
||||
Ok(_) => log::debug!("○ No common config to extract for {}", app.as_str()),
|
||||
Err(e) => log::debug!("○ Failed to extract common config for {}: {e}", app.as_str()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false) => {} // 已有供应商,静默跳过
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"○ No default provider to import for {}: {}",
|
||||
app.as_str(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2.1 OpenCode 供应商导入(累加式模式,需特殊处理)
|
||||
// OpenCode 与其他应用不同:配置文件中可同时存在多个供应商
|
||||
// 需要遍历 provider 字段下的每个供应商并导入
|
||||
match crate::services::provider::import_opencode_providers_from_live(&app_state) {
|
||||
@@ -456,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::warn!("○ Failed to import OpenCode providers: {e}"),
|
||||
Err(e) => log::debug!("○ Failed to import OpenCode providers: {e}"),
|
||||
}
|
||||
|
||||
// 2.2 OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入)
|
||||
@@ -467,7 +512,7 @@ pub fn run() {
|
||||
.map(|providers| providers.values().any(|p| p.category.as_deref() == Some("omo")))
|
||||
.unwrap_or(false);
|
||||
if !has_omo {
|
||||
match crate::services::OmoService::import_from_local(&app_state, &crate::services::omo::STANDARD) {
|
||||
match crate::services::OmoService::import_from_local(&app_state) {
|
||||
Ok(provider) => {
|
||||
log::info!("✓ Imported OMO config from local as provider '{}'", provider.name);
|
||||
}
|
||||
@@ -481,46 +526,6 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 2.3 OMO Slim config import (when no omo-slim provider in DB, import from local)
|
||||
{
|
||||
let has_omo_slim = app_state
|
||||
.db
|
||||
.get_all_providers("opencode")
|
||||
.map(|providers| {
|
||||
providers
|
||||
.values()
|
||||
.any(|p| p.category.as_deref() == Some("omo-slim"))
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !has_omo_slim {
|
||||
match crate::services::OmoService::import_from_local(&app_state, &crate::services::omo::SLIM) {
|
||||
Ok(provider) => {
|
||||
log::info!(
|
||||
"✓ Imported OMO Slim config from local as provider '{}'",
|
||||
provider.name
|
||||
);
|
||||
}
|
||||
Err(AppError::OmoConfigNotFound) => {
|
||||
log::debug!("○ No OMO Slim config to import");
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("✗ Failed to import OMO Slim config from local: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2.4 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...");
|
||||
@@ -566,8 +571,6 @@ pub fn run() {
|
||||
crate::app_config::AppType::Claude,
|
||||
crate::app_config::AppType::Codex,
|
||||
crate::app_config::AppType::Gemini,
|
||||
crate::app_config::AppType::OpenCode,
|
||||
crate::app_config::AppType::OpenClaw,
|
||||
] {
|
||||
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
|
||||
&app_state,
|
||||
@@ -686,10 +689,6 @@ 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,25 +767,6 @@ pub fn run() {
|
||||
|
||||
// 检查 settings 表中的代理状态,自动恢复代理服务
|
||||
restore_proxy_state_on_startup(&state).await;
|
||||
|
||||
// Periodic backup check (on startup)
|
||||
if let Err(e) = state.db.periodic_backup_if_needed() {
|
||||
log::warn!("Periodic backup failed on startup: {e}");
|
||||
}
|
||||
|
||||
// Periodic backup timer: check every hour while the app is running
|
||||
let db_for_timer = state.db.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut interval =
|
||||
tokio::time::interval(std::time::Duration::from_secs(3600));
|
||||
interval.tick().await; // skip immediate first tick (already checked above)
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Err(e) = db_for_timer.periodic_backup_if_needed() {
|
||||
log::warn!("Periodic backup timer failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Linux: 禁用 WebKitGTK 硬件加速,防止 EGL 初始化失败导致白屏
|
||||
@@ -845,10 +825,12 @@ pub fn run() {
|
||||
commands::get_skills_migration_result,
|
||||
commands::get_app_config_path,
|
||||
commands::open_app_config_folder,
|
||||
commands::get_claude_common_config_snippet,
|
||||
commands::set_claude_common_config_snippet,
|
||||
commands::get_common_config_snippet,
|
||||
commands::set_common_config_snippet,
|
||||
commands::extract_common_config_snippet,
|
||||
commands::read_live_provider_settings,
|
||||
commands::patch_claude_live_settings,
|
||||
commands::get_settings,
|
||||
commands::save_settings,
|
||||
commands::get_rectifier_config,
|
||||
@@ -905,17 +887,9 @@ 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,
|
||||
commands::list_db_backups,
|
||||
commands::restore_db_backup,
|
||||
commands::rename_db_backup,
|
||||
commands::sync_current_providers_live,
|
||||
// Deep link import
|
||||
commands::parse_deeplink,
|
||||
@@ -1014,19 +988,6 @@ pub fn run() {
|
||||
// OpenCode specific
|
||||
commands::import_opencode_providers_from_live,
|
||||
commands::get_opencode_live_provider_ids,
|
||||
// OpenClaw specific
|
||||
commands::import_openclaw_providers_from_live,
|
||||
commands::get_openclaw_live_provider_ids,
|
||||
commands::get_openclaw_default_model,
|
||||
commands::set_openclaw_default_model,
|
||||
commands::get_openclaw_model_catalog,
|
||||
commands::set_openclaw_model_catalog,
|
||||
commands::get_openclaw_agents_defaults,
|
||||
commands::set_openclaw_agents_defaults,
|
||||
commands::get_openclaw_env,
|
||||
commands::set_openclaw_env,
|
||||
commands::get_openclaw_tools,
|
||||
commands::set_openclaw_tools,
|
||||
// Global upstream proxy
|
||||
commands::get_global_proxy_url,
|
||||
commands::set_global_proxy_url,
|
||||
@@ -1039,18 +1000,6 @@ pub fn run() {
|
||||
commands::get_current_omo_provider_id,
|
||||
commands::get_omo_provider_count,
|
||||
commands::disable_current_omo,
|
||||
commands::read_omo_slim_local_file,
|
||||
commands::get_current_omo_slim_provider_id,
|
||||
commands::get_omo_slim_provider_count,
|
||||
commands::disable_current_omo_slim,
|
||||
// Workspace files (OpenClaw)
|
||||
commands::read_workspace_file,
|
||||
commands::write_workspace_file,
|
||||
// Daily memory files (OpenClaw workspace)
|
||||
commands::list_daily_memory_files,
|
||||
commands::read_daily_memory_file,
|
||||
commands::write_daily_memory_file,
|
||||
commands::delete_daily_memory_file,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
|
||||
@@ -1,546 +0,0 @@
|
||||
//! OpenClaw 配置文件读写模块
|
||||
//!
|
||||
//! 处理 `~/.openclaw/openclaw.json` 配置文件的读写操作(JSON5 格式)。
|
||||
//! OpenClaw 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。
|
||||
//!
|
||||
//! ## 配置文件格式
|
||||
//!
|
||||
//! ```json5
|
||||
//! {
|
||||
//! // 模型供应商配置(映射为 CC Switch 的"供应商")
|
||||
//! models: {
|
||||
//! mode: "merge",
|
||||
//! providers: {
|
||||
//! "custom-provider": {
|
||||
//! baseUrl: "https://api.example.com/v1",
|
||||
//! apiKey: "${API_KEY}",
|
||||
//! api: "openai-completions",
|
||||
//! models: [{ id: "model-id", name: "Model Name" }]
|
||||
//! }
|
||||
//! }
|
||||
//! },
|
||||
//! // 环境变量配置
|
||||
//! env: {
|
||||
//! ANTHROPIC_API_KEY: "sk-...",
|
||||
//! vars: { ... }
|
||||
//! },
|
||||
//! // Agent 默认模型配置
|
||||
//! agents: {
|
||||
//! defaults: {
|
||||
//! model: {
|
||||
//! primary: "provider/model",
|
||||
//! fallbacks: ["provider2/model2"]
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use crate::config::write_json_file;
|
||||
use crate::error::AppError;
|
||||
use crate::settings::get_openclaw_override_dir;
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// ============================================================================
|
||||
// Path Functions
|
||||
// ============================================================================
|
||||
|
||||
/// 获取 OpenClaw 配置目录
|
||||
///
|
||||
/// 默认路径: `~/.openclaw/`
|
||||
/// 可通过 settings.openclaw_config_dir 覆盖
|
||||
pub fn get_openclaw_dir() -> PathBuf {
|
||||
if let Some(override_dir) = get_openclaw_override_dir() {
|
||||
return override_dir;
|
||||
}
|
||||
|
||||
// 所有平台统一使用 ~/.openclaw
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".openclaw"))
|
||||
.unwrap_or_else(|| PathBuf::from(".openclaw"))
|
||||
}
|
||||
|
||||
/// 获取 OpenClaw 配置文件路径
|
||||
///
|
||||
/// 返回 `~/.openclaw/openclaw.json`
|
||||
pub fn get_openclaw_config_path() -> PathBuf {
|
||||
get_openclaw_dir().join("openclaw.json")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Type Definitions
|
||||
// ============================================================================
|
||||
|
||||
/// OpenClaw 供应商配置(对应 models.providers 中的条目)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpenClawProviderConfig {
|
||||
/// API 基础 URL
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
|
||||
/// API Key(支持环境变量引用 ${VAR_NAME})
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub api_key: Option<String>,
|
||||
|
||||
/// API 类型(如 "openai-completions", "anthropic" 等)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub api: Option<String>,
|
||||
|
||||
/// 支持的模型列表
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub models: Vec<OpenClawModelEntry>,
|
||||
|
||||
/// Other custom fields (preserve unknown fields)
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// OpenClaw 模型条目
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpenClawModelEntry {
|
||||
/// 模型 ID
|
||||
pub id: String,
|
||||
|
||||
/// 模型显示名称
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
|
||||
/// 模型别名(用于快捷引用)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub alias: Option<String>,
|
||||
|
||||
/// 模型成本(输入/输出价格)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cost: Option<OpenClawModelCost>,
|
||||
|
||||
/// 上下文窗口大小
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context_window: Option<u32>,
|
||||
|
||||
/// Other custom fields (preserve unknown fields)
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// OpenClaw 模型成本配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenClawModelCost {
|
||||
/// 输入价格(每百万 token)
|
||||
pub input: f64,
|
||||
|
||||
/// 输出价格(每百万 token)
|
||||
pub output: f64,
|
||||
|
||||
/// Other custom fields (preserve unknown fields)
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// OpenClaw 默认模型配置(agents.defaults.model)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenClawDefaultModel {
|
||||
/// 主模型 ID(格式:provider/model)
|
||||
pub primary: String,
|
||||
|
||||
/// 回退模型列表
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub fallbacks: Vec<String>,
|
||||
|
||||
/// Other custom fields (preserve unknown fields)
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// OpenClaw 模型目录条目(agents.defaults.models 中的值)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenClawModelCatalogEntry {
|
||||
/// 模型别名(用于 UI 显示)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub alias: Option<String>,
|
||||
|
||||
/// Other custom fields (preserve unknown fields)
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// OpenClaw agents.defaults 配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenClawAgentsDefaults {
|
||||
/// 默认模型配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<OpenClawDefaultModel>,
|
||||
|
||||
/// 模型目录/允许列表(键为 provider/model 格式)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub models: Option<HashMap<String, OpenClawModelCatalogEntry>>,
|
||||
|
||||
/// Other custom fields (preserve unknown fields)
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// OpenClaw agents 顶层配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct OpenClawAgents {
|
||||
/// 默认配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub defaults: Option<OpenClawAgentsDefaults>,
|
||||
|
||||
/// Other custom fields (preserve unknown fields)
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Core Read/Write Functions
|
||||
// ============================================================================
|
||||
|
||||
/// 读取 OpenClaw 配置文件
|
||||
///
|
||||
/// 支持 JSON5 格式,返回完整的配置 JSON 对象
|
||||
pub fn read_openclaw_config() -> Result<Value, AppError> {
|
||||
let path = get_openclaw_config_path();
|
||||
|
||||
if !path.exists() {
|
||||
// Return empty config structure
|
||||
return Ok(json!({
|
||||
"models": {
|
||||
"mode": "merge",
|
||||
"providers": {}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||
|
||||
// 尝试 JSON5 解析(支持注释和尾随逗号)
|
||||
json5::from_str(&content)
|
||||
.map_err(|e| AppError::Config(format!("Failed to parse OpenClaw config as JSON5: {}", e)))
|
||||
}
|
||||
|
||||
/// 写入 OpenClaw 配置文件(原子写入)
|
||||
///
|
||||
/// 使用标准 JSON 格式写入(JSON5 是 JSON 的超集)
|
||||
pub fn write_openclaw_config(config: &Value) -> Result<(), AppError> {
|
||||
let path = get_openclaw_config_path();
|
||||
|
||||
// 确保目录存在
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
}
|
||||
|
||||
// 复用统一的原子写入逻辑
|
||||
write_json_file(&path, config)?;
|
||||
|
||||
log::debug!("OpenClaw config written to {path:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Provider Functions (Untyped - for raw JSON operations)
|
||||
// ============================================================================
|
||||
|
||||
/// 获取所有供应商配置(原始 JSON)
|
||||
///
|
||||
/// 从 `models.providers` 读取
|
||||
pub fn get_providers() -> Result<Map<String, Value>, AppError> {
|
||||
let config = read_openclaw_config()?;
|
||||
Ok(config
|
||||
.get("models")
|
||||
.and_then(|m| m.get("providers"))
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// 设置供应商配置(原始 JSON)
|
||||
///
|
||||
/// 写入到 `models.providers`
|
||||
pub fn set_provider(id: &str, provider_config: Value) -> Result<(), AppError> {
|
||||
let mut full_config = read_openclaw_config()?;
|
||||
|
||||
// 确保 models 结构存在
|
||||
if full_config.get("models").is_none() {
|
||||
full_config["models"] = json!({
|
||||
"mode": "merge",
|
||||
"providers": {}
|
||||
});
|
||||
}
|
||||
|
||||
// 确保 providers 对象存在
|
||||
if full_config["models"].get("providers").is_none() {
|
||||
full_config["models"]["providers"] = json!({});
|
||||
}
|
||||
|
||||
// 设置供应商
|
||||
if let Some(providers) = full_config["models"]
|
||||
.get_mut("providers")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
providers.insert(id.to_string(), provider_config);
|
||||
}
|
||||
|
||||
write_openclaw_config(&full_config)
|
||||
}
|
||||
|
||||
/// 删除供应商配置
|
||||
pub fn remove_provider(id: &str) -> Result<(), AppError> {
|
||||
let mut config = read_openclaw_config()?;
|
||||
|
||||
if let Some(providers) = config
|
||||
.get_mut("models")
|
||||
.and_then(|m| m.get_mut("providers"))
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
providers.remove(id);
|
||||
}
|
||||
|
||||
write_openclaw_config(&config)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Provider Functions (Typed)
|
||||
// ============================================================================
|
||||
|
||||
/// 获取所有供应商配置(类型化)
|
||||
pub fn get_typed_providers() -> Result<IndexMap<String, OpenClawProviderConfig>, AppError> {
|
||||
let providers = get_providers()?;
|
||||
let mut result = IndexMap::new();
|
||||
|
||||
for (id, value) in providers {
|
||||
match serde_json::from_value::<OpenClawProviderConfig>(value.clone()) {
|
||||
Ok(config) => {
|
||||
result.insert(id, config);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to parse OpenClaw provider '{id}': {e}");
|
||||
// Skip invalid providers but continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 设置供应商配置(类型化)
|
||||
pub fn set_typed_provider(id: &str, config: &OpenClawProviderConfig) -> Result<(), AppError> {
|
||||
let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
set_provider(id, value)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agents Configuration Functions
|
||||
// ============================================================================
|
||||
|
||||
/// 读取默认模型配置(agents.defaults.model)
|
||||
pub fn get_default_model() -> Result<Option<OpenClawDefaultModel>, AppError> {
|
||||
let config = read_openclaw_config()?;
|
||||
|
||||
let Some(model_value) = config
|
||||
.get("agents")
|
||||
.and_then(|a| a.get("defaults"))
|
||||
.and_then(|d| d.get("model"))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let model = serde_json::from_value(model_value.clone())
|
||||
.map_err(|e| AppError::Config(format!("Failed to parse agents.defaults.model: {e}")))?;
|
||||
|
||||
Ok(Some(model))
|
||||
}
|
||||
|
||||
/// 设置默认模型配置(agents.defaults.model)
|
||||
pub fn set_default_model(model: &OpenClawDefaultModel) -> Result<(), AppError> {
|
||||
let mut config = read_openclaw_config()?;
|
||||
|
||||
// Ensure agents.defaults path exists, preserving unknown fields
|
||||
ensure_agents_defaults_path(&mut config);
|
||||
|
||||
let model_value =
|
||||
serde_json::to_value(model).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
|
||||
config["agents"]["defaults"]["model"] = model_value;
|
||||
|
||||
write_openclaw_config(&config)
|
||||
}
|
||||
|
||||
/// 读取模型目录/允许列表(agents.defaults.models)
|
||||
pub fn get_model_catalog() -> Result<Option<HashMap<String, OpenClawModelCatalogEntry>>, AppError> {
|
||||
let config = read_openclaw_config()?;
|
||||
|
||||
let Some(models_value) = config
|
||||
.get("agents")
|
||||
.and_then(|a| a.get("defaults"))
|
||||
.and_then(|d| d.get("models"))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let catalog = serde_json::from_value(models_value.clone())
|
||||
.map_err(|e| AppError::Config(format!("Failed to parse agents.defaults.models: {e}")))?;
|
||||
|
||||
Ok(Some(catalog))
|
||||
}
|
||||
|
||||
/// 设置模型目录/允许列表(agents.defaults.models)
|
||||
pub fn set_model_catalog(
|
||||
catalog: &HashMap<String, OpenClawModelCatalogEntry>,
|
||||
) -> Result<(), AppError> {
|
||||
let mut config = read_openclaw_config()?;
|
||||
|
||||
// Ensure agents.defaults path exists, preserving unknown fields
|
||||
ensure_agents_defaults_path(&mut config);
|
||||
|
||||
let catalog_value =
|
||||
serde_json::to_value(catalog).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
|
||||
config["agents"]["defaults"]["models"] = catalog_value;
|
||||
|
||||
write_openclaw_config(&config)
|
||||
}
|
||||
|
||||
/// Ensure the `agents.defaults` path exists in the config,
|
||||
/// preserving any existing unknown fields.
|
||||
fn ensure_agents_defaults_path(config: &mut Value) {
|
||||
if config.get("agents").is_none() {
|
||||
config["agents"] = json!({});
|
||||
}
|
||||
if config["agents"].get("defaults").is_none() {
|
||||
config["agents"]["defaults"] = json!({});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Full Agents Defaults Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Read the full agents.defaults config
|
||||
pub fn get_agents_defaults() -> Result<Option<OpenClawAgentsDefaults>, AppError> {
|
||||
let config = read_openclaw_config()?;
|
||||
|
||||
let Some(defaults_value) = config.get("agents").and_then(|a| a.get("defaults")) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let defaults = serde_json::from_value(defaults_value.clone())
|
||||
.map_err(|e| AppError::Config(format!("Failed to parse agents.defaults: {e}")))?;
|
||||
|
||||
Ok(Some(defaults))
|
||||
}
|
||||
|
||||
/// Write the full agents.defaults config
|
||||
pub fn set_agents_defaults(defaults: &OpenClawAgentsDefaults) -> Result<(), AppError> {
|
||||
let mut config = read_openclaw_config()?;
|
||||
|
||||
if config.get("agents").is_none() {
|
||||
config["agents"] = json!({});
|
||||
}
|
||||
|
||||
let value =
|
||||
serde_json::to_value(defaults).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
|
||||
config["agents"]["defaults"] = value;
|
||||
|
||||
write_openclaw_config(&config)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Env Configuration
|
||||
// ============================================================================
|
||||
|
||||
/// OpenClaw env configuration (env section of openclaw.json)
|
||||
///
|
||||
/// Stores environment variables like API keys and custom vars.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenClawEnvConfig {
|
||||
/// All environment variable key-value pairs
|
||||
#[serde(flatten)]
|
||||
pub vars: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// Read the env config section
|
||||
pub fn get_env_config() -> Result<OpenClawEnvConfig, AppError> {
|
||||
let config = read_openclaw_config()?;
|
||||
|
||||
let Some(env_value) = config.get("env") else {
|
||||
return Ok(OpenClawEnvConfig {
|
||||
vars: HashMap::new(),
|
||||
});
|
||||
};
|
||||
|
||||
serde_json::from_value(env_value.clone())
|
||||
.map_err(|e| AppError::Config(format!("Failed to parse env config: {e}")))
|
||||
}
|
||||
|
||||
/// Write the env config section
|
||||
pub fn set_env_config(env: &OpenClawEnvConfig) -> Result<(), AppError> {
|
||||
let mut config = read_openclaw_config()?;
|
||||
|
||||
let value = serde_json::to_value(env).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
|
||||
config["env"] = value;
|
||||
|
||||
write_openclaw_config(&config)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tools Configuration
|
||||
// ============================================================================
|
||||
|
||||
/// OpenClaw tools configuration (tools section of openclaw.json)
|
||||
///
|
||||
/// Controls tool permissions with profile-based allow/deny lists.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenClawToolsConfig {
|
||||
/// Active permission profile (e.g. "default", "strict", "permissive")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<String>,
|
||||
|
||||
/// Allowed tool patterns
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub allow: Vec<String>,
|
||||
|
||||
/// Denied tool patterns
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub deny: Vec<String>,
|
||||
|
||||
/// Other custom fields (preserve unknown fields)
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// Read the tools config section
|
||||
pub fn get_tools_config() -> Result<OpenClawToolsConfig, AppError> {
|
||||
let config = read_openclaw_config()?;
|
||||
|
||||
let Some(tools_value) = config.get("tools") else {
|
||||
return Ok(OpenClawToolsConfig {
|
||||
profile: None,
|
||||
allow: Vec::new(),
|
||||
deny: Vec::new(),
|
||||
extra: HashMap::new(),
|
||||
});
|
||||
};
|
||||
|
||||
serde_json::from_value(tools_value.clone())
|
||||
.map_err(|e| AppError::Config(format!("Failed to parse tools config: {e}")))
|
||||
}
|
||||
|
||||
/// Write the tools config section
|
||||
pub fn set_tools_config(tools: &OpenClawToolsConfig) -> Result<(), AppError> {
|
||||
let mut config = read_openclaw_config()?;
|
||||
|
||||
let value = serde_json::to_value(tools).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
|
||||
config["tools"] = value;
|
||||
|
||||
write_openclaw_config(&config)
|
||||
}
|
||||
@@ -145,25 +145,14 @@ pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
|
||||
|
||||
match plugins {
|
||||
Some(arr) => {
|
||||
// Mutual exclusion: standard OMO and OMO Slim cannot coexist as plugins
|
||||
if plugin_name.starts_with("oh-my-opencode")
|
||||
&& !plugin_name.starts_with("oh-my-opencode-slim")
|
||||
{
|
||||
// Adding standard OMO -> remove all Slim variants
|
||||
arr.retain(|v| {
|
||||
v.as_str()
|
||||
.map(|s| !s.starts_with("oh-my-opencode-slim"))
|
||||
.unwrap_or(true)
|
||||
});
|
||||
} else if plugin_name.starts_with("oh-my-opencode-slim") {
|
||||
// Adding Slim -> remove all standard OMO variants (but keep slim)
|
||||
arr.retain(|v| {
|
||||
v.as_str()
|
||||
.map(|s| {
|
||||
!s.starts_with("oh-my-opencode") || s.starts_with("oh-my-opencode-slim")
|
||||
})
|
||||
.unwrap_or(true)
|
||||
});
|
||||
}
|
||||
|
||||
let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name));
|
||||
|
||||
@@ -5,7 +5,6 @@ use crate::codex_config::get_codex_auth_path;
|
||||
use crate::config::get_claude_settings_path;
|
||||
use crate::error::AppError;
|
||||
use crate::gemini_config::get_gemini_dir;
|
||||
use crate::openclaw_config::get_openclaw_dir;
|
||||
use crate::opencode_config::get_opencode_dir;
|
||||
|
||||
/// 返回指定应用所使用的提示词文件路径。
|
||||
@@ -15,7 +14,6 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
|
||||
AppType::Gemini => get_gemini_dir(),
|
||||
AppType::OpenCode => get_opencode_dir(),
|
||||
AppType::OpenClaw => get_openclaw_dir(),
|
||||
};
|
||||
|
||||
let filename = match app {
|
||||
@@ -23,7 +21,6 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
AppType::Codex => "AGENTS.md",
|
||||
AppType::Gemini => "GEMINI.md",
|
||||
AppType::OpenCode => "AGENTS.md",
|
||||
AppType::OpenClaw => "AGENTS.md", // OpenClaw uses AGENTS.md for agent instructions
|
||||
};
|
||||
|
||||
Ok(base_dir.join(filename))
|
||||
|
||||
@@ -191,6 +191,19 @@ pub struct ProviderProxyConfig {
|
||||
pub proxy_password: Option<String>,
|
||||
}
|
||||
|
||||
/// 通用配置启用状态(按应用)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CommonConfigEnabledByApp {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub claude: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub codex: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gemini: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub opencode: Option<bool>,
|
||||
}
|
||||
|
||||
/// 供应商元数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProviderMeta {
|
||||
@@ -230,16 +243,23 @@ pub struct ProviderMeta {
|
||||
/// 供应商单独的代理配置
|
||||
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_config: Option<ProviderProxyConfig>,
|
||||
/// 是否启用通用配置片段(用于跨供应商保持勾选状态)
|
||||
#[serde(
|
||||
rename = "commonConfigEnabled",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub common_config_enabled: Option<bool>,
|
||||
/// 按应用记录通用配置启用状态(优先于 commonConfigEnabled)
|
||||
#[serde(
|
||||
rename = "commonConfigEnabledByApp",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub common_config_enabled_by_app: Option<CommonConfigEnabledByApp>,
|
||||
/// Claude API 格式(仅 Claude 供应商使用)
|
||||
/// - "anthropic": 原生 Anthropic Messages API,直接透传
|
||||
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
|
||||
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
|
||||
pub api_format: Option<String>,
|
||||
/// Claude 认证字段名(仅 Claude 供应商使用)
|
||||
/// - "ANTHROPIC_AUTH_TOKEN" (默认): 大多数第三方/聚合供应商
|
||||
/// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key
|
||||
#[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")]
|
||||
pub api_key_field: Option<String>,
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
@@ -636,7 +656,7 @@ pub struct OpenCodeModelLimit {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
mod provider_tests {
|
||||
use super::{
|
||||
ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, OpenCodeProviderConfig, Provider,
|
||||
ProviderManager, ProviderMeta, UniversalProvider,
|
||||
|
||||
@@ -263,13 +263,10 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
base = base.replace("/v1/v1", "/v1");
|
||||
}
|
||||
|
||||
// 为 Claude 原生 /v1/messages 端点添加 ?beta=true 参数
|
||||
// 为 Claude 相关端点添加 ?beta=true 参数
|
||||
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
|
||||
// 注意:不要为 OpenAI Chat Completions (/v1/chat/completions) 添加此参数
|
||||
// 当 apiFormat="openai_chat" 时,请求会转发到 /v1/chat/completions,
|
||||
// 但该端点是 OpenAI 标准,不支持 ?beta=true 参数
|
||||
if endpoint.contains("/v1/messages")
|
||||
&& !endpoint.contains("/v1/chat/completions")
|
||||
// 注:openai_chat 模式下会转发到 /v1/chat/completions,此处也需要保持一致
|
||||
if (endpoint.contains("/v1/messages") || endpoint.contains("/v1/chat/completions"))
|
||||
&& !endpoint.contains('?')
|
||||
{
|
||||
format!("{base}?beta=true")
|
||||
@@ -516,15 +513,6 @@ mod tests {
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_no_beta_for_openai_chat_completions() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// OpenAI Chat Completions 端点不添加 ?beta=true
|
||||
// 这是 Nvidia 等 apiFormat="openai_chat" 供应商使用的端点
|
||||
let url = adapter.build_url("https://integrate.api.nvidia.com", "/v1/chat/completions");
|
||||
assert_eq!(url, "https://integrate.api.nvidia.com/v1/chat/completions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_needs_transform() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
|
||||
@@ -136,10 +136,6 @@ impl ProviderType {
|
||||
// OpenCode doesn't support proxy, but return a default type for completeness
|
||||
ProviderType::Codex // Fallback to Codex-like type
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy, but return a default type for completeness
|
||||
ProviderType::Codex // Fallback to Codex-like type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,10 +184,6 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
|
||||
// OpenCode doesn't support proxy, fallback to Codex adapter
|
||||
Box::new(CodexAdapter::new())
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy, fallback to Codex adapter
|
||||
Box::new(CodexAdapter::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,8 +113,6 @@ pub struct ProxyTakeoverStatus {
|
||||
pub claude: bool,
|
||||
pub codex: bool,
|
||||
pub gemini: bool,
|
||||
pub opencode: bool,
|
||||
pub openclaw: bool,
|
||||
}
|
||||
|
||||
/// API 格式类型(预留,当前不需要格式转换)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use super::provider::{sanitize_claude_settings_for_live, ProviderService};
|
||||
use crate::app_config::{AppType, MultiAppConfig};
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use chrono::Utc;
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -78,4 +82,146 @@ impl ConfigService {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 同步当前供应商到对应的 live 配置。
|
||||
pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> {
|
||||
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
|
||||
Self::sync_current_provider_for_app(config, &AppType::Codex)?;
|
||||
Self::sync_current_provider_for_app(config, &AppType::Gemini)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_current_provider_for_app(
|
||||
config: &mut MultiAppConfig,
|
||||
app_type: &AppType,
|
||||
) -> Result<(), AppError> {
|
||||
let (current_id, provider) = {
|
||||
let manager = match config.get_manager(app_type) {
|
||||
Some(manager) => manager,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if manager.current.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let current_id = manager.current.clone();
|
||||
let provider = match manager.providers.get(¤t_id) {
|
||||
Some(provider) => provider.clone(),
|
||||
None => {
|
||||
log::warn!(
|
||||
"当前应用 {app_type:?} 的供应商 {current_id} 不存在,跳过 live 同步"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
(current_id, provider)
|
||||
};
|
||||
|
||||
match app_type {
|
||||
AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?,
|
||||
AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?,
|
||||
AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?,
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses additive mode, no live sync needed
|
||||
// OpenCode providers are managed directly in the config file
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_codex_live(
|
||||
config: &mut MultiAppConfig,
|
||||
provider_id: &str,
|
||||
provider: &Provider,
|
||||
) -> Result<(), AppError> {
|
||||
let settings = provider.settings_config.as_object().ok_or_else(|| {
|
||||
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置必须是对象"))
|
||||
})?;
|
||||
let auth = settings.get("auth").ok_or_else(|| {
|
||||
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置缺少 auth 字段"))
|
||||
})?;
|
||||
if !auth.is_object() {
|
||||
return Err(AppError::Config(format!(
|
||||
"供应商 {provider_id} 的 Codex auth 配置必须是 JSON 对象"
|
||||
)));
|
||||
}
|
||||
let cfg_text = settings.get("config").and_then(Value::as_str);
|
||||
|
||||
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
||||
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
|
||||
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
|
||||
// MCP 的启用/禁用应通过 McpService::toggle_app 进行
|
||||
|
||||
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
|
||||
if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
|
||||
if let Some(target) = manager.providers.get_mut(provider_id) {
|
||||
if let Some(obj) = target.settings_config.as_object_mut() {
|
||||
obj.insert(
|
||||
"config".to_string(),
|
||||
serde_json::Value::String(cfg_text_after),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_claude_live(
|
||||
config: &mut MultiAppConfig,
|
||||
provider_id: &str,
|
||||
provider: &Provider,
|
||||
) -> Result<(), AppError> {
|
||||
use crate::config::{read_json_file, write_json_file};
|
||||
|
||||
let settings_path = crate::config::get_claude_settings_path();
|
||||
if let Some(parent) = settings_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
}
|
||||
|
||||
let settings = sanitize_claude_settings_for_live(&provider.settings_config);
|
||||
write_json_file(&settings_path, &settings)?;
|
||||
|
||||
let live_after = read_json_file::<serde_json::Value>(&settings_path)?;
|
||||
if let Some(manager) = config.get_manager_mut(&AppType::Claude) {
|
||||
if let Some(target) = manager.providers.get_mut(provider_id) {
|
||||
target.settings_config = live_after;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_gemini_live(
|
||||
config: &mut MultiAppConfig,
|
||||
provider_id: &str,
|
||||
provider: &Provider,
|
||||
) -> Result<(), AppError> {
|
||||
use crate::gemini_config::{env_to_json, read_gemini_env};
|
||||
|
||||
ProviderService::write_gemini_live(provider)?;
|
||||
|
||||
// 读回实际写入的内容并更新到配置中(包含 settings.json)
|
||||
let live_after_env = read_gemini_env()?;
|
||||
let settings_path = crate::gemini_config::get_gemini_settings_path();
|
||||
let live_after_config = if settings_path.exists() {
|
||||
crate::config::read_json_file(&settings_path)?
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
let mut live_after = env_to_json(&live_after_env);
|
||||
if let Some(obj) = live_after.as_object_mut() {
|
||||
obj.insert("config".to_string(), live_after_config);
|
||||
}
|
||||
|
||||
if let Some(manager) = config.get_manager_mut(&AppType::Gemini) {
|
||||
if let Some(target) = manager.providers.get_mut(provider_id) {
|
||||
target.settings_config = live_after;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,11 +123,6 @@ impl McpService {
|
||||
&server.server,
|
||||
)?;
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw MCP support is still in development (Issue #4834)
|
||||
// Skip for now
|
||||
log::debug!("OpenClaw MCP support is still in development, skipping sync");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -153,10 +148,6 @@ impl McpService {
|
||||
AppType::OpenCode => {
|
||||
mcp::remove_server_from_opencode(id)?;
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw MCP support is still in development
|
||||
log::debug!("OpenClaw MCP support is still in development, skipping remove");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -10,15 +10,12 @@ 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;
|
||||
pub use omo::OmoService;
|
||||
pub use prompt::PromptService;
|
||||
pub use provider::{ProviderService, ProviderSortUpdate, SwitchResult};
|
||||
pub use provider::{ProviderService, ProviderSortUpdate};
|
||||
pub use proxy::ProxyService;
|
||||
#[allow(unused_imports)]
|
||||
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
||||
|
||||
+104
-195
@@ -2,6 +2,7 @@ use crate::config::write_json_file;
|
||||
use crate::database::OmoGlobalConfig;
|
||||
use crate::error::AppError;
|
||||
use crate::opencode_config::get_opencode_dir;
|
||||
use crate::provider::{CommonConfigEnabledByApp, ProviderMeta};
|
||||
use crate::store::AppState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
@@ -20,83 +21,15 @@ pub struct OmoLocalFileData {
|
||||
|
||||
type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>, bool);
|
||||
|
||||
// ── Variant descriptor ─────────────────────────────────────────
|
||||
|
||||
pub struct OmoVariant {
|
||||
pub filename: &'static str,
|
||||
pub category: &'static str,
|
||||
pub provider_prefix: &'static str,
|
||||
pub plugin_name: &'static str,
|
||||
pub plugin_prefix: &'static str,
|
||||
pub known_keys: &'static [&'static str],
|
||||
pub has_categories: bool,
|
||||
pub config_key: &'static str,
|
||||
pub label: &'static str,
|
||||
pub import_label: &'static str,
|
||||
}
|
||||
|
||||
pub const STANDARD: OmoVariant = OmoVariant {
|
||||
filename: "oh-my-opencode.jsonc",
|
||||
category: "omo",
|
||||
provider_prefix: "omo-",
|
||||
plugin_name: "oh-my-opencode@latest",
|
||||
plugin_prefix: "oh-my-opencode",
|
||||
known_keys: &[
|
||||
"$schema",
|
||||
"agents",
|
||||
"categories",
|
||||
"sisyphus_agent",
|
||||
"disabled_agents",
|
||||
"disabled_mcps",
|
||||
"disabled_hooks",
|
||||
"disabled_skills",
|
||||
"lsp",
|
||||
"experimental",
|
||||
"background_task",
|
||||
"browser_automation_engine",
|
||||
"claude_code",
|
||||
],
|
||||
has_categories: true,
|
||||
config_key: "common_config_omo",
|
||||
label: "OMO",
|
||||
import_label: "Imported",
|
||||
};
|
||||
|
||||
pub const SLIM: OmoVariant = OmoVariant {
|
||||
filename: "oh-my-opencode-slim.jsonc",
|
||||
category: "omo-slim",
|
||||
provider_prefix: "omo-slim-",
|
||||
plugin_name: "oh-my-opencode-slim@latest",
|
||||
plugin_prefix: "oh-my-opencode-slim",
|
||||
known_keys: &[
|
||||
"$schema",
|
||||
"agents",
|
||||
"sisyphus_agent",
|
||||
"disabled_agents",
|
||||
"disabled_mcps",
|
||||
"disabled_hooks",
|
||||
"lsp",
|
||||
"experimental",
|
||||
],
|
||||
has_categories: false,
|
||||
config_key: "common_config_omo_slim",
|
||||
label: "OMO Slim",
|
||||
import_label: "Imported Slim",
|
||||
};
|
||||
|
||||
// ── Service ────────────────────────────────────────────────────
|
||||
|
||||
pub struct OmoService;
|
||||
|
||||
impl OmoService {
|
||||
// ── Path helpers ────────────────────────────────────────
|
||||
|
||||
fn config_path(v: &OmoVariant) -> PathBuf {
|
||||
get_opencode_dir().join(v.filename)
|
||||
fn config_path() -> PathBuf {
|
||||
get_opencode_dir().join("oh-my-opencode.jsonc")
|
||||
}
|
||||
|
||||
fn resolve_local_config_path(v: &OmoVariant) -> Result<PathBuf, AppError> {
|
||||
let config_path = Self::config_path(v);
|
||||
fn resolve_local_config_path() -> Result<PathBuf, AppError> {
|
||||
let config_path = Self::config_path();
|
||||
if config_path.exists() {
|
||||
return Ok(config_path);
|
||||
}
|
||||
@@ -120,15 +53,26 @@ impl OmoService {
|
||||
.ok_or_else(|| AppError::Config("Expected JSON object".to_string()))
|
||||
}
|
||||
|
||||
// ── Field extraction ───────────────────────────────────
|
||||
fn extract_other_fields(obj: &Map<String, Value>) -> Map<String, Value> {
|
||||
const KNOWN_KEYS: [&str; 13] = [
|
||||
"$schema",
|
||||
"agents",
|
||||
"categories",
|
||||
"sisyphus_agent",
|
||||
"disabled_agents",
|
||||
"disabled_mcps",
|
||||
"disabled_hooks",
|
||||
"disabled_skills",
|
||||
"lsp",
|
||||
"experimental",
|
||||
"background_task",
|
||||
"browser_automation_engine",
|
||||
"claude_code",
|
||||
];
|
||||
|
||||
fn extract_other_fields_with_keys(
|
||||
obj: &Map<String, Value>,
|
||||
known: &[&str],
|
||||
) -> Map<String, Value> {
|
||||
let mut other = Map::new();
|
||||
for (k, v) in obj {
|
||||
if !known.contains(&k.as_str()) {
|
||||
if !KNOWN_KEYS.contains(&k.as_str()) {
|
||||
other.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
@@ -176,8 +120,6 @@ impl OmoService {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Merge helpers ──────────────────────────────────────
|
||||
|
||||
fn insert_opt_value(result: &mut Map<String, Value>, key: &str, value: &Option<Value>) {
|
||||
if let Some(v) = value {
|
||||
result.insert(key.to_string(), v.clone());
|
||||
@@ -201,40 +143,53 @@ impl OmoService {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API (variant-parameterized) ─────────────────
|
||||
fn resolve_common_config_enabled(provider: &crate::provider::Provider) -> bool {
|
||||
// Unified path: use provider meta (same mechanism as other apps).
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if let Some(enabled) = meta
|
||||
.common_config_enabled_by_app
|
||||
.as_ref()
|
||||
.and_then(|by_app| by_app.opencode)
|
||||
{
|
||||
return enabled;
|
||||
}
|
||||
if let Some(enabled) = meta.common_config_enabled {
|
||||
return enabled;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> {
|
||||
let config_path = Self::config_path(v);
|
||||
// Backward compatibility: legacy OMO providers stored this flag in settings_config.
|
||||
provider
|
||||
.settings_config
|
||||
.get("useCommonConfig")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn delete_config_file() -> Result<(), AppError> {
|
||||
let config_path = Self::config_path();
|
||||
if config_path.exists() {
|
||||
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||
log::info!("{} config file deleted: {config_path:?}", v.label);
|
||||
log::info!("OMO config file deleted: {config_path:?}");
|
||||
}
|
||||
crate::opencode_config::remove_plugin_by_prefix(v.plugin_prefix)?;
|
||||
crate::opencode_config::remove_plugin_by_prefix("oh-my-opencode")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_config_to_file(state: &AppState, v: &OmoVariant) -> Result<(), AppError> {
|
||||
let global = state.db.get_omo_global_config(v.config_key)?;
|
||||
let current_omo = state.db.get_current_omo_provider("opencode", v.category)?;
|
||||
pub fn write_config_to_file(state: &AppState) -> Result<(), AppError> {
|
||||
let global = state.db.get_omo_global_config()?;
|
||||
let current_omo = state.db.get_current_omo_provider("opencode")?;
|
||||
|
||||
let profile_data = current_omo.as_ref().map(|p| {
|
||||
let agents = p.settings_config.get("agents").cloned();
|
||||
let categories = if v.has_categories {
|
||||
p.settings_config.get("categories").cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let categories = p.settings_config.get("categories").cloned();
|
||||
let other_fields = p.settings_config.get("otherFields").cloned();
|
||||
let use_common_config = p
|
||||
.settings_config
|
||||
.get("useCommonConfig")
|
||||
.and_then(|val| val.as_bool())
|
||||
.unwrap_or(true);
|
||||
let use_common_config = Self::resolve_common_config_enabled(p);
|
||||
(agents, categories, other_fields, use_common_config)
|
||||
});
|
||||
|
||||
let merged = Self::merge_config(v, &global, profile_data.as_ref());
|
||||
let config_path = Self::config_path(v);
|
||||
let merged = Self::merge_config(&global, profile_data.as_ref());
|
||||
let config_path = Self::config_path();
|
||||
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
@@ -242,19 +197,15 @@ impl OmoService {
|
||||
|
||||
write_json_file(&config_path, &merged)?;
|
||||
|
||||
crate::opencode_config::add_plugin(v.plugin_name)?;
|
||||
crate::opencode_config::add_plugin("oh-my-opencode@latest")?;
|
||||
|
||||
log::info!("{} config written to {config_path:?}", v.label);
|
||||
log::info!("OMO config written to {config_path:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_config(
|
||||
v: &OmoVariant,
|
||||
global: &OmoGlobalConfig,
|
||||
profile_data: Option<&OmoProfileData>,
|
||||
) -> Value {
|
||||
fn merge_config(global: &OmoGlobalConfig, profile_data: Option<&OmoProfileData>) -> Value {
|
||||
let mut result = Map::new();
|
||||
let use_common_config = profile_data.map(|(_, _, _, uc)| *uc).unwrap_or(true);
|
||||
let use_common_config = profile_data.map(|(_, _, _, v)| *v).unwrap_or(true);
|
||||
|
||||
if use_common_config {
|
||||
if let Some(url) = &global.schema_url {
|
||||
@@ -265,69 +216,60 @@ impl OmoService {
|
||||
Self::insert_string_array(&mut result, "disabled_agents", &global.disabled_agents);
|
||||
Self::insert_string_array(&mut result, "disabled_mcps", &global.disabled_mcps);
|
||||
Self::insert_string_array(&mut result, "disabled_hooks", &global.disabled_hooks);
|
||||
|
||||
if v.has_categories {
|
||||
Self::insert_string_array(&mut result, "disabled_skills", &global.disabled_skills);
|
||||
Self::insert_opt_value(&mut result, "background_task", &global.background_task);
|
||||
Self::insert_opt_value(
|
||||
&mut result,
|
||||
"browser_automation_engine",
|
||||
&global.browser_automation_engine,
|
||||
);
|
||||
Self::insert_opt_value(&mut result, "claude_code", &global.claude_code);
|
||||
}
|
||||
|
||||
Self::insert_string_array(&mut result, "disabled_skills", &global.disabled_skills);
|
||||
Self::insert_opt_value(&mut result, "lsp", &global.lsp);
|
||||
Self::insert_opt_value(&mut result, "experimental", &global.experimental);
|
||||
Self::insert_opt_value(&mut result, "background_task", &global.background_task);
|
||||
Self::insert_opt_value(
|
||||
&mut result,
|
||||
"browser_automation_engine",
|
||||
&global.browser_automation_engine,
|
||||
);
|
||||
Self::insert_opt_value(&mut result, "claude_code", &global.claude_code);
|
||||
|
||||
Self::insert_object_entries(&mut result, global.other_fields.as_ref());
|
||||
}
|
||||
|
||||
if let Some((agents, categories, other_fields, _)) = profile_data {
|
||||
Self::insert_opt_value(&mut result, "agents", agents);
|
||||
if v.has_categories {
|
||||
Self::insert_opt_value(&mut result, "categories", categories);
|
||||
}
|
||||
Self::insert_opt_value(&mut result, "categories", categories);
|
||||
Self::insert_object_entries(&mut result, other_fields.as_ref());
|
||||
}
|
||||
|
||||
Value::Object(result)
|
||||
}
|
||||
|
||||
pub fn import_from_local(
|
||||
pub fn import_from_local(state: &AppState) -> Result<crate::provider::Provider, AppError> {
|
||||
let actual_path = Self::resolve_local_config_path()?;
|
||||
Self::import_from_path(state, &actual_path)
|
||||
}
|
||||
|
||||
fn import_from_path(
|
||||
state: &AppState,
|
||||
v: &OmoVariant,
|
||||
path: &std::path::Path,
|
||||
) -> Result<crate::provider::Provider, AppError> {
|
||||
let actual_path = Self::resolve_local_config_path(v)?;
|
||||
let obj = Self::read_jsonc_object(&actual_path)?;
|
||||
let obj = Self::read_jsonc_object(path)?;
|
||||
|
||||
let mut settings = Map::new();
|
||||
if let Some(agents) = obj.get("agents") {
|
||||
settings.insert("agents".to_string(), agents.clone());
|
||||
}
|
||||
if v.has_categories {
|
||||
if let Some(categories) = obj.get("categories") {
|
||||
settings.insert("categories".to_string(), categories.clone());
|
||||
}
|
||||
if let Some(categories) = obj.get("categories") {
|
||||
settings.insert("categories".to_string(), categories.clone());
|
||||
}
|
||||
settings.insert("useCommonConfig".to_string(), Value::Bool(true));
|
||||
|
||||
let other = Self::extract_other_fields_with_keys(&obj, v.known_keys);
|
||||
let other = Self::extract_other_fields(&obj);
|
||||
if !other.is_empty() {
|
||||
settings.insert("otherFields".to_string(), Value::Object(other));
|
||||
}
|
||||
|
||||
let mut global = state.db.get_omo_global_config(v.config_key)?;
|
||||
let mut global = state.db.get_omo_global_config()?;
|
||||
Self::merge_global_from_obj(&obj, &mut global);
|
||||
global.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
state.db.save_omo_global_config(v.config_key, &global)?;
|
||||
state.db.save_omo_global_config(&global)?;
|
||||
|
||||
let provider_id = format!("{}{}", v.provider_prefix, uuid::Uuid::new_v4());
|
||||
let name = format!(
|
||||
"{} {}",
|
||||
v.import_label,
|
||||
chrono::Local::now().format("%Y-%m-%d %H:%M")
|
||||
);
|
||||
let provider_id = format!("omo-{}", uuid::Uuid::new_v4());
|
||||
let name = format!("Imported {}", chrono::Local::now().format("%Y-%m-%d %H:%M"));
|
||||
let settings_config =
|
||||
serde_json::to_value(&settings).unwrap_or_else(|_| serde_json::json!({}));
|
||||
|
||||
@@ -336,11 +278,17 @@ impl OmoService {
|
||||
name,
|
||||
settings_config,
|
||||
website_url: None,
|
||||
category: Some(v.category.to_string()),
|
||||
category: Some("omo".to_string()),
|
||||
created_at: Some(chrono::Utc::now().timestamp_millis()),
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: None,
|
||||
meta: Some(ProviderMeta {
|
||||
common_config_enabled_by_app: Some(CommonConfigEnabledByApp {
|
||||
opencode: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
@@ -349,13 +297,13 @@ impl OmoService {
|
||||
state.db.save_provider("opencode", &provider)?;
|
||||
state
|
||||
.db
|
||||
.set_omo_provider_current("opencode", &provider.id, v.category)?;
|
||||
Self::write_config_to_file(state, v)?;
|
||||
.set_omo_provider_current("opencode", &provider.id)?;
|
||||
Self::write_config_to_file(state)?;
|
||||
Ok(provider)
|
||||
}
|
||||
|
||||
pub fn read_local_file(v: &OmoVariant) -> Result<OmoLocalFileData, AppError> {
|
||||
let actual_path = Self::resolve_local_config_path(v)?;
|
||||
pub fn read_local_file() -> Result<OmoLocalFileData, AppError> {
|
||||
let actual_path = Self::resolve_local_config_path()?;
|
||||
let metadata = std::fs::metadata(&actual_path).ok();
|
||||
let last_modified = metadata
|
||||
.and_then(|m| m.modified().ok())
|
||||
@@ -363,28 +311,22 @@ impl OmoService {
|
||||
|
||||
let obj = Self::read_jsonc_object(&actual_path)?;
|
||||
|
||||
Ok(Self::build_local_file_data(
|
||||
v,
|
||||
Ok(Self::build_local_file_data_from_obj(
|
||||
&obj,
|
||||
actual_path.to_string_lossy().to_string(),
|
||||
last_modified,
|
||||
))
|
||||
}
|
||||
|
||||
fn build_local_file_data(
|
||||
v: &OmoVariant,
|
||||
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 = if v.has_categories {
|
||||
obj.get("categories").cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let categories = obj.get("categories").cloned();
|
||||
|
||||
let other = Self::extract_other_fields_with_keys(obj, v.known_keys);
|
||||
let other = Self::extract_other_fields(obj);
|
||||
let other_fields = if other.is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -484,7 +426,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_merge_config_empty() {
|
||||
let global = OmoGlobalConfig::default();
|
||||
let merged = OmoService::merge_config(&STANDARD, &global, None);
|
||||
let merged = OmoService::merge_config(&global, None);
|
||||
assert!(merged.is_object());
|
||||
}
|
||||
|
||||
@@ -501,7 +443,7 @@ mod tests {
|
||||
let categories = None;
|
||||
let other_fields = None;
|
||||
let profile_data = (agents, categories, other_fields, true);
|
||||
let merged = OmoService::merge_config(&STANDARD, &global, Some(&profile_data));
|
||||
let merged = OmoService::merge_config(&global, Some(&profile_data));
|
||||
let obj = merged.as_object().unwrap();
|
||||
|
||||
assert_eq!(obj["$schema"], "https://example.com/schema.json");
|
||||
@@ -523,7 +465,7 @@ mod tests {
|
||||
let categories = None;
|
||||
let other_fields = None;
|
||||
let profile_data = (agents, categories, other_fields, false);
|
||||
let merged = OmoService::merge_config(&STANDARD, &global, Some(&profile_data));
|
||||
let merged = OmoService::merge_config(&global, Some(&profile_data));
|
||||
let obj = merged.as_object().unwrap();
|
||||
|
||||
assert!(!obj.contains_key("$schema"));
|
||||
@@ -548,8 +490,7 @@ mod tests {
|
||||
});
|
||||
let obj_map = obj.as_object().unwrap().clone();
|
||||
|
||||
let data = OmoService::build_local_file_data(
|
||||
&STANDARD,
|
||||
let data = OmoService::build_local_file_data_from_obj(
|
||||
&obj_map,
|
||||
"/tmp/oh-my-opencode.jsonc".to_string(),
|
||||
None,
|
||||
@@ -581,43 +522,11 @@ mod tests {
|
||||
let other_fields = Some(serde_json::json!("profile_non_object"));
|
||||
let profile_data = (agents, categories, other_fields, true);
|
||||
|
||||
let merged = OmoService::merge_config(&STANDARD, &global, Some(&profile_data));
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_config_slim_excludes_categories_and_extra_fields() {
|
||||
let global = OmoGlobalConfig {
|
||||
schema_url: Some("https://slim.schema".to_string()),
|
||||
disabled_agents: vec!["oracle".to_string()],
|
||||
disabled_skills: vec!["playwright".to_string()],
|
||||
background_task: Some(serde_json::json!({"key": "val"})),
|
||||
browser_automation_engine: Some(serde_json::json!({"provider": "pw"})),
|
||||
claude_code: Some(serde_json::json!({"mcp": true})),
|
||||
..Default::default()
|
||||
};
|
||||
let agents = Some(serde_json::json!({"orchestrator": {"model": "k2"}}));
|
||||
let categories = Some(serde_json::json!({"code": {"model": "gpt"}}));
|
||||
let other_fields = None;
|
||||
let profile_data = (agents, categories, other_fields, true);
|
||||
|
||||
let merged = OmoService::merge_config(&SLIM, &global, Some(&profile_data));
|
||||
let obj = merged.as_object().unwrap();
|
||||
|
||||
// Slim should NOT include these
|
||||
assert!(!obj.contains_key("disabled_skills"));
|
||||
assert!(!obj.contains_key("background_task"));
|
||||
assert!(!obj.contains_key("browser_automation_engine"));
|
||||
assert!(!obj.contains_key("claude_code"));
|
||||
assert!(!obj.contains_key("categories"));
|
||||
|
||||
// Slim SHOULD include these
|
||||
assert_eq!(obj["$schema"], "https://slim.schema");
|
||||
assert!(obj.contains_key("agents"));
|
||||
assert!(obj.contains_key("disabled_agents"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
//! Live configuration operations
|
||||
//!
|
||||
//! Handles reading and writing live configuration files for Claude, Codex, and Gemini.
|
||||
//!
|
||||
//! ## Common Config Runtime Merge
|
||||
//!
|
||||
//! When writing to live files, this module performs runtime merge of:
|
||||
//! - `customConfig` (provider's settings_config) - provider-specific settings
|
||||
//! - `commonConfig` (from database settings table) - shared template settings
|
||||
//!
|
||||
//! The merge follows the rule: customConfig overrides commonConfig.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -9,6 +17,7 @@ use serde_json::{json, Value};
|
||||
use crate::app_config::AppType;
|
||||
use crate::codex_config::{get_codex_auth_path, get_codex_config_path};
|
||||
use crate::config::{delete_file, get_claude_settings_path, read_json_file, write_json_file};
|
||||
use crate::config_merge::merge_config_for_live;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::services::mcp::McpService;
|
||||
@@ -104,24 +113,86 @@ impl LiveSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write live configuration snapshot for a provider
|
||||
/// Write live configuration snapshot for a provider (raw, without common config merge)
|
||||
///
|
||||
/// This function writes the provider's settings_config directly to the live file.
|
||||
/// Use `write_live_snapshot_with_merge` for runtime merge with common config.
|
||||
pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
|
||||
write_live_snapshot_internal(app_type, provider, &provider.settings_config)
|
||||
}
|
||||
|
||||
/// Write live configuration snapshot with common config runtime merge
|
||||
///
|
||||
/// This function performs runtime merge of:
|
||||
/// - Provider's settings_config (custom config)
|
||||
/// - Common config snippet from database (shared template)
|
||||
///
|
||||
/// The merge rule is: customConfig overrides commonConfig.
|
||||
pub(crate) fn write_live_snapshot_with_merge(
|
||||
state: &AppState,
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
) -> Result<(), AppError> {
|
||||
// Get common config snippet from database
|
||||
let common_config_snippet = state.db.get_config_snippet(app_type.as_str())?;
|
||||
|
||||
// Use shared merge function (single source of truth)
|
||||
let merge_result = merge_config_for_live(app_type, provider, common_config_snippet.as_deref());
|
||||
|
||||
// Log warning if any
|
||||
if let Some(warning) = &merge_result.warning {
|
||||
log::warn!(
|
||||
"Common config merge warning for {:?} provider '{}': {}",
|
||||
app_type,
|
||||
provider.id,
|
||||
warning
|
||||
);
|
||||
}
|
||||
|
||||
// Check if merge actually happened (config changed)
|
||||
if merge_result.config != provider.settings_config {
|
||||
log::debug!(
|
||||
"Writing live config with common config merge for {:?} provider '{}'",
|
||||
app_type,
|
||||
provider.id
|
||||
);
|
||||
}
|
||||
|
||||
// Write the merged config to live file
|
||||
let merged_provider = Provider {
|
||||
settings_config: merge_result.config,
|
||||
..provider.clone()
|
||||
};
|
||||
write_live_snapshot_internal(app_type, &merged_provider, &merged_provider.settings_config)
|
||||
}
|
||||
|
||||
/// Internal function to write live configuration
|
||||
fn write_live_snapshot_internal(
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
config_to_write: &Value,
|
||||
) -> Result<(), AppError> {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
let path = get_claude_settings_path();
|
||||
let settings = sanitize_claude_settings_for_live(&provider.settings_config);
|
||||
let settings = sanitize_claude_settings_for_live(config_to_write);
|
||||
write_json_file(&path, &settings)?;
|
||||
}
|
||||
AppType::Codex => {
|
||||
let obj = provider
|
||||
.settings_config
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?;
|
||||
let auth = obj
|
||||
.get("auth")
|
||||
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
|
||||
let obj = config_to_write.as_object().ok_or_else(|| {
|
||||
AppError::Config(
|
||||
"CODEX_CONFIG_NOT_OBJECT: settings_config must be a JSON object".to_string(),
|
||||
)
|
||||
})?;
|
||||
let auth = obj.get("auth").ok_or_else(|| {
|
||||
AppError::Config(
|
||||
"CODEX_CONFIG_MISSING_AUTH: settings_config missing 'auth' field".to_string(),
|
||||
)
|
||||
})?;
|
||||
let config_str = obj.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string())
|
||||
AppError::Config(
|
||||
"CODEX_CONFIG_MISSING_CONFIG: settings_config missing 'config' field or not a string".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let auth_path = get_codex_auth_path();
|
||||
@@ -131,7 +202,12 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
}
|
||||
AppType::Gemini => {
|
||||
// Delegate to write_gemini_live which handles env file writing correctly
|
||||
write_gemini_live(provider)?;
|
||||
// Create a temporary provider with the merged config
|
||||
let temp_provider = Provider {
|
||||
settings_config: config_to_write.clone(),
|
||||
..provider.clone()
|
||||
};
|
||||
write_gemini_live(&temp_provider)?;
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses additive mode - write provider to config
|
||||
@@ -139,7 +215,7 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
use crate::provider::OpenCodeProviderConfig;
|
||||
|
||||
// Defensive check: if settings_config is a full config structure, extract provider fragment
|
||||
let config_to_write = if let Some(obj) = provider.settings_config.as_object() {
|
||||
let config_to_write = if let Some(obj) = config_to_write.as_object() {
|
||||
// Detect full config structure (has $schema or top-level provider field)
|
||||
if obj.contains_key("$schema") || obj.contains_key("provider") {
|
||||
log::warn!(
|
||||
@@ -191,485 +267,10 @@ 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(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Key fields definitions for partial merge
|
||||
// ============================================================================
|
||||
|
||||
/// Claude env-level key fields that belong to the provider.
|
||||
/// When adding a new field here, also update backfill_claude_key_fields().
|
||||
const CLAUDE_KEY_ENV_FIELDS: &[&str] = &[
|
||||
// --- API auth & endpoint ---
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
// --- Model selection ---
|
||||
"ANTHROPIC_MODEL",
|
||||
"ANTHROPIC_REASONING_MODEL",
|
||||
"ANTHROPIC_SMALL_FAST_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"CLAUDE_CODE_SUBAGENT_MODEL",
|
||||
// --- AWS Bedrock ---
|
||||
"CLAUDE_CODE_USE_BEDROCK",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AWS_SESSION_TOKEN",
|
||||
"AWS_REGION",
|
||||
"AWS_PROFILE",
|
||||
"ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION",
|
||||
// --- Google Vertex AI ---
|
||||
"CLAUDE_CODE_USE_VERTEX",
|
||||
"ANTHROPIC_VERTEX_PROJECT_ID",
|
||||
"CLOUD_ML_REGION",
|
||||
// --- Microsoft Foundry ---
|
||||
"CLAUDE_CODE_USE_FOUNDRY",
|
||||
// --- Provider behavior ---
|
||||
"CLAUDE_CODE_MAX_OUTPUT_TOKENS",
|
||||
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
|
||||
"API_TIMEOUT_MS",
|
||||
"DISABLE_PROMPT_CACHING",
|
||||
];
|
||||
|
||||
/// Claude top-level key fields (legacy + modern format).
|
||||
/// When adding a new field here, also update backfill_claude_key_fields().
|
||||
const CLAUDE_KEY_TOP_LEVEL: &[&str] = &[
|
||||
"apiBaseUrl", // legacy
|
||||
"primaryModel", // legacy
|
||||
"smallFastModel", // legacy
|
||||
"model", // modern
|
||||
"apiKey", // Bedrock API Key auth
|
||||
];
|
||||
|
||||
/// Codex TOML key fields.
|
||||
/// When adding a new field here, also update backfill_codex_key_fields().
|
||||
const CODEX_KEY_TOP_LEVEL: &[&str] = &[
|
||||
"model_provider",
|
||||
"model",
|
||||
"model_reasoning_effort",
|
||||
"review_model",
|
||||
"plan_mode_reasoning_effort",
|
||||
];
|
||||
|
||||
/// Gemini env-level key fields.
|
||||
/// When adding a new field here, also update backfill_gemini_key_fields().
|
||||
const GEMINI_KEY_ENV_FIELDS: &[&str] = &[
|
||||
"GOOGLE_GEMINI_BASE_URL",
|
||||
"GEMINI_API_KEY",
|
||||
"GEMINI_MODEL",
|
||||
"GOOGLE_API_KEY",
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Partial merge: write only key fields to live config
|
||||
// ============================================================================
|
||||
|
||||
/// Write only provider-specific key fields to live configuration,
|
||||
/// preserving all other user settings in the live file.
|
||||
///
|
||||
/// Used for switch-mode apps (Claude, Codex, Gemini) during:
|
||||
/// - `switch_normal()` — switching providers
|
||||
/// - `sync_current_to_live()` — startup sync
|
||||
/// - `add()` / `update()` when the provider is current
|
||||
pub(crate) fn write_live_partial(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
|
||||
match app_type {
|
||||
AppType::Claude => write_claude_live_partial(provider),
|
||||
AppType::Codex => write_codex_live_partial(provider),
|
||||
AppType::Gemini => write_gemini_live_partial(provider),
|
||||
// Additive mode apps still use full snapshot
|
||||
AppType::OpenCode | AppType::OpenClaw => write_live_snapshot(app_type, provider),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a JSON merge patch (RFC 7396) directly to Claude live settings.json.
|
||||
/// Used for user-level preferences (attribution, thinking, etc.) that are
|
||||
/// independent of the active provider.
|
||||
pub fn patch_claude_live(patch: Value) -> Result<(), AppError> {
|
||||
let path = get_claude_settings_path();
|
||||
let mut live = if path.exists() {
|
||||
read_json_file(&path).unwrap_or_else(|_| json!({}))
|
||||
} else {
|
||||
json!({})
|
||||
};
|
||||
json_merge_patch(&mut live, &patch);
|
||||
let settings = sanitize_claude_settings_for_live(&live);
|
||||
write_json_file(&path, &settings)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RFC 7396 JSON Merge Patch: null deletes, objects merge recursively, rest overwrites.
|
||||
fn json_merge_patch(target: &mut Value, patch: &Value) {
|
||||
if let Some(patch_obj) = patch.as_object() {
|
||||
if !target.is_object() {
|
||||
*target = json!({});
|
||||
}
|
||||
let target_obj = target.as_object_mut().unwrap();
|
||||
for (key, value) in patch_obj {
|
||||
if value.is_null() {
|
||||
target_obj.remove(key);
|
||||
} else if value.is_object() {
|
||||
let entry = target_obj.entry(key.clone()).or_insert(json!({}));
|
||||
json_merge_patch(entry, value);
|
||||
// Clean up empty container objects
|
||||
if entry.as_object().map_or(false, |o| o.is_empty()) {
|
||||
target_obj.remove(key);
|
||||
}
|
||||
} else {
|
||||
target_obj.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Claude: merge only key env and top-level fields into live settings.json
|
||||
fn write_claude_live_partial(provider: &Provider) -> Result<(), AppError> {
|
||||
let path = get_claude_settings_path();
|
||||
|
||||
// 1. Read existing live config (start from empty if file doesn't exist)
|
||||
let mut live = if path.exists() {
|
||||
read_json_file(&path).unwrap_or_else(|_| json!({}))
|
||||
} else {
|
||||
json!({})
|
||||
};
|
||||
|
||||
// 2. Ensure live.env exists as an object
|
||||
if !live.get("env").is_some_and(|v| v.is_object()) {
|
||||
live.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("env".into(), json!({}));
|
||||
}
|
||||
|
||||
// 3. Clear key env fields from live, then write from provider
|
||||
let live_env = live.get_mut("env").unwrap().as_object_mut().unwrap();
|
||||
for key in CLAUDE_KEY_ENV_FIELDS {
|
||||
live_env.remove(*key);
|
||||
}
|
||||
|
||||
if let Some(provider_env) = provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|v| v.as_object())
|
||||
{
|
||||
for key in CLAUDE_KEY_ENV_FIELDS {
|
||||
if let Some(value) = provider_env.get(*key) {
|
||||
live_env.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Handle top-level legacy key fields
|
||||
let live_obj = live.as_object_mut().unwrap();
|
||||
for key in CLAUDE_KEY_TOP_LEVEL {
|
||||
live_obj.remove(*key);
|
||||
}
|
||||
if let Some(provider_obj) = provider.settings_config.as_object() {
|
||||
for key in CLAUDE_KEY_TOP_LEVEL {
|
||||
if let Some(value) = provider_obj.get(*key) {
|
||||
live_obj.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Sanitize and write
|
||||
let settings = sanitize_claude_settings_for_live(&live);
|
||||
write_json_file(&path, &settings)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Codex: replace auth.json entirely, partially merge config.toml key fields
|
||||
fn write_codex_live_partial(provider: &Provider) -> Result<(), AppError> {
|
||||
let obj = provider
|
||||
.settings_config
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?;
|
||||
|
||||
// auth.json is entirely provider-specific, replace it wholesale
|
||||
let auth = obj
|
||||
.get("auth")
|
||||
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
|
||||
|
||||
let provider_config_str = obj.get("config").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
// Read existing config.toml (or start from empty)
|
||||
let config_path = get_codex_config_path();
|
||||
let existing_toml = if config_path.exists() {
|
||||
std::fs::read_to_string(&config_path).unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Parse both existing and provider TOML
|
||||
let mut live_doc = existing_toml
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.unwrap_or_else(|_| toml_edit::DocumentMut::new());
|
||||
|
||||
// Remove key fields from live doc
|
||||
let live_root = live_doc.as_table_mut();
|
||||
for key in CODEX_KEY_TOP_LEVEL {
|
||||
live_root.remove(key);
|
||||
}
|
||||
live_root.remove("model_providers");
|
||||
|
||||
// Parse provider TOML and extract key fields
|
||||
if !provider_config_str.is_empty() {
|
||||
if let Ok(provider_doc) = provider_config_str.parse::<toml_edit::DocumentMut>() {
|
||||
let provider_root = provider_doc.as_table();
|
||||
|
||||
// Copy key top-level fields from provider
|
||||
for key in CODEX_KEY_TOP_LEVEL {
|
||||
if let Some(item) = provider_root.get(key) {
|
||||
live_root.insert(key, item.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Copy model_providers table from provider
|
||||
if let Some(mp) = provider_root.get("model_providers") {
|
||||
live_root.insert("model_providers", mp.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write using atomic write
|
||||
crate::codex_config::write_codex_live_atomic(auth, Some(&live_doc.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gemini: merge only key env fields, preserve settings.json (MCP etc.)
|
||||
fn write_gemini_live_partial(provider: &Provider) -> Result<(), AppError> {
|
||||
use crate::gemini_config::{get_gemini_env_path, read_gemini_env, write_gemini_env_atomic};
|
||||
|
||||
let auth_type = detect_gemini_auth_type(provider);
|
||||
|
||||
// 1. Read existing env from live .env file
|
||||
let mut env_map = if get_gemini_env_path().exists() {
|
||||
read_gemini_env().unwrap_or_default()
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
// 2. Remove key fields from existing env
|
||||
for key in GEMINI_KEY_ENV_FIELDS {
|
||||
env_map.remove(*key);
|
||||
}
|
||||
|
||||
// 3. Extract key fields from provider and merge
|
||||
if let Some(provider_env) = provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|v| v.as_object())
|
||||
{
|
||||
for key in GEMINI_KEY_ENV_FIELDS {
|
||||
if let Some(value) = provider_env.get(*key).and_then(|v| v.as_str()) {
|
||||
if !value.is_empty() {
|
||||
env_map.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Handle auth type specific behavior
|
||||
match auth_type {
|
||||
GeminiAuthType::GoogleOfficial => {
|
||||
// Google official uses OAuth, clear all env
|
||||
env_map.clear();
|
||||
write_gemini_env_atomic(&env_map)?;
|
||||
}
|
||||
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
|
||||
// Validate and write env
|
||||
crate::gemini_config::validate_gemini_settings_strict(&provider.settings_config)?;
|
||||
write_gemini_env_atomic(&env_map)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Handle settings.json (same as write_gemini_live — preserve existing MCP etc.)
|
||||
use crate::gemini_config::get_gemini_settings_path;
|
||||
let settings_path = get_gemini_settings_path();
|
||||
|
||||
if let Some(config_value) = provider.settings_config.get("config") {
|
||||
if config_value.is_object() {
|
||||
let mut merged = if settings_path.exists() {
|
||||
read_json_file::<Value>(&settings_path).unwrap_or_else(|_| json!({}))
|
||||
} else {
|
||||
json!({})
|
||||
};
|
||||
if let (Some(merged_obj), Some(config_obj)) =
|
||||
(merged.as_object_mut(), config_value.as_object())
|
||||
{
|
||||
for (k, v) in config_obj {
|
||||
merged_obj.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
write_json_file(&settings_path, &merged)?;
|
||||
} else if !config_value.is_null() {
|
||||
return Err(AppError::localized(
|
||||
"gemini.validation.invalid_config",
|
||||
"Gemini 配置格式错误: config 必须是对象或 null",
|
||||
"Gemini config invalid: config must be an object or null",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Set security flag based on auth type
|
||||
match auth_type {
|
||||
GeminiAuthType::GoogleOfficial => ensure_google_oauth_security_flag(provider)?,
|
||||
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
|
||||
crate::gemini_config::write_packycode_settings()?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Backfill: extract only key fields from live config
|
||||
// ============================================================================
|
||||
|
||||
/// Extract only provider-specific key fields from a live config value.
|
||||
///
|
||||
/// Used during backfill to ensure the provider's `settings_config` converges
|
||||
/// to containing only key fields over time.
|
||||
pub(crate) fn backfill_key_fields(app_type: &AppType, live_config: &Value) -> Value {
|
||||
match app_type {
|
||||
AppType::Claude => backfill_claude_key_fields(live_config),
|
||||
AppType::Codex => backfill_codex_key_fields(live_config),
|
||||
AppType::Gemini => backfill_gemini_key_fields(live_config),
|
||||
// Additive mode: return full config (no backfill needed)
|
||||
_ => live_config.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn backfill_claude_key_fields(live: &Value) -> Value {
|
||||
let mut result = json!({});
|
||||
let result_obj = result.as_object_mut().unwrap();
|
||||
|
||||
// Extract key env fields
|
||||
if let Some(live_env) = live.get("env").and_then(|v| v.as_object()) {
|
||||
let mut env_obj = serde_json::Map::new();
|
||||
for key in CLAUDE_KEY_ENV_FIELDS {
|
||||
if let Some(value) = live_env.get(*key) {
|
||||
env_obj.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
if !env_obj.is_empty() {
|
||||
result_obj.insert("env".to_string(), Value::Object(env_obj));
|
||||
}
|
||||
}
|
||||
|
||||
// Extract key top-level fields
|
||||
if let Some(live_obj) = live.as_object() {
|
||||
for key in CLAUDE_KEY_TOP_LEVEL {
|
||||
if let Some(value) = live_obj.get(*key) {
|
||||
result_obj.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn backfill_codex_key_fields(live: &Value) -> Value {
|
||||
let mut result = json!({});
|
||||
let result_obj = result.as_object_mut().unwrap();
|
||||
|
||||
// auth is entirely provider-specific — keep it as-is
|
||||
if let Some(auth) = live.get("auth") {
|
||||
result_obj.insert("auth".to_string(), auth.clone());
|
||||
}
|
||||
|
||||
// Extract key TOML fields from config string
|
||||
if let Some(config_str) = live.get("config").and_then(|v| v.as_str()) {
|
||||
if let Ok(doc) = config_str.parse::<toml_edit::DocumentMut>() {
|
||||
let mut new_doc = toml_edit::DocumentMut::new();
|
||||
let new_root = new_doc.as_table_mut();
|
||||
|
||||
// Copy key top-level fields
|
||||
for key in CODEX_KEY_TOP_LEVEL {
|
||||
if let Some(item) = doc.as_table().get(key) {
|
||||
new_root.insert(key, item.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Copy model_providers table
|
||||
if let Some(mp) = doc.as_table().get("model_providers") {
|
||||
new_root.insert("model_providers", mp.clone());
|
||||
}
|
||||
|
||||
let toml_str = new_doc.to_string();
|
||||
if !toml_str.trim().is_empty() {
|
||||
result_obj.insert("config".to_string(), Value::String(toml_str));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn backfill_gemini_key_fields(live: &Value) -> Value {
|
||||
let mut result = json!({});
|
||||
let result_obj = result.as_object_mut().unwrap();
|
||||
|
||||
// Extract key env fields
|
||||
if let Some(live_env) = live.get("env").and_then(|v| v.as_object()) {
|
||||
let mut env_obj = serde_json::Map::new();
|
||||
for key in GEMINI_KEY_ENV_FIELDS {
|
||||
if let Some(value) = live_env.get(*key) {
|
||||
env_obj.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
if !env_obj.is_empty() {
|
||||
result_obj.insert("env".to_string(), Value::Object(env_obj));
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Sync all providers to live configuration (for additive mode apps)
|
||||
///
|
||||
/// Writes all providers from the database to the live configuration file.
|
||||
@@ -702,6 +303,8 @@ fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<()
|
||||
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
||||
/// 这确保了配置导入后无效 ID 会自动 fallback 到数据库。
|
||||
///
|
||||
/// This function uses `write_live_snapshot_with_merge` to perform runtime merge
|
||||
/// with common config when enabled.
|
||||
/// For additive mode apps (OpenCode), all providers are synced instead of just the current one.
|
||||
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
||||
// Sync providers based on mode
|
||||
@@ -719,7 +322,8 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
||||
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if let Some(provider) = providers.get(¤t_id) {
|
||||
write_live_partial(&app_type, provider)?;
|
||||
// Use write_live_snapshot_with_merge to support common config runtime merge
|
||||
write_live_snapshot_with_merge(state, &app_type, provider)?;
|
||||
}
|
||||
// Note: get_effective_current_provider already validates existence,
|
||||
// so providers.get() should always succeed here
|
||||
@@ -815,21 +419,6 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
|
||||
let config = read_opencode_config()?;
|
||||
Ok(config)
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
use crate::openclaw_config::{get_openclaw_config_path, read_openclaw_config};
|
||||
|
||||
let config_path = get_openclaw_config_path();
|
||||
if !config_path.exists() {
|
||||
return Err(AppError::localized(
|
||||
"openclaw.config.missing",
|
||||
"OpenClaw 配置文件不存在",
|
||||
"OpenClaw configuration file not found",
|
||||
));
|
||||
}
|
||||
|
||||
let config = read_openclaw_config()?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -838,12 +427,6 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
|
||||
/// Returns `Ok(true)` if a provider was actually imported,
|
||||
/// `Ok(false)` if skipped (providers already exist for this app).
|
||||
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
||||
// Additive mode apps (OpenCode, OpenClaw) should use their dedicated
|
||||
// import_xxx_providers_from_live functions, not this generic default config import
|
||||
if app_type.is_additive_mode() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
{
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if !providers.is_empty() {
|
||||
@@ -911,9 +494,23 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
||||
"config": config_obj
|
||||
})
|
||||
}
|
||||
// OpenCode and OpenClaw use additive mode and are handled by early return above
|
||||
AppType::OpenCode | AppType::OpenClaw => {
|
||||
unreachable!("additive mode apps are handled by early return")
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses additive mode - import from live is not the same pattern
|
||||
// For now, return an empty config structure
|
||||
use crate::opencode_config::{get_opencode_config_path, read_opencode_config};
|
||||
|
||||
let config_path = get_opencode_config_path();
|
||||
if !config_path.exists() {
|
||||
return Err(AppError::localized(
|
||||
"opencode.live.missing",
|
||||
"OpenCode 配置文件不存在",
|
||||
"OpenCode configuration file is missing",
|
||||
));
|
||||
}
|
||||
|
||||
// For OpenCode, we return the full config - but note that OpenCode
|
||||
// uses additive mode, so importing defaults works differently
|
||||
read_opencode_config()?
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1091,87 +688,3 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
/// Import all providers from OpenClaw live config to database
|
||||
///
|
||||
/// This imports existing providers from ~/.openclaw/openclaw.json
|
||||
/// into the CC Switch database. Each provider found will be added to the
|
||||
/// database with is_current set to false.
|
||||
pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, AppError> {
|
||||
use crate::openclaw_config;
|
||||
|
||||
let providers = openclaw_config::get_typed_providers()?;
|
||||
if providers.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut imported = 0;
|
||||
let existing = state.db.get_all_providers("openclaw")?;
|
||||
|
||||
for (id, config) in providers {
|
||||
// Validate: skip entries with empty id or no models
|
||||
if id.trim().is_empty() {
|
||||
log::warn!("Skipping OpenClaw provider with empty id");
|
||||
continue;
|
||||
}
|
||||
if config.models.is_empty() {
|
||||
log::warn!("Skipping OpenClaw provider '{id}': no models defined");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if already exists in database
|
||||
if existing.contains_key(&id) {
|
||||
log::debug!("OpenClaw provider '{id}' already exists in database, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert to Value for settings_config
|
||||
let settings_config = match serde_json::to_value(&config) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to serialize OpenClaw provider '{id}': {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Determine display name: use first model name if available, otherwise use id
|
||||
let display_name = config
|
||||
.models
|
||||
.first()
|
||||
.and_then(|m| m.name.clone())
|
||||
.unwrap_or_else(|| id.clone());
|
||||
|
||||
// Create provider
|
||||
let provider = Provider::with_id(id.clone(), display_name, settings_config, None);
|
||||
|
||||
// Save to database
|
||||
if let Err(e) = state.db.save_provider("openclaw", &provider) {
|
||||
log::warn!("Failed to import OpenClaw provider '{id}': {e}");
|
||||
continue;
|
||||
}
|
||||
|
||||
imported += 1;
|
||||
log::info!("Imported OpenClaw provider '{id}' from live config");
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
/// Remove an OpenClaw provider from live config
|
||||
///
|
||||
/// This removes a specific provider from ~/.openclaw/openclaw.json
|
||||
/// without affecting other providers in the file.
|
||||
pub fn remove_openclaw_provider_from_live(provider_id: &str) -> Result<(), AppError> {
|
||||
use crate::openclaw_config;
|
||||
|
||||
// Check if OpenClaw config directory exists
|
||||
if !openclaw_config::get_openclaw_dir().exists() {
|
||||
log::debug!("OpenClaw config directory doesn't exist, skipping removal of '{provider_id}'");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
openclaw_config::remove_provider(provider_id)?;
|
||||
log::info!("OpenClaw provider '{provider_id}' removed from live config");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::config_merge::{
|
||||
extract_json_difference, extract_toml_difference_str, is_common_config_enabled,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::provider::{Provider, UsageResult};
|
||||
use crate::services::mcp::McpService;
|
||||
@@ -21,31 +24,21 @@ use crate::store::AppState;
|
||||
|
||||
// Re-export sub-module functions for external access
|
||||
pub use live::{
|
||||
import_default_config, import_openclaw_providers_from_live,
|
||||
import_opencode_providers_from_live, read_live_settings, sync_current_to_live,
|
||||
import_default_config, import_opencode_providers_from_live, read_live_settings,
|
||||
sync_current_to_live,
|
||||
};
|
||||
|
||||
// Internal re-exports (pub(crate))
|
||||
pub(crate) use live::sanitize_claude_settings_for_live;
|
||||
pub(crate) use live::write_live_partial;
|
||||
pub(crate) use live::{write_live_snapshot, write_live_snapshot_with_merge};
|
||||
|
||||
// Internal re-exports
|
||||
use live::{
|
||||
backfill_key_fields, remove_openclaw_provider_from_live, remove_opencode_provider_from_live,
|
||||
write_live_snapshot,
|
||||
};
|
||||
use live::{remove_opencode_provider_from_live, write_gemini_live};
|
||||
use usage::validate_usage_script;
|
||||
|
||||
/// Provider business logic service
|
||||
pub struct ProviderService;
|
||||
|
||||
/// Result of a provider switch operation, including any non-fatal warnings
|
||||
#[derive(Debug, serde::Serialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SwitchResult {
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -85,6 +78,47 @@ mod tests {
|
||||
assert_eq!(api_key, "token");
|
||||
assert_eq!(base_url, "https://claude.example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_codex_common_config_preserves_mcp_servers_base_url() {
|
||||
let config_toml = r#"model_provider = "azure"
|
||||
model = "gpt-4"
|
||||
disable_response_storage = true
|
||||
|
||||
[model_providers.azure]
|
||||
name = "Azure OpenAI"
|
||||
base_url = "https://azure.example/v1"
|
||||
wire_api = "responses"
|
||||
|
||||
[mcp_servers.my_server]
|
||||
base_url = "http://localhost:8080"
|
||||
"#;
|
||||
|
||||
let settings = json!({ "config": config_toml });
|
||||
let extracted = ProviderService::extract_codex_common_config(&settings)
|
||||
.expect("extract_codex_common_config should succeed");
|
||||
|
||||
assert!(
|
||||
!extracted
|
||||
.lines()
|
||||
.any(|line| line.trim_start().starts_with("model_provider")),
|
||||
"should remove top-level model_provider"
|
||||
);
|
||||
assert!(
|
||||
!extracted
|
||||
.lines()
|
||||
.any(|line| line.trim_start().starts_with("model =")),
|
||||
"should remove top-level model"
|
||||
);
|
||||
assert!(
|
||||
!extracted.contains("[model_providers"),
|
||||
"should remove entire model_providers table"
|
||||
);
|
||||
assert!(
|
||||
extracted.contains("http://localhost:8080"),
|
||||
"should keep mcp_servers.* base_url"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderService {
|
||||
@@ -111,10 +145,10 @@ impl ProviderService {
|
||||
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
||||
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
|
||||
///
|
||||
/// 对于累加模式应用(OpenCode, OpenClaw),不存在"当前供应商"概念,直接返回空字符串。
|
||||
/// 对于 OpenCode(累加模式),不存在"当前供应商"概念,直接返回空字符串。
|
||||
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
|
||||
// Additive mode apps have no "current" provider concept
|
||||
if app_type.is_additive_mode() {
|
||||
// OpenCode uses additive mode - no "current" provider concept
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
return Ok(String::new());
|
||||
}
|
||||
crate::settings::get_effective_current_provider(&state.db, &app_type)
|
||||
@@ -131,11 +165,10 @@ impl ProviderService {
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
|
||||
// Additive mode apps (OpenCode, OpenClaw) - always write to live config
|
||||
if app_type.is_additive_mode() {
|
||||
// OpenCode uses additive mode - always write to live config
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
// OMO providers use exclusive mode and write to dedicated config file.
|
||||
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo")
|
||||
{
|
||||
if provider.category.as_deref() == Some("omo") {
|
||||
// Do not auto-enable newly added OMO providers.
|
||||
// Users must explicitly switch/apply an OMO provider to activate it.
|
||||
return Ok(true);
|
||||
@@ -151,7 +184,8 @@ impl ProviderService {
|
||||
state
|
||||
.db
|
||||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
||||
write_live_partial(&app_type, &provider)?;
|
||||
// Use write_live_snapshot_with_merge to support common config runtime merge
|
||||
write_live_snapshot_with_merge(state, &app_type, &provider)?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
@@ -171,35 +205,14 @@ impl ProviderService {
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
|
||||
// Additive mode apps (OpenCode, OpenClaw) - always update in live config
|
||||
if app_type.is_additive_mode() {
|
||||
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo")
|
||||
{
|
||||
let is_omo_current =
|
||||
state
|
||||
.db
|
||||
.is_omo_provider_current(app_type.as_str(), &provider.id, "omo")?;
|
||||
// OpenCode uses additive mode - always update in live config
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
if provider.category.as_deref() == Some("omo") {
|
||||
let is_omo_current = state
|
||||
.db
|
||||
.is_omo_provider_current(app_type.as_str(), &provider.id)?;
|
||||
if is_omo_current {
|
||||
crate::services::OmoService::write_config_to_file(
|
||||
state,
|
||||
&crate::services::omo::STANDARD,
|
||||
)?;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if matches!(app_type, AppType::OpenCode)
|
||||
&& provider.category.as_deref() == Some("omo-slim")
|
||||
{
|
||||
let is_current = state.db.is_omo_provider_current(
|
||||
app_type.as_str(),
|
||||
&provider.id,
|
||||
"omo-slim",
|
||||
)?;
|
||||
if is_current {
|
||||
crate::services::OmoService::write_config_to_file(
|
||||
state,
|
||||
&crate::services::omo::SLIM,
|
||||
)?;
|
||||
crate::services::OmoService::write_config_to_file(state)?;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -232,7 +245,8 @@ impl ProviderService {
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||||
} else {
|
||||
write_live_partial(&app_type, &provider)?;
|
||||
// Use write_live_snapshot_with_merge to support common config runtime merge
|
||||
write_live_snapshot_with_merge(state, &app_type, &provider)?;
|
||||
// Sync MCP
|
||||
McpService::sync_all_enabled(state)?;
|
||||
}
|
||||
@@ -244,78 +258,43 @@ impl ProviderService {
|
||||
/// Delete a provider
|
||||
///
|
||||
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
|
||||
/// 对于累加模式应用(OpenCode, OpenClaw),可以随时删除任意供应商,同时从 live 配置中移除。
|
||||
/// 对于 OpenCode(累加模式),可以随时删除任意供应商,同时从 live 配置中移除。
|
||||
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||
// Additive mode apps - no current provider concept
|
||||
if app_type.is_additive_mode() {
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
let provider_category = state
|
||||
// OpenCode uses additive mode - no current provider concept
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
let is_omo = state
|
||||
.db
|
||||
.get_provider_by_id(id, app_type.as_str())?
|
||||
.and_then(|p| p.category)
|
||||
.as_deref()
|
||||
== Some("omo");
|
||||
|
||||
if is_omo {
|
||||
let was_current = state.db.is_omo_provider_current(app_type.as_str(), id)?;
|
||||
let omo_count = state
|
||||
.db
|
||||
.get_provider_by_id(id, app_type.as_str())?
|
||||
.and_then(|p| p.category);
|
||||
.get_all_providers(app_type.as_str())?
|
||||
.values()
|
||||
.filter(|p| p.category.as_deref() == Some("omo"))
|
||||
.count();
|
||||
|
||||
if provider_category.as_deref() == Some("omo") {
|
||||
let was_current =
|
||||
state
|
||||
.db
|
||||
.is_omo_provider_current(app_type.as_str(), id, "omo")?;
|
||||
let omo_count = state
|
||||
.db
|
||||
.get_all_providers(app_type.as_str())?
|
||||
.values()
|
||||
.filter(|p| p.category.as_deref() == Some("omo"))
|
||||
.count();
|
||||
|
||||
if omo_count <= 1 && was_current {
|
||||
return Err(AppError::Message(
|
||||
"无法删除当前启用的最后一个 OMO 配置,请先停用".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
state.db.delete_provider(app_type.as_str(), id)?;
|
||||
if was_current {
|
||||
crate::services::OmoService::delete_config_file(
|
||||
&crate::services::omo::STANDARD,
|
||||
)?;
|
||||
}
|
||||
return Ok(());
|
||||
if omo_count <= 1 && was_current {
|
||||
return Err(AppError::Message(
|
||||
"无法删除当前启用的最后一个 OMO 配置,请先停用".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if provider_category.as_deref() == Some("omo-slim") {
|
||||
let was_current =
|
||||
state
|
||||
.db
|
||||
.is_omo_provider_current(app_type.as_str(), id, "omo-slim")?;
|
||||
let slim_count = state
|
||||
.db
|
||||
.get_all_providers(app_type.as_str())?
|
||||
.values()
|
||||
.filter(|p| p.category.as_deref() == Some("omo-slim"))
|
||||
.count();
|
||||
|
||||
if slim_count <= 1 && was_current {
|
||||
return Err(AppError::Message(
|
||||
"无法删除当前启用的最后一个 OMO Slim 配置,请先停用".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
state.db.delete_provider(app_type.as_str(), id)?;
|
||||
if was_current {
|
||||
crate::services::OmoService::delete_config_file(
|
||||
&crate::services::omo::SLIM,
|
||||
)?;
|
||||
}
|
||||
return Ok(());
|
||||
state.db.delete_provider(app_type.as_str(), id)?;
|
||||
if was_current {
|
||||
crate::services::OmoService::delete_config_file()?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Remove from database
|
||||
state.db.delete_provider(app_type.as_str(), id)?;
|
||||
// Also remove from live config
|
||||
match app_type {
|
||||
AppType::OpenCode => remove_opencode_provider_from_live(id)?,
|
||||
AppType::OpenClaw => remove_openclaw_provider_from_live(id)?,
|
||||
_ => {} // Should not reach here
|
||||
}
|
||||
remove_opencode_provider_from_live(id)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -332,7 +311,7 @@ impl ProviderService {
|
||||
state.db.delete_provider(app_type.as_str(), id)
|
||||
}
|
||||
|
||||
/// Remove provider from live config only (for additive mode apps like OpenCode, OpenClaw)
|
||||
/// Remove provider from live config only (for additive mode apps like OpenCode)
|
||||
///
|
||||
/// Does NOT delete from database - provider remains in the list.
|
||||
/// This is used when user wants to "remove" a provider from active config
|
||||
@@ -344,54 +323,27 @@ impl ProviderService {
|
||||
) -> Result<(), AppError> {
|
||||
match app_type {
|
||||
AppType::OpenCode => {
|
||||
let provider_category = state
|
||||
let is_omo = state
|
||||
.db
|
||||
.get_provider_by_id(id, app_type.as_str())?
|
||||
.and_then(|p| p.category);
|
||||
.and_then(|p| p.category)
|
||||
.as_deref()
|
||||
== Some("omo");
|
||||
|
||||
if provider_category.as_deref() == Some("omo") {
|
||||
state
|
||||
.db
|
||||
.clear_omo_provider_current(app_type.as_str(), id, "omo")?;
|
||||
let still_has_current = state
|
||||
.db
|
||||
.get_current_omo_provider("opencode", "omo")?
|
||||
.is_some();
|
||||
if is_omo {
|
||||
state.db.clear_omo_provider_current(app_type.as_str(), id)?;
|
||||
let still_has_current =
|
||||
state.db.get_current_omo_provider("opencode")?.is_some();
|
||||
if still_has_current {
|
||||
crate::services::OmoService::write_config_to_file(
|
||||
state,
|
||||
&crate::services::omo::STANDARD,
|
||||
)?;
|
||||
crate::services::OmoService::write_config_to_file(state)?;
|
||||
} else {
|
||||
crate::services::OmoService::delete_config_file(
|
||||
&crate::services::omo::STANDARD,
|
||||
)?;
|
||||
}
|
||||
} else if provider_category.as_deref() == Some("omo-slim") {
|
||||
state
|
||||
.db
|
||||
.clear_omo_provider_current(app_type.as_str(), id, "omo-slim")?;
|
||||
let still_has_current = state
|
||||
.db
|
||||
.get_current_omo_provider("opencode", "omo-slim")?
|
||||
.is_some();
|
||||
if still_has_current {
|
||||
crate::services::OmoService::write_config_to_file(
|
||||
state,
|
||||
&crate::services::omo::SLIM,
|
||||
)?;
|
||||
} else {
|
||||
crate::services::OmoService::delete_config_file(
|
||||
&crate::services::omo::SLIM,
|
||||
)?;
|
||||
crate::services::OmoService::delete_config_file()?;
|
||||
}
|
||||
} else {
|
||||
remove_opencode_provider_from_live(id)?;
|
||||
}
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
remove_openclaw_provider_from_live(id)?;
|
||||
}
|
||||
// Future: add other additive mode apps here
|
||||
_ => {
|
||||
return Err(AppError::Message(format!(
|
||||
"App {} does not support remove from live config",
|
||||
@@ -414,7 +366,7 @@ impl ProviderService {
|
||||
/// c. Update database is_current (as default for new devices)
|
||||
/// d. Write target provider config to live files
|
||||
/// e. Sync MCP configuration
|
||||
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<SwitchResult, AppError> {
|
||||
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||
// Check if provider exists
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
let _provider = providers
|
||||
@@ -426,13 +378,6 @@ impl ProviderService {
|
||||
return Self::switch_normal(state, app_type, id, &providers);
|
||||
}
|
||||
|
||||
// OMO Slim providers are switched through their own exclusive path.
|
||||
if matches!(app_type, AppType::OpenCode)
|
||||
&& _provider.category.as_deref() == Some("omo-slim")
|
||||
{
|
||||
return Self::switch_normal(state, app_type, id, &providers);
|
||||
}
|
||||
|
||||
// Check if proxy takeover mode is active AND proxy server is actually running
|
||||
// Both conditions must be true to use hot-switch mode
|
||||
// Use blocking wait since this is a sync function
|
||||
@@ -486,7 +431,7 @@ impl ProviderService {
|
||||
|
||||
// Note: No Live config write, no MCP sync
|
||||
// The proxy server will route requests to the new provider via is_current
|
||||
return Ok(SwitchResult::default());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Normal mode: full switch with Live config write
|
||||
@@ -499,64 +444,52 @@ impl ProviderService {
|
||||
app_type: AppType,
|
||||
id: &str,
|
||||
providers: &indexmap::IndexMap<String, Provider>,
|
||||
) -> Result<SwitchResult, AppError> {
|
||||
) -> Result<(), AppError> {
|
||||
let provider = providers
|
||||
.get(id)
|
||||
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
||||
|
||||
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo") {
|
||||
state
|
||||
.db
|
||||
.set_omo_provider_current(app_type.as_str(), id, "omo")?;
|
||||
crate::services::OmoService::write_config_to_file(
|
||||
state,
|
||||
&crate::services::omo::STANDARD,
|
||||
)?;
|
||||
return Ok(SwitchResult::default());
|
||||
state.db.set_omo_provider_current(app_type.as_str(), id)?;
|
||||
crate::services::OmoService::write_config_to_file(state)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo-slim")
|
||||
{
|
||||
state
|
||||
.db
|
||||
.set_omo_provider_current(app_type.as_str(), id, "omo-slim")?;
|
||||
crate::services::OmoService::write_config_to_file(state, &crate::services::omo::SLIM)?;
|
||||
return Ok(SwitchResult::default());
|
||||
}
|
||||
|
||||
let mut result = SwitchResult::default();
|
||||
|
||||
// Backfill: Backfill current live config to current provider
|
||||
// Use effective current provider (validated existence) to ensure backfill targets valid provider
|
||||
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
||||
|
||||
if let Some(current_id) = current_id {
|
||||
if current_id != id {
|
||||
// Additive mode apps - all providers coexist in the same file,
|
||||
// OpenCode uses additive mode - 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() {
|
||||
if !matches!(app_type, AppType::OpenCode) {
|
||||
// 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() {
|
||||
// Only extract key fields from live config for backfill
|
||||
current_provider.settings_config =
|
||||
backfill_key_fields(&app_type, &live_config);
|
||||
if let Err(e) =
|
||||
state.db.save_provider(app_type.as_str(), ¤t_provider)
|
||||
{
|
||||
log::warn!("Backfill failed: {e}");
|
||||
result
|
||||
.warnings
|
||||
.push(format!("backfill_failed:{current_id}"));
|
||||
}
|
||||
// Check if common config is enabled for this provider
|
||||
let common_enabled =
|
||||
is_common_config_enabled(current_provider.meta.as_ref(), &app_type);
|
||||
|
||||
let config_to_save = if common_enabled {
|
||||
// Extract custom config from live (remove common config parts)
|
||||
Self::extract_custom_from_live(state, &app_type, &live_config)?
|
||||
} else {
|
||||
// Common config not enabled, use live config directly
|
||||
live_config
|
||||
};
|
||||
|
||||
current_provider.settings_config = config_to_save;
|
||||
// Ignore backfill failure, don't affect switch flow
|
||||
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Additive mode apps skip setting is_current (no such concept)
|
||||
if !app_type.is_additive_mode() {
|
||||
// OpenCode uses additive mode - skip setting is_current (no such concept)
|
||||
if !matches!(app_type, AppType::OpenCode) {
|
||||
// Update local settings (device-level, takes priority)
|
||||
crate::settings::set_current_provider(&app_type, Some(id))?;
|
||||
|
||||
@@ -564,10 +497,14 @@ impl ProviderService {
|
||||
state.db.set_current_provider(app_type.as_str(), id)?;
|
||||
}
|
||||
|
||||
// Sync to live (partial merge: only key fields, preserving user settings)
|
||||
write_live_partial(&app_type, provider)?;
|
||||
// Sync to live (use write_live_snapshot_with_merge for common config runtime merge)
|
||||
// Note: write_gemini_live handles security flag internally for Gemini
|
||||
write_live_snapshot_with_merge(state, &app_type, provider)?;
|
||||
|
||||
Ok(result)
|
||||
// Sync MCP
|
||||
McpService::sync_all_enabled(state)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync current provider to live configuration (re-export)
|
||||
@@ -575,6 +512,290 @@ impl ProviderService {
|
||||
sync_current_to_live(state)
|
||||
}
|
||||
|
||||
/// Extract common config snippet from current provider
|
||||
///
|
||||
/// Extracts the current provider's configuration and removes provider-specific fields
|
||||
/// (API keys, model settings, endpoints) to create a reusable common config snippet.
|
||||
pub fn extract_common_config_snippet(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
) -> Result<String, AppError> {
|
||||
// Get current provider
|
||||
let current_id = Self::current(state, app_type.clone())?;
|
||||
if current_id.is_empty() {
|
||||
return Err(AppError::Message("No current provider".to_string()));
|
||||
}
|
||||
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
let provider = providers
|
||||
.get(¤t_id)
|
||||
.ok_or_else(|| AppError::Message(format!("Provider {current_id} not found")))?;
|
||||
|
||||
match app_type {
|
||||
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract common config snippet from a config value (e.g. editor content).
|
||||
pub fn extract_common_config_snippet_from_settings(
|
||||
app_type: AppType,
|
||||
settings_config: &Value,
|
||||
) -> Result<String, AppError> {
|
||||
match app_type {
|
||||
AppType::Claude => Self::extract_claude_common_config(settings_config),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract custom config from live config (remove common config parts).
|
||||
///
|
||||
/// This is used during backfill to avoid polluting the provider's settings_config
|
||||
/// with common config values that should remain in the common config snippet.
|
||||
fn extract_custom_from_live(
|
||||
state: &AppState,
|
||||
app_type: &AppType,
|
||||
live_config: &Value,
|
||||
) -> Result<Value, AppError> {
|
||||
// Get common config snippet from database
|
||||
let common_snippet = state
|
||||
.db
|
||||
.get_config_snippet(app_type.as_str())?
|
||||
.unwrap_or_default();
|
||||
|
||||
if common_snippet.trim().is_empty() {
|
||||
// No common config, return live config as-is
|
||||
return Ok(live_config.clone());
|
||||
}
|
||||
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
// Parse common config as JSON
|
||||
let common_config: Value = serde_json::from_str(&common_snippet).map_err(|e| {
|
||||
AppError::Config(format!("Failed to parse common config snippet: {e}"))
|
||||
})?;
|
||||
|
||||
// Extract difference (custom = live - common)
|
||||
let (custom_config, _) = extract_json_difference(live_config, &common_config);
|
||||
Ok(custom_config)
|
||||
}
|
||||
AppType::Codex => {
|
||||
// Codex: Extract TOML config field difference
|
||||
let mut result = live_config.clone();
|
||||
|
||||
if let Some(config_str) = live_config.get("config").and_then(|v| v.as_str()) {
|
||||
// Extract TOML difference for config field
|
||||
// Returns (custom_toml, has_common_keys, error)
|
||||
let (custom_toml, _, _) =
|
||||
extract_toml_difference_str(config_str, &common_snippet);
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
obj.insert("config".to_string(), Value::String(custom_toml));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
AppType::Gemini => {
|
||||
// Gemini: Extract env field difference
|
||||
// Parse common config (supports ENV format and JSON format)
|
||||
let common_env = crate::config_merge::parse_gemini_common_snippet(&common_snippet);
|
||||
|
||||
if common_env.is_empty() {
|
||||
return Ok(live_config.clone());
|
||||
}
|
||||
|
||||
let mut result = live_config.clone();
|
||||
|
||||
if let Some(live_env) = live_config.get("env").and_then(|v| v.as_object()) {
|
||||
// Extract difference: custom = live_env - common_env
|
||||
let mut custom_env = serde_json::Map::new();
|
||||
for (key, value) in live_env {
|
||||
if common_env.get(key) != Some(value) {
|
||||
// Key doesn't exist in common or value is different
|
||||
custom_env.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
obj.insert("env".to_string(), Value::Object(custom_env));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support common config
|
||||
Ok(live_config.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract common config for Claude (JSON format)
|
||||
fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
let mut config = settings.clone();
|
||||
|
||||
// Fields to exclude from common config
|
||||
const ENV_EXCLUDES: &[&str] = &[
|
||||
// Auth
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
// Models (5 fields)
|
||||
"ANTHROPIC_MODEL",
|
||||
"ANTHROPIC_REASONING_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
// Endpoint
|
||||
"ANTHROPIC_BASE_URL",
|
||||
];
|
||||
|
||||
const TOP_LEVEL_EXCLUDES: &[&str] = &[
|
||||
"apiBaseUrl",
|
||||
// Legacy model fields
|
||||
"primaryModel",
|
||||
"smallFastModel",
|
||||
];
|
||||
|
||||
// Remove env fields
|
||||
if let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||||
for key in ENV_EXCLUDES {
|
||||
env.remove(*key);
|
||||
}
|
||||
// If env is empty after removal, remove the env object itself
|
||||
if env.is_empty() {
|
||||
config.as_object_mut().map(|obj| obj.remove("env"));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove top-level fields
|
||||
if let Some(obj) = config.as_object_mut() {
|
||||
for key in TOP_LEVEL_EXCLUDES {
|
||||
obj.remove(*key);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if result is empty
|
||||
if config.as_object().is_none_or(|obj| obj.is_empty()) {
|
||||
return Ok("{}".to_string());
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// Extract common config for Codex (TOML format)
|
||||
fn extract_codex_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
// Codex config is stored as { "auth": {...}, "config": "toml string" }
|
||||
let config_toml = settings
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if config_toml.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let mut doc = config_toml
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|e| AppError::Message(format!("TOML parse error: {e}")))?;
|
||||
|
||||
// Remove provider-specific fields.
|
||||
let root = doc.as_table_mut();
|
||||
root.remove("model");
|
||||
root.remove("model_provider");
|
||||
// Legacy/alt formats might use a top-level base_url.
|
||||
root.remove("base_url");
|
||||
|
||||
// Remove entire model_providers table (provider-specific configuration)
|
||||
root.remove("model_providers");
|
||||
|
||||
// Clean up multiple empty lines (keep at most one blank line).
|
||||
let mut cleaned = String::new();
|
||||
let mut blank_run = 0usize;
|
||||
for line in doc.to_string().lines() {
|
||||
if line.trim().is_empty() {
|
||||
blank_run += 1;
|
||||
if blank_run <= 1 {
|
||||
cleaned.push('\n');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
blank_run = 0;
|
||||
cleaned.push_str(line);
|
||||
cleaned.push('\n');
|
||||
}
|
||||
|
||||
Ok(cleaned.trim().to_string())
|
||||
}
|
||||
|
||||
/// Extract common config for Gemini (ENV format)
|
||||
///
|
||||
/// Extracts `.env` values while excluding provider-specific credentials:
|
||||
/// - GOOGLE_GEMINI_BASE_URL
|
||||
/// - GEMINI_API_KEY
|
||||
///
|
||||
/// Returns ENV format (KEY=VALUE per line) instead of JSON.
|
||||
/// Values containing newlines/carriage returns are skipped to prevent
|
||||
/// ENV format injection/truncation.
|
||||
fn extract_gemini_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
let env = settings.get("env").and_then(|v| v.as_object());
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
if let Some(env) = env {
|
||||
for (key, value) in env {
|
||||
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
|
||||
continue;
|
||||
}
|
||||
let Value::String(v) = value else {
|
||||
continue;
|
||||
};
|
||||
let trimmed = v.trim();
|
||||
if !trimmed.is_empty() {
|
||||
// Skip values containing newlines to prevent ENV format injection
|
||||
if trimmed.contains('\n') || trimmed.contains('\r') {
|
||||
continue;
|
||||
}
|
||||
lines.push(format!("{key}={trimmed}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
// Sort for consistent output
|
||||
lines.sort();
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
/// Extract common config for OpenCode (JSON format)
|
||||
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
// OpenCode uses a different config structure with npm, options, 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() {
|
||||
if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) {
|
||||
options.remove("apiKey");
|
||||
options.remove("baseURL");
|
||||
}
|
||||
// Keep npm 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.
|
||||
@@ -587,11 +808,6 @@ impl ProviderService {
|
||||
read_live_settings(app_type)
|
||||
}
|
||||
|
||||
/// Patch Claude live settings directly (user-level preferences)
|
||||
pub fn patch_claude_live(patch: Value) -> Result<(), AppError> {
|
||||
live::patch_claude_live(patch)
|
||||
}
|
||||
|
||||
/// Get custom endpoints list (re-export)
|
||||
pub fn get_custom_endpoints(
|
||||
state: &AppState,
|
||||
@@ -687,6 +903,10 @@ impl ProviderService {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
||||
write_gemini_live(provider)
|
||||
}
|
||||
|
||||
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
@@ -753,17 +973,6 @@ impl ProviderService {
|
||||
));
|
||||
}
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw uses config structure: { baseUrl, apiKey, api, models }
|
||||
// Basic validation - must be an object
|
||||
if !provider.settings_config.is_object() {
|
||||
return Err(AppError::localized(
|
||||
"provider.openclaw.settings.not_object",
|
||||
"OpenClaw 配置必须是 JSON 对象",
|
||||
"OpenClaw configuration must be a JSON object",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and clean UsageScript configuration (common for all app types)
|
||||
@@ -935,30 +1144,6 @@ impl ProviderService {
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw uses apiKey and baseUrl directly on the object
|
||||
let api_key = provider
|
||||
.settings_config
|
||||
.get("apiKey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.openclaw.api_key.missing",
|
||||
"缺少 API Key",
|
||||
"API key is missing",
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let base_url = provider
|
||||
.settings_config
|
||||
.get("baseUrl")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
|
||||
use crate::config_merge::merge_config_for_live;
|
||||
use crate::database::Database;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::server::ProxyServer;
|
||||
use crate::proxy::types::*;
|
||||
use crate::services::provider::write_live_partial;
|
||||
use crate::services::provider::write_live_snapshot;
|
||||
use serde_json::{json, Value};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
@@ -210,16 +211,11 @@ impl ProxyService {
|
||||
.await
|
||||
.map(|c| c.enabled)
|
||||
.unwrap_or(false);
|
||||
// OpenCode and OpenClaw don't support proxy features, always return false
|
||||
let opencode_enabled = false;
|
||||
let openclaw_enabled = false;
|
||||
|
||||
Ok(ProxyTakeoverStatus {
|
||||
claude: claude_enabled,
|
||||
codex: codex_enabled,
|
||||
gemini: gemini_enabled,
|
||||
opencode: opencode_enabled,
|
||||
openclaw: openclaw_enabled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -377,10 +373,6 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy features
|
||||
return Err("OpenCode 不支持代理功能".to_string());
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features
|
||||
return Err("OpenClaw 不支持代理功能".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
self.sync_live_config_to_provider(app_type, &live_config)
|
||||
@@ -597,9 +589,6 @@ impl ProxyService {
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features, skip silently
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features, skip silently
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -782,10 +771,6 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy features
|
||||
return Err("OpenCode 不支持代理功能".to_string());
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features
|
||||
return Err("OpenClaw 不支持代理功能".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let json_str = serde_json::to_string(&config)
|
||||
@@ -998,10 +983,6 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy features
|
||||
return Err("OpenCode 不支持代理功能".to_string());
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features
|
||||
return Err("OpenClaw 不支持代理功能".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1088,9 +1069,6 @@ impl ProxyService {
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features, skip silently
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features, skip silently
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1126,9 +1104,6 @@ impl ProxyService {
|
||||
AppType::OpenCode => {
|
||||
// OpenCode doesn't support proxy features, skip silently
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features, skip silently
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1212,10 +1187,6 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy features
|
||||
Err("OpenCode 不支持代理功能".to_string())
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features
|
||||
Err("OpenClaw 不支持代理功能".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1237,10 +1208,6 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy takeover
|
||||
false
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy takeover
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1266,7 +1233,27 @@ impl ProxyService {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
write_live_partial(app_type, provider)
|
||||
// Use shared merge function (single source of truth)
|
||||
let common_config_snippet = self.db.get_config_snippet(app_type.as_str()).ok().flatten();
|
||||
let merge_result =
|
||||
merge_config_for_live(app_type, provider, common_config_snippet.as_deref());
|
||||
|
||||
// Log warning if any
|
||||
if let Some(warning) = &merge_result.warning {
|
||||
log::warn!(
|
||||
"Common config merge warning for {:?} provider '{}': {}",
|
||||
app_type,
|
||||
provider.id,
|
||||
warning
|
||||
);
|
||||
}
|
||||
|
||||
// Write merged config to live file
|
||||
let merged_provider = Provider {
|
||||
settings_config: merge_result.config,
|
||||
..provider.clone()
|
||||
};
|
||||
write_live_snapshot(app_type, &merged_provider)
|
||||
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
|
||||
|
||||
Ok(true)
|
||||
@@ -1284,10 +1271,6 @@ impl ProxyService {
|
||||
// OpenCode doesn't support proxy features
|
||||
Ok(())
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support proxy features
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1523,26 +1506,50 @@ impl ProxyService {
|
||||
///
|
||||
/// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,
|
||||
/// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。
|
||||
///
|
||||
/// **重要**: 新架构下 settings_config 存储的是自定义配置(custom diff),
|
||||
/// 备份时需要先与 common config 合并,生成完整的 finalConfig。
|
||||
pub async fn update_live_backup_from_provider(
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider: &Provider,
|
||||
) -> Result<(), String> {
|
||||
let app_type_enum =
|
||||
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
|
||||
|
||||
// Get common config snippet for merge
|
||||
let common_snippet = self.db.get_config_snippet(app_type).ok().flatten();
|
||||
|
||||
// Merge custom config with common config to get final config
|
||||
let merge_result =
|
||||
merge_config_for_live(&app_type_enum, provider, common_snippet.as_deref());
|
||||
|
||||
// Log warning if any
|
||||
if let Some(warning) = &merge_result.warning {
|
||||
log::warn!(
|
||||
"Common config merge warning for {} provider '{}': {}",
|
||||
app_type,
|
||||
provider.id,
|
||||
warning
|
||||
);
|
||||
}
|
||||
|
||||
let final_config = merge_result.config;
|
||||
|
||||
let backup_json = match app_type {
|
||||
"claude" => {
|
||||
// Claude: settings_config 直接作为备份
|
||||
serde_json::to_string(&provider.settings_config)
|
||||
// Claude: 使用合并后的 final config 作为备份
|
||||
serde_json::to_string(&final_config)
|
||||
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?
|
||||
}
|
||||
"codex" => {
|
||||
// Codex: settings_config 包含 {"auth": ..., "config": ...},直接使用
|
||||
serde_json::to_string(&provider.settings_config)
|
||||
// Codex: 使用合并后的 final config
|
||||
serde_json::to_string(&final_config)
|
||||
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?
|
||||
}
|
||||
"gemini" => {
|
||||
// Gemini: 只提取 env 字段(与原始备份格式一致)
|
||||
// proxy.rs 的 read_gemini_live() 返回 {"env": {...}}
|
||||
let env_backup = if let Some(env) = provider.settings_config.get("env") {
|
||||
let env_backup = if let Some(env) = final_config.get("env") {
|
||||
json!({ "env": env })
|
||||
} else {
|
||||
json!({ "env": {} })
|
||||
@@ -1558,7 +1565,7 @@ impl ProxyService {
|
||||
.await
|
||||
.map_err(|e| format!("更新 {app_type} 备份失败: {e}"))?;
|
||||
|
||||
log::info!("已更新 {app_type} Live 备份(热切换)");
|
||||
log::info!("已更新 {app_type} Live 备份(热切换,含 common config 合并)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+157
-549
@@ -10,7 +10,7 @@ use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::time::timeout;
|
||||
|
||||
@@ -159,154 +159,6 @@ pub struct SkillMetadata {
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
// ========== ~/.agents/ lock 文件解析 ==========
|
||||
|
||||
/// `~/.agents/.skill-lock.json` 文件结构
|
||||
#[derive(Deserialize)]
|
||||
struct AgentsLockFile {
|
||||
skills: HashMap<String, AgentsLockSkill>,
|
||||
}
|
||||
|
||||
/// lock 文件中单个 skill 的信息
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AgentsLockSkill {
|
||||
source: Option<String>,
|
||||
source_type: Option<String>,
|
||||
source_url: Option<String>,
|
||||
skill_path: Option<String>,
|
||||
branch: Option<String>,
|
||||
source_branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct LockRepoInfo {
|
||||
owner: String,
|
||||
repo: String,
|
||||
skill_path: Option<String>,
|
||||
branch: Option<String>,
|
||||
}
|
||||
|
||||
fn normalize_optional_branch(branch: Option<String>) -> Option<String> {
|
||||
branch.and_then(|b| {
|
||||
let trimmed = b.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_branch_from_source_url(source_url: Option<&str>) -> Option<String> {
|
||||
let source_url = source_url?;
|
||||
let source_url = source_url.trim();
|
||||
if source_url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 支持 https://github.com/owner/repo/tree/<branch>/...
|
||||
if let Some((_, after_tree)) = source_url.split_once("/tree/") {
|
||||
let branch = after_tree
|
||||
.split('/')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())?;
|
||||
return Some(branch.to_string());
|
||||
}
|
||||
|
||||
// 支持 URL fragment: ...git#branch
|
||||
if let Some((_, fragment)) = source_url.split_once('#') {
|
||||
let branch = fragment
|
||||
.split('&')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())?;
|
||||
return Some(branch.to_string());
|
||||
}
|
||||
|
||||
// 支持 query: ...?branch=xxx / ?ref=xxx
|
||||
if let Some((_, query)) = source_url.split_once('?') {
|
||||
for pair in query.split('&') {
|
||||
let Some((key, value)) = pair.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
if matches!(key, "branch" | "ref") {
|
||||
let branch = value.trim();
|
||||
if !branch.is_empty() {
|
||||
return Some(branch.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// 获取 `~/.agents/skills/` 目录(存在时返回)
|
||||
fn get_agents_skills_dir() -> Option<PathBuf> {
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".agents").join("skills"))
|
||||
.filter(|p| p.exists())
|
||||
}
|
||||
|
||||
/// 解析 `~/.agents/.skill-lock.json`,返回 skill_name -> 仓库信息
|
||||
fn parse_agents_lock() -> HashMap<String, LockRepoInfo> {
|
||||
let path = match dirs::home_dir() {
|
||||
Some(h) => h.join(".agents").join(".skill-lock.json"),
|
||||
None => {
|
||||
log::warn!("无法获取 HOME 目录,跳过解析 agents lock 文件");
|
||||
return HashMap::new();
|
||||
}
|
||||
};
|
||||
let content = match fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
log::debug!("未找到 agents lock 文件: {}", path.display());
|
||||
} else {
|
||||
log::warn!("读取 agents lock 文件失败 ({}): {}", path.display(), e);
|
||||
}
|
||||
return HashMap::new();
|
||||
}
|
||||
};
|
||||
let lock: AgentsLockFile = match serde_json::from_str(&content) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
log::warn!("解析 agents lock 文件失败 ({}): {}", path.display(), e);
|
||||
return HashMap::new();
|
||||
}
|
||||
};
|
||||
let parsed: HashMap<String, LockRepoInfo> = lock
|
||||
.skills
|
||||
.into_iter()
|
||||
.filter_map(|(name, skill)| {
|
||||
let source = skill.source?;
|
||||
if skill.source_type.as_deref() != Some("github") {
|
||||
return None;
|
||||
}
|
||||
let (owner, repo) = source.split_once('/')?;
|
||||
let branch = normalize_optional_branch(skill.branch)
|
||||
.or_else(|| normalize_optional_branch(skill.source_branch))
|
||||
.or_else(|| parse_branch_from_source_url(skill.source_url.as_deref()));
|
||||
Some((
|
||||
name,
|
||||
LockRepoInfo {
|
||||
owner: owner.to_string(),
|
||||
repo: repo.to_string(),
|
||||
skill_path: skill.skill_path,
|
||||
branch,
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
log::info!(
|
||||
"agents lock 文件解析完成,共识别 {} 个 github skill",
|
||||
parsed.len()
|
||||
);
|
||||
parsed
|
||||
}
|
||||
|
||||
// ========== SkillService ==========
|
||||
|
||||
pub struct SkillService;
|
||||
@@ -378,11 +230,6 @@ impl SkillService {
|
||||
return Ok(custom.join("skills"));
|
||||
}
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
if let Some(custom) = crate::settings::get_openclaw_override_dir() {
|
||||
return Ok(custom.join("skills"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 默认路径:回退到用户主目录下的标准位置
|
||||
@@ -397,7 +244,6 @@ impl SkillService {
|
||||
AppType::Codex => home.join(".codex").join("skills"),
|
||||
AppType::Gemini => home.join(".gemini").join("skills"),
|
||||
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
|
||||
AppType::OpenClaw => home.join(".openclaw").join("skills"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -423,25 +269,11 @@ impl SkillService {
|
||||
) -> Result<InstalledSkill> {
|
||||
let ssot_dir = Self::get_ssot_dir()?;
|
||||
|
||||
// 允许多级目录(如 a/b/c),但必须是安全的相对路径。
|
||||
let source_rel = Self::sanitize_skill_source_path(&skill.directory).ok_or_else(|| {
|
||||
anyhow!(format_skill_error(
|
||||
"INVALID_SKILL_DIRECTORY",
|
||||
&[("directory", &skill.directory)],
|
||||
Some("checkZipContent"),
|
||||
))
|
||||
})?;
|
||||
// 安装目录名始终使用最后一段,避免在 SSOT 中创建多级目录。
|
||||
let install_name = source_rel
|
||||
// 使用目录最后一段作为安装名
|
||||
let install_name = Path::new(&skill.directory)
|
||||
.file_name()
|
||||
.and_then(|name| Self::sanitize_install_name(&name.to_string_lossy()))
|
||||
.ok_or_else(|| {
|
||||
anyhow!(format_skill_error(
|
||||
"INVALID_SKILL_DIRECTORY",
|
||||
&[("directory", &skill.directory)],
|
||||
Some("checkZipContent"),
|
||||
))
|
||||
})?;
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| skill.directory.clone());
|
||||
|
||||
// 检查数据库中是否已有同名 directory 的 skill(来自其他仓库)
|
||||
let existing_skills = db.get_all_installed_skills()?;
|
||||
@@ -520,7 +352,7 @@ impl SkillService {
|
||||
repo_branch = used_branch;
|
||||
|
||||
// 复制到 SSOT
|
||||
let source = temp_dir.join(&source_rel);
|
||||
let source = temp_dir.join(&skill.directory);
|
||||
if !source.exists() {
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
return Err(anyhow!(format_skill_error(
|
||||
@@ -530,24 +362,7 @@ impl SkillService {
|
||||
)));
|
||||
}
|
||||
|
||||
let canonical_temp = temp_dir.canonicalize().unwrap_or_else(|_| temp_dir.clone());
|
||||
let canonical_source = source.canonicalize().map_err(|_| {
|
||||
anyhow!(format_skill_error(
|
||||
"SKILL_DIR_NOT_FOUND",
|
||||
&[("path", &source.display().to_string())],
|
||||
Some("checkRepoUrl"),
|
||||
))
|
||||
})?;
|
||||
if !canonical_source.starts_with(&canonical_temp) || !canonical_source.is_dir() {
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
return Err(anyhow!(format_skill_error(
|
||||
"INVALID_SKILL_DIRECTORY",
|
||||
&[("directory", &skill.directory)],
|
||||
Some("checkZipContent"),
|
||||
)));
|
||||
}
|
||||
|
||||
Self::copy_dir_recursive(&canonical_source, &dest)?;
|
||||
Self::copy_dir_recursive(&source, &dest)?;
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
// 使用实际下载成功的分支,避免 readme_url / repo_branch 与真实分支不一致。
|
||||
@@ -628,7 +443,12 @@ impl SkillService {
|
||||
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
|
||||
|
||||
// 从所有应用目录删除
|
||||
for app in AppType::all() {
|
||||
for app in [
|
||||
AppType::Claude,
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::OpenCode,
|
||||
] {
|
||||
let _ = Self::remove_from_app(&skill.directory, &app);
|
||||
}
|
||||
|
||||
@@ -685,49 +505,73 @@ impl SkillService {
|
||||
.map(|s| s.directory.clone())
|
||||
.collect();
|
||||
|
||||
// 收集所有待扫描的目录及其来源标签
|
||||
let mut scan_sources: Vec<(PathBuf, String)> = Vec::new();
|
||||
for app in AppType::all() {
|
||||
if let Ok(d) = Self::get_app_skills_dir(&app) {
|
||||
scan_sources.push((d, app.as_str().to_string()));
|
||||
}
|
||||
}
|
||||
if let Some(agents_dir) = get_agents_skills_dir() {
|
||||
scan_sources.push((agents_dir, "agents".to_string()));
|
||||
}
|
||||
if let Ok(ssot_dir) = Self::get_ssot_dir() {
|
||||
scan_sources.push((ssot_dir, "cc-switch".to_string()));
|
||||
}
|
||||
|
||||
let mut unmanaged: HashMap<String, UnmanagedSkill> = HashMap::new();
|
||||
|
||||
for (scan_dir, label) in &scan_sources {
|
||||
let entries = match fs::read_dir(scan_dir) {
|
||||
Ok(e) => e,
|
||||
for app in [
|
||||
AppType::Claude,
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::OpenCode,
|
||||
] {
|
||||
let app_dir = match Self::get_app_skills_dir(&app) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
|
||||
if !app_dir.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(&app_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let dir_name = entry.file_name().to_string_lossy().to_string();
|
||||
if dir_name.starts_with('.') || managed_dirs.contains(&dir_name) {
|
||||
|
||||
// 跳过隐藏目录(以 . 开头,如 .system)
|
||||
if dir_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过已管理的
|
||||
if managed_dirs.contains(&dir_name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否有 SKILL.md
|
||||
let skill_md = path.join("SKILL.md");
|
||||
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
|
||||
let (name, description) = if skill_md.exists() {
|
||||
match Self::parse_skill_metadata_static(&skill_md) {
|
||||
Ok(meta) => (
|
||||
meta.name.unwrap_or_else(|| dir_name.clone()),
|
||||
meta.description,
|
||||
),
|
||||
Err(_) => (dir_name.clone(), None),
|
||||
}
|
||||
} else {
|
||||
(dir_name.clone(), None)
|
||||
};
|
||||
|
||||
// 添加或更新
|
||||
let app_str = match app {
|
||||
AppType::Claude => "claude",
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini",
|
||||
AppType::OpenCode => "opencode",
|
||||
};
|
||||
|
||||
unmanaged
|
||||
.entry(dir_name.clone())
|
||||
.and_modify(|s| s.found_in.push(label.clone()))
|
||||
.and_modify(|s| s.found_in.push(app_str.to_string()))
|
||||
.or_insert(UnmanagedSkill {
|
||||
directory: dir_name,
|
||||
name,
|
||||
description,
|
||||
found_in: vec![label.clone()],
|
||||
path: path.display().to_string(),
|
||||
found_in: vec![app_str.to_string()],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -743,36 +587,33 @@ impl SkillService {
|
||||
directories: Vec<String>,
|
||||
) -> Result<Vec<InstalledSkill>> {
|
||||
let ssot_dir = Self::get_ssot_dir()?;
|
||||
let agents_lock = parse_agents_lock();
|
||||
let mut imported = Vec::new();
|
||||
|
||||
// 将 lock 文件中发现的仓库保存到 skill_repos
|
||||
save_repos_from_lock(db, &agents_lock, directories.iter().map(|s| s.as_str()));
|
||||
|
||||
// 收集所有候选搜索目录
|
||||
let mut search_sources: Vec<(PathBuf, String)> = Vec::new();
|
||||
for app in AppType::all() {
|
||||
if let Ok(d) = Self::get_app_skills_dir(&app) {
|
||||
search_sources.push((d, app.as_str().to_string()));
|
||||
}
|
||||
}
|
||||
if let Some(agents_dir) = get_agents_skills_dir() {
|
||||
search_sources.push((agents_dir, "agents".to_string()));
|
||||
}
|
||||
search_sources.push((ssot_dir.clone(), "cc-switch".to_string()));
|
||||
|
||||
for dir_name in directories {
|
||||
// 在所有候选目录中查找
|
||||
// 找到源目录(从任一应用目录复制)
|
||||
let mut source_path: Option<PathBuf> = None;
|
||||
let mut found_in: Vec<String> = Vec::new();
|
||||
|
||||
for (base, label) in &search_sources {
|
||||
let skill_path = base.join(&dir_name);
|
||||
if skill_path.exists() {
|
||||
if source_path.is_none() {
|
||||
source_path = Some(skill_path);
|
||||
for app in [
|
||||
AppType::Claude,
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::OpenCode,
|
||||
] {
|
||||
if let Ok(app_dir) = Self::get_app_skills_dir(&app) {
|
||||
let skill_path = app_dir.join(&dir_name);
|
||||
if skill_path.exists() {
|
||||
if source_path.is_none() {
|
||||
source_path = Some(skill_path);
|
||||
}
|
||||
let app_str = match app {
|
||||
AppType::Claude => "claude",
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini",
|
||||
AppType::OpenCode => "opencode",
|
||||
};
|
||||
found_in.push(app_str.to_string());
|
||||
}
|
||||
found_in.push(label.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -789,25 +630,40 @@ impl SkillService {
|
||||
|
||||
// 解析元数据
|
||||
let skill_md = dest.join("SKILL.md");
|
||||
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
|
||||
let (name, description) = if skill_md.exists() {
|
||||
match Self::parse_skill_metadata_static(&skill_md) {
|
||||
Ok(meta) => (
|
||||
meta.name.unwrap_or_else(|| dir_name.clone()),
|
||||
meta.description,
|
||||
),
|
||||
Err(_) => (dir_name.clone(), None),
|
||||
}
|
||||
} else {
|
||||
(dir_name.clone(), None)
|
||||
};
|
||||
|
||||
// 构建启用状态
|
||||
let apps = SkillApps::from_labels(&found_in);
|
||||
|
||||
// 从 lock 文件提取仓库信息
|
||||
let (id, repo_owner, repo_name, repo_branch, readme_url) =
|
||||
build_repo_info_from_lock(&agents_lock, &dir_name);
|
||||
let mut apps = SkillApps::default();
|
||||
for app_str in &found_in {
|
||||
match app_str.as_str() {
|
||||
"claude" => apps.claude = true,
|
||||
"codex" => apps.codex = true,
|
||||
"gemini" => apps.gemini = true,
|
||||
"opencode" => apps.opencode = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建记录
|
||||
let skill = InstalledSkill {
|
||||
id,
|
||||
id: format!("local:{dir_name}"),
|
||||
name,
|
||||
description,
|
||||
directory: dir_name,
|
||||
repo_owner,
|
||||
repo_name,
|
||||
repo_branch,
|
||||
readme_url,
|
||||
repo_owner: None,
|
||||
repo_name: None,
|
||||
repo_branch: None,
|
||||
readme_url: None,
|
||||
apps,
|
||||
installed_at: chrono::Utc::now().timestamp(),
|
||||
};
|
||||
@@ -1191,79 +1047,6 @@ impl SkillService {
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
/// 从 SKILL.md 读取名称和描述,不存在则用目录名兜底
|
||||
fn read_skill_name_desc(skill_md: &Path, fallback_name: &str) -> (String, Option<String>) {
|
||||
if skill_md.exists() {
|
||||
match Self::parse_skill_metadata_static(skill_md) {
|
||||
Ok(meta) => (
|
||||
meta.name.unwrap_or_else(|| fallback_name.to_string()),
|
||||
meta.description,
|
||||
),
|
||||
Err(_) => (fallback_name.to_string(), None),
|
||||
}
|
||||
} else {
|
||||
(fallback_name.to_string(), None)
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验并规范化技能源路径(允许多级目录),拒绝路径穿越和绝对路径
|
||||
fn sanitize_skill_source_path(raw: &str) -> Option<PathBuf> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut normalized = PathBuf::new();
|
||||
let mut has_component = false;
|
||||
|
||||
for component in Path::new(trimmed).components() {
|
||||
match component {
|
||||
Component::Normal(name) => {
|
||||
let segment = name.to_string_lossy().trim().to_string();
|
||||
if segment.is_empty() || segment == "." || segment == ".." {
|
||||
return None;
|
||||
}
|
||||
normalized.push(segment);
|
||||
has_component = true;
|
||||
}
|
||||
Component::CurDir
|
||||
| Component::ParentDir
|
||||
| Component::RootDir
|
||||
| Component::Prefix(_) => {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
has_component.then_some(normalized)
|
||||
}
|
||||
|
||||
/// 校验并规范化安装目录名(最终落盘目录名,仅单段)
|
||||
fn sanitize_install_name(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let path = Path::new(trimmed);
|
||||
let mut components = path.components();
|
||||
match (components.next(), components.next()) {
|
||||
(Some(Component::Normal(name)), None) => {
|
||||
let normalized = name.to_string_lossy().trim().to_string();
|
||||
if normalized.is_empty()
|
||||
|| normalized == "."
|
||||
|| normalized == ".."
|
||||
|| normalized.starts_with('.')
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(normalized)
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 去重技能列表(基于完整 key,不同仓库的同名 skill 分开显示)
|
||||
fn deduplicate_discoverable_skills(skills: &mut Vec<DiscoverableSkill>) {
|
||||
let mut seen = HashMap::new();
|
||||
@@ -1287,7 +1070,7 @@ impl SkillService {
|
||||
let _ = temp_dir.keep();
|
||||
|
||||
let mut branches = Vec::new();
|
||||
if !repo.branch.is_empty() && !repo.branch.eq_ignore_ascii_case("HEAD") {
|
||||
if !repo.branch.is_empty() {
|
||||
branches.push(repo.branch.as_str());
|
||||
}
|
||||
if !branches.contains(&"main") {
|
||||
@@ -1352,12 +1135,9 @@ impl SkillService {
|
||||
)));
|
||||
};
|
||||
|
||||
// 第一遍:解压普通文件和目录,收集 symlink 条目
|
||||
let mut symlinks: Vec<(PathBuf, String)> = Vec::new();
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i)?;
|
||||
let file_path = file.name().to_string();
|
||||
let file_path = file.name();
|
||||
|
||||
let relative_path =
|
||||
if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) {
|
||||
@@ -1372,12 +1152,7 @@ impl SkillService {
|
||||
|
||||
let outpath = dest.join(relative_path);
|
||||
|
||||
if file.is_symlink() {
|
||||
// 读取 symlink 目标路径
|
||||
let mut target = String::new();
|
||||
std::io::Read::read_to_string(&mut file, &mut target)?;
|
||||
symlinks.push((outpath, target.trim().to_string()));
|
||||
} else if file.is_dir() {
|
||||
if file.is_dir() {
|
||||
fs::create_dir_all(&outpath)?;
|
||||
} else {
|
||||
if let Some(parent) = outpath.parent() {
|
||||
@@ -1388,9 +1163,6 @@ impl SkillService {
|
||||
}
|
||||
}
|
||||
|
||||
// 第二遍:解析 symlink,将目标内容复制到 symlink 位置
|
||||
Self::resolve_symlinks_in_dir(dest, &symlinks)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1413,58 +1185,6 @@ impl SkillService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 解析 ZIP 中的符号链接:将目标内容复制到 symlink 位置
|
||||
///
|
||||
/// GitHub ZIP 归档保留了 symlink 元数据,解压时可通过 `is_symlink()` 检测。
|
||||
/// 此方法将 symlink 解析为实际文件/目录内容(而非创建真实 symlink),
|
||||
/// 以确保跨平台兼容且 skill 内容自包含。
|
||||
fn resolve_symlinks_in_dir(base_dir: &Path, symlinks: &[(PathBuf, String)]) -> Result<()> {
|
||||
// 规范化 base_dir(macOS 上 /tmp → /private/tmp,需保持一致)
|
||||
let canonical_base = base_dir
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| base_dir.to_path_buf());
|
||||
|
||||
for (link_path, target) in symlinks {
|
||||
// 计算 symlink 的父目录,然后拼接目标的相对路径
|
||||
let parent = link_path.parent().unwrap_or(base_dir);
|
||||
let resolved = parent.join(target);
|
||||
|
||||
// 规范化路径(解析 .. 等)
|
||||
let resolved = match resolved.canonicalize() {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
log::warn!(
|
||||
"Symlink 目标不存在,跳过: {} -> {}",
|
||||
link_path.display(),
|
||||
target
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 安全检查:确保目标在 base_dir 内(防止路径穿越)
|
||||
if !resolved.starts_with(&canonical_base) {
|
||||
log::warn!(
|
||||
"Symlink 目标超出仓库范围,跳过: {} -> {}",
|
||||
link_path.display(),
|
||||
resolved.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 复制目标内容到 symlink 位置
|
||||
if resolved.is_dir() {
|
||||
Self::copy_dir_recursive(&resolved, link_path)?;
|
||||
} else if resolved.is_file() {
|
||||
if let Some(parent) = link_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::copy(&resolved, link_path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========== 从 ZIP 文件安装 ==========
|
||||
|
||||
/// 从本地 ZIP 文件安装 Skills
|
||||
@@ -1497,56 +1217,13 @@ impl SkillService {
|
||||
let ssot_dir = Self::get_ssot_dir()?;
|
||||
let mut installed = Vec::new();
|
||||
let existing_skills = db.get_all_installed_skills()?;
|
||||
let zip_stem = zip_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
for skill_dir in skill_dirs {
|
||||
// 解析元数据(提前解析,用于确定安装名)
|
||||
let skill_md = skill_dir.join("SKILL.md");
|
||||
let meta = if skill_md.exists() {
|
||||
Self::parse_skill_metadata_static(&skill_md).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 获取目录名称作为安装名
|
||||
// 当 SKILL.md 在 ZIP 根目录时,skill_dir == temp_dir,
|
||||
// file_name() 会返回临时目录名(如 .tmpDZKGpF),需要回退到其他来源
|
||||
let install_name = {
|
||||
let dir_name = skill_dir
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
if skill_dir == temp_dir || dir_name.is_empty() || dir_name.starts_with('.') {
|
||||
// SKILL.md 在根目录:优先用元数据 name,否则用 ZIP 文件名
|
||||
meta.as_ref()
|
||||
.and_then(|m| m.name.as_deref())
|
||||
.and_then(Self::sanitize_install_name)
|
||||
.or_else(|| zip_stem.as_deref().and_then(Self::sanitize_install_name))
|
||||
} else {
|
||||
Self::sanitize_install_name(&dir_name)
|
||||
.or_else(|| {
|
||||
meta.as_ref()
|
||||
.and_then(|m| m.name.as_deref())
|
||||
.and_then(Self::sanitize_install_name)
|
||||
})
|
||||
.or_else(|| zip_stem.as_deref().and_then(Self::sanitize_install_name))
|
||||
}
|
||||
};
|
||||
let install_name = match install_name {
|
||||
Some(name) => name,
|
||||
None => {
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
return Err(anyhow!(format_skill_error(
|
||||
"INVALID_SKILL_DIRECTORY",
|
||||
&[("zip", &zip_path.display().to_string())],
|
||||
Some("checkZipContent"),
|
||||
)));
|
||||
}
|
||||
};
|
||||
let install_name = skill_dir
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
// 检查是否已有同名 directory 的 skill
|
||||
let conflict = existing_skills
|
||||
@@ -1562,12 +1239,18 @@ impl SkillService {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (name, description) = match meta {
|
||||
Some(m) => (
|
||||
m.name.unwrap_or_else(|| install_name.clone()),
|
||||
m.description,
|
||||
),
|
||||
None => (install_name.clone(), None),
|
||||
// 解析元数据
|
||||
let skill_md = skill_dir.join("SKILL.md");
|
||||
let (name, description) = if skill_md.exists() {
|
||||
match Self::parse_skill_metadata_static(&skill_md) {
|
||||
Ok(meta) => (
|
||||
meta.name.unwrap_or_else(|| install_name.clone()),
|
||||
meta.description,
|
||||
),
|
||||
Err(_) => (install_name.clone(), None),
|
||||
}
|
||||
} else {
|
||||
(install_name.clone(), None)
|
||||
};
|
||||
|
||||
// 复制到 SSOT
|
||||
@@ -1631,8 +1314,6 @@ impl SkillService {
|
||||
let temp_path = temp_dir.path().to_path_buf();
|
||||
let _ = temp_dir.keep(); // Keep the directory, we'll clean up later
|
||||
|
||||
let mut symlinks: Vec<(PathBuf, String)> = Vec::new();
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i)?;
|
||||
let file_path = match file.enclosed_name() {
|
||||
@@ -1642,11 +1323,7 @@ impl SkillService {
|
||||
|
||||
let outpath = temp_path.join(&file_path);
|
||||
|
||||
if file.is_symlink() {
|
||||
let mut target = String::new();
|
||||
std::io::Read::read_to_string(&mut file, &mut target)?;
|
||||
symlinks.push((outpath, target.trim().to_string()));
|
||||
} else if file.is_dir() {
|
||||
if file.is_dir() {
|
||||
fs::create_dir_all(&outpath)?;
|
||||
} else {
|
||||
if let Some(parent) = outpath.parent() {
|
||||
@@ -1657,9 +1334,6 @@ impl SkillService {
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 symlink
|
||||
Self::resolve_symlinks_in_dir(&temp_path, &symlinks)?;
|
||||
|
||||
Ok(temp_path)
|
||||
}
|
||||
|
||||
@@ -1732,109 +1406,38 @@ impl SkillService {
|
||||
|
||||
// ========== 迁移支持 ==========
|
||||
|
||||
/// 从 lock 文件信息构建 skill 的 ID、仓库字段和 readme URL
|
||||
///
|
||||
/// 返回 (id, repo_owner, repo_name, repo_branch, readme_url)
|
||||
fn build_repo_info_from_lock(
|
||||
lock: &HashMap<String, LockRepoInfo>,
|
||||
dir_name: &str,
|
||||
) -> (
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
) {
|
||||
match lock.get(dir_name) {
|
||||
Some(info) => {
|
||||
let branch = info.branch.clone();
|
||||
let url_branch = branch.clone().unwrap_or_else(|| "HEAD".to_string());
|
||||
// 优先使用 lock 文件中的 skillPath,否则回退到 dir_name/SKILL.md
|
||||
let fallback = format!("{dir_name}/SKILL.md");
|
||||
let doc_path = info.skill_path.as_deref().unwrap_or(&fallback);
|
||||
let url = Some(SkillService::build_skill_doc_url(
|
||||
&info.owner,
|
||||
&info.repo,
|
||||
&url_branch,
|
||||
doc_path,
|
||||
));
|
||||
(
|
||||
format!("{}/{}:{dir_name}", info.owner, info.repo),
|
||||
Some(info.owner.clone()),
|
||||
Some(info.repo.clone()),
|
||||
branch,
|
||||
url,
|
||||
)
|
||||
}
|
||||
None => (format!("local:{dir_name}"), None, None, None, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 lock 文件中发现的仓库保存到 skill_repos(去重)
|
||||
fn save_repos_from_lock(
|
||||
db: &Arc<Database>,
|
||||
lock: &HashMap<String, LockRepoInfo>,
|
||||
directories: impl Iterator<Item = impl AsRef<str>>,
|
||||
) {
|
||||
let existing_repos: HashSet<(String, String)> = db
|
||||
.get_skill_repos()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|r| (r.owner, r.name))
|
||||
.collect();
|
||||
let mut added = HashSet::new();
|
||||
|
||||
for dir_name in directories {
|
||||
if let Some(info) = lock.get(dir_name.as_ref()) {
|
||||
let key = (info.owner.clone(), info.repo.clone());
|
||||
if !existing_repos.contains(&key) && added.insert(key) {
|
||||
let skill_repo = SkillRepo {
|
||||
owner: info.owner.clone(),
|
||||
name: info.repo.clone(),
|
||||
// 未知分支时使用 HEAD 语义,后续下载会回退到 main/master。
|
||||
branch: info.branch.clone().unwrap_or_else(|| "HEAD".to_string()),
|
||||
enabled: true,
|
||||
};
|
||||
if let Err(e) = db.save_skill_repo(&skill_repo) {
|
||||
log::warn!("保存 skill 仓库 {}/{} 失败: {}", info.owner, info.repo, e);
|
||||
} else {
|
||||
log::info!(
|
||||
"从 agents lock 文件发现并添加仓库: {}/{} ({})",
|
||||
info.owner,
|
||||
info.repo,
|
||||
skill_repo.branch
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 首次启动迁移:扫描应用目录,重建数据库
|
||||
pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
||||
let ssot_dir = SkillService::get_ssot_dir()?;
|
||||
let agents_lock = parse_agents_lock();
|
||||
let mut discovered: HashMap<String, SkillApps> = HashMap::new();
|
||||
|
||||
// 扫描各应用目录
|
||||
for app in AppType::all() {
|
||||
for app in [
|
||||
AppType::Claude,
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::OpenCode,
|
||||
] {
|
||||
let app_dir = match SkillService::get_app_skills_dir(&app) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let entries = match fs::read_dir(&app_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !app_dir.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for entry in entries.flatten() {
|
||||
for entry in fs::read_dir(&app_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let dir_name = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
// 跳过隐藏目录(以 . 开头,如 .system)
|
||||
if dir_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
@@ -1845,6 +1448,7 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
||||
SkillService::copy_dir_recursive(&path, &ssot_path)?;
|
||||
}
|
||||
|
||||
// 记录启用状态
|
||||
discovered
|
||||
.entry(dir_name)
|
||||
.or_default()
|
||||
@@ -1855,28 +1459,32 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
||||
// 重建数据库
|
||||
db.clear_skills()?;
|
||||
|
||||
// 将 lock 文件中发现的仓库保存到 skill_repos
|
||||
save_repos_from_lock(db, &agents_lock, discovered.keys());
|
||||
|
||||
let mut count = 0;
|
||||
for (directory, apps) in discovered {
|
||||
let ssot_path = ssot_dir.join(&directory);
|
||||
let skill_md = ssot_path.join("SKILL.md");
|
||||
|
||||
let (name, description) = SkillService::read_skill_name_desc(&skill_md, &directory);
|
||||
|
||||
let (id, repo_owner, repo_name, repo_branch, readme_url) =
|
||||
build_repo_info_from_lock(&agents_lock, &directory);
|
||||
let (name, description) = if skill_md.exists() {
|
||||
match SkillService::parse_skill_metadata_static(&skill_md) {
|
||||
Ok(meta) => (
|
||||
meta.name.unwrap_or_else(|| directory.clone()),
|
||||
meta.description,
|
||||
),
|
||||
Err(_) => (directory.clone(), None),
|
||||
}
|
||||
} else {
|
||||
(directory.clone(), None)
|
||||
};
|
||||
|
||||
let skill = InstalledSkill {
|
||||
id,
|
||||
id: format!("local:{directory}"),
|
||||
name,
|
||||
description,
|
||||
directory,
|
||||
repo_owner,
|
||||
repo_name,
|
||||
repo_branch,
|
||||
readme_url,
|
||||
repo_owner: None,
|
||||
repo_name: None,
|
||||
repo_branch: None,
|
||||
readme_url: None,
|
||||
apps,
|
||||
installed_at: chrono::Utc::now().timestamp(),
|
||||
};
|
||||
|
||||
@@ -240,14 +240,6 @@ impl StreamCheckService {
|
||||
"OpenCode does not support health check yet",
|
||||
));
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw doesn't support stream check yet
|
||||
return Err(AppError::localized(
|
||||
"openclaw_no_stream_check",
|
||||
"OpenClaw 暂不支持健康检查",
|
||||
"OpenClaw does not support health check yet",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let response_time = start.elapsed().as_millis() as u64;
|
||||
@@ -575,11 +567,6 @@ impl StreamCheckService {
|
||||
// Try to extract first model from the models object
|
||||
Self::extract_opencode_model(provider).unwrap_or_else(|| "gpt-4o".to_string())
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw uses models array in settings_config
|
||||
// Try to extract first model from the models array
|
||||
Self::extract_openclaw_model(provider).unwrap_or_else(|| "gpt-4o".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,21 +580,6 @@ impl StreamCheckService {
|
||||
models.keys().next().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn extract_openclaw_model(provider: &Provider) -> Option<String> {
|
||||
// OpenClaw uses models array: [{ "id": "model-id", "name": "Model Name" }]
|
||||
let models = provider
|
||||
.settings_config
|
||||
.get("models")
|
||||
.and_then(|m| m.as_array())?;
|
||||
|
||||
// Return the first model ID from the models array
|
||||
models
|
||||
.first()
|
||||
.and_then(|m| m.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn extract_env_model(provider: &Provider, key: &str) -> Option<String> {
|
||||
provider
|
||||
.settings_config
|
||||
|
||||
@@ -1,552 +0,0 @@
|
||||
//! WebDAV HTTP transport layer.
|
||||
//!
|
||||
//! Low-level HTTP primitives for WebDAV operations (PUT, GET, HEAD, MKCOL, PROPFIND).
|
||||
//! The sync protocol logic lives in [`super::webdav_sync`].
|
||||
|
||||
use reqwest::{Method, RequestBuilder, StatusCode, Url};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::proxy::http_client;
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,704 +0,0 @@
|
||||
//! WebDAV v2 sync protocol layer.
|
||||
//!
|
||||
//! Implements manifest-based synchronization on top of the HTTP transport
|
||||
//! primitives in [`super::webdav`]. Artifact set: `db.sql` + `skills.zip`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::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());
|
||||
}
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use tempfile::{tempdir, TempDir};
|
||||
use zip::write::SimpleFileOptions;
|
||||
use zip::DateTime;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::services::skill::SkillService;
|
||||
|
||||
use super::{io_context_localized, localized, 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ pub mod terminal;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
|
||||
use providers::{claude, codex, gemini, openclaw, opencode};
|
||||
use providers::{claude, codex};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -40,9 +40,6 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let mut sessions = Vec::new();
|
||||
sessions.extend(codex::scan_sessions());
|
||||
sessions.extend(claude::scan_sessions());
|
||||
sessions.extend(opencode::scan_sessions());
|
||||
sessions.extend(openclaw::scan_sessions());
|
||||
sessions.extend(gemini::scan_sessions());
|
||||
|
||||
sessions.sort_by(|a, b| {
|
||||
let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);
|
||||
@@ -58,9 +55,6 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<Session
|
||||
match provider_id {
|
||||
"codex" => codex::load_messages(path),
|
||||
"claude" => claude::load_messages(path),
|
||||
"opencode" => opencode::load_messages(path),
|
||||
"openclaw" => openclaw::load_messages(path),
|
||||
"gemini" => gemini::load_messages(path),
|
||||
_ => Err(format!("Unsupported provider: {provider_id}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
|
||||
use super::utils::{parse_timestamp_to_ms, truncate_summary};
|
||||
|
||||
const PROVIDER_ID: &str = "gemini";
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let gemini_dir = crate::gemini_config::get_gemini_dir();
|
||||
let tmp_dir = gemini_dir.join("tmp");
|
||||
if !tmp_dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
|
||||
// Iterate over project hash directories: tmp/<project_hash>/chats/session-*.json
|
||||
let project_dirs = match std::fs::read_dir(&tmp_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
for entry in project_dirs.flatten() {
|
||||
let chats_dir = entry.path().join("chats");
|
||||
if !chats_dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let chat_files = match std::fs::read_dir(&chats_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
for file_entry in chat_files.flatten() {
|
||||
let path = file_entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
if let Some(meta) = parse_session(&path) {
|
||||
sessions.push(meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessions
|
||||
}
|
||||
|
||||
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
let data = std::fs::read_to_string(path).map_err(|e| format!("Failed to read session: {e}"))?;
|
||||
let value: Value =
|
||||
serde_json::from_str(&data).map_err(|e| format!("Failed to parse session JSON: {e}"))?;
|
||||
|
||||
let messages = value
|
||||
.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| "No messages array found".to_string())?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for msg in messages {
|
||||
let content = match msg.get("content").and_then(Value::as_str) {
|
||||
Some(c) if !c.trim().is_empty() => c.to_string(),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let role = match msg.get("type").and_then(Value::as_str) {
|
||||
Some("gemini") => "assistant".to_string(),
|
||||
Some("user") => "user".to_string(),
|
||||
Some(other) => other.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let ts = msg.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||
|
||||
result.push(SessionMessage { role, content, ts });
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let data = std::fs::read_to_string(path).ok()?;
|
||||
let value: Value = serde_json::from_str(&data).ok()?;
|
||||
|
||||
let session_id = value.get("sessionId").and_then(Value::as_str)?.to_string();
|
||||
|
||||
let created_at = value.get("startTime").and_then(parse_timestamp_to_ms);
|
||||
let last_active_at = value.get("lastUpdated").and_then(parse_timestamp_to_ms);
|
||||
|
||||
// Derive title from first user message
|
||||
let title = value
|
||||
.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|msgs| {
|
||||
msgs.iter()
|
||||
.find(|m| m.get("type").and_then(Value::as_str) == Some("user"))
|
||||
.and_then(|m| m.get("content").and_then(Value::as_str))
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(|s| truncate_summary(s, 160))
|
||||
});
|
||||
|
||||
let source_path = path.to_string_lossy().to_string();
|
||||
|
||||
Some(SessionMeta {
|
||||
provider_id: PROVIDER_ID.to_string(),
|
||||
session_id: session_id.clone(),
|
||||
title: title.clone(),
|
||||
summary: title,
|
||||
project_dir: None, // project hash is not reversible
|
||||
created_at,
|
||||
last_active_at: last_active_at.or(created_at),
|
||||
source_path: Some(source_path),
|
||||
resume_command: Some(format!("gemini --resume {session_id}")),
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
pub mod claude;
|
||||
pub mod codex;
|
||||
pub mod gemini;
|
||||
pub mod openclaw;
|
||||
pub mod opencode;
|
||||
mod utils;
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::openclaw_config::get_openclaw_dir;
|
||||
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
|
||||
use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary};
|
||||
|
||||
const PROVIDER_ID: &str = "openclaw";
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let agents_dir = get_openclaw_dir().join("agents");
|
||||
if !agents_dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
|
||||
// Traverse each agent directory
|
||||
let agent_entries = match std::fs::read_dir(&agents_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return sessions,
|
||||
};
|
||||
|
||||
for agent_entry in agent_entries.flatten() {
|
||||
let agent_path = agent_entry.path();
|
||||
if !agent_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let sessions_dir = agent_path.join("sessions");
|
||||
if !sessions_dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let session_entries = match std::fs::read_dir(&sessions_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
for entry in session_entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
|
||||
continue;
|
||||
}
|
||||
// Skip sessions.json index file
|
||||
if path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n == "sessions.json")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(meta) = parse_session(&path) {
|
||||
sessions.push(meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessions
|
||||
}
|
||||
|
||||
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
let file = File::open(path).map_err(|e| format!("Failed to open session file: {e}"))?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut messages = Vec::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&line) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if value.get("type").and_then(Value::as_str) != Some("message") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let message = match value.get("message") {
|
||||
Some(msg) => msg,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let raw_role = message
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
|
||||
// Map OpenClaw roles to our standard roles
|
||||
let role = match raw_role {
|
||||
"toolResult" => "tool".to_string(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
|
||||
let content = message.get("content").map(extract_text).unwrap_or_default();
|
||||
if content.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ts = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||
|
||||
messages.push(SessionMessage { role, content, ts });
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let file = File::open(path).ok()?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let mut session_id: Option<String> = None;
|
||||
let mut cwd: Option<String> = None;
|
||||
let mut created_at: Option<i64> = None;
|
||||
let mut last_active_at: Option<i64> = None;
|
||||
let mut summary: Option<String> = None;
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&line) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
|
||||
if created_at.is_none() {
|
||||
created_at = Some(ts);
|
||||
}
|
||||
last_active_at = Some(ts);
|
||||
}
|
||||
|
||||
let event_type = value.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
|
||||
if event_type == "session" {
|
||||
if session_id.is_none() {
|
||||
session_id = value
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
if cwd.is_none() {
|
||||
cwd = value
|
||||
.get("cwd")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
|
||||
created_at.get_or_insert(ts);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if event_type != "message" {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract first message content for summary
|
||||
if summary.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let message = match value.get("message") {
|
||||
Some(msg) => msg,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let text = message.get("content").map(extract_text).unwrap_or_default();
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
summary = Some(text);
|
||||
}
|
||||
|
||||
// Fall back to filename as session ID
|
||||
let session_id = session_id.or_else(|| {
|
||||
path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
let session_id = session_id?;
|
||||
|
||||
let title = cwd
|
||||
.as_deref()
|
||||
.and_then(path_basename)
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let summary = summary.map(|text| truncate_summary(&text, 160));
|
||||
|
||||
Some(SessionMeta {
|
||||
provider_id: PROVIDER_ID.to_string(),
|
||||
session_id: session_id.clone(),
|
||||
title,
|
||||
summary,
|
||||
project_dir: cwd,
|
||||
created_at,
|
||||
last_active_at,
|
||||
source_path: Some(path.to_string_lossy().to_string()),
|
||||
resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume
|
||||
})
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
|
||||
use super::utils::{parse_timestamp_to_ms, path_basename, truncate_summary};
|
||||
|
||||
const PROVIDER_ID: &str = "opencode";
|
||||
|
||||
/// Return the OpenCode data directory.
|
||||
///
|
||||
/// Respects `XDG_DATA_HOME` on all platforms; falls back to
|
||||
/// `~/.local/share/opencode/storage/`.
|
||||
fn get_opencode_data_dir() -> PathBuf {
|
||||
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
||||
if !xdg.is_empty() {
|
||||
return PathBuf::from(xdg).join("opencode").join("storage");
|
||||
}
|
||||
}
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".local/share/opencode/storage"))
|
||||
.unwrap_or_else(|| PathBuf::from(".local/share/opencode/storage"))
|
||||
}
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let storage = get_opencode_data_dir();
|
||||
let session_dir = storage.join("session");
|
||||
if !session_dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut json_files = Vec::new();
|
||||
collect_json_files(&session_dir, &mut json_files);
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
for path in json_files {
|
||||
if let Some(meta) = parse_session(&storage, &path) {
|
||||
sessions.push(meta);
|
||||
}
|
||||
}
|
||||
sessions
|
||||
}
|
||||
|
||||
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
// `path` is the message directory: storage/message/{sessionID}/
|
||||
if !path.is_dir() {
|
||||
return Err(format!("Message directory not found: {}", path.display()));
|
||||
}
|
||||
|
||||
let storage = path
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.ok_or_else(|| "Cannot determine storage root from message path".to_string())?;
|
||||
|
||||
let mut msg_files = Vec::new();
|
||||
collect_json_files(path, &mut msg_files);
|
||||
|
||||
// Parse all messages and collect (created_ts, message_id, role, parts_text)
|
||||
let mut entries: Vec<(i64, String, String, String)> = Vec::new();
|
||||
|
||||
for msg_path in &msg_files {
|
||||
let data = match std::fs::read_to_string(msg_path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&data) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let msg_id = match value.get("id").and_then(Value::as_str) {
|
||||
Some(id) => id.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let role = value
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let created_ts = value
|
||||
.get("time")
|
||||
.and_then(|t| t.get("created"))
|
||||
.and_then(parse_timestamp_to_ms)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Collect text parts from storage/part/{messageID}/
|
||||
let part_dir = storage.join("part").join(&msg_id);
|
||||
let text = collect_parts_text(&part_dir);
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push((created_ts, msg_id, role, text));
|
||||
}
|
||||
|
||||
// Sort by created timestamp
|
||||
entries.sort_by_key(|(ts, _, _, _)| *ts);
|
||||
|
||||
let messages = entries
|
||||
.into_iter()
|
||||
.map(|(ts, _, role, content)| SessionMessage {
|
||||
role,
|
||||
content,
|
||||
ts: if ts > 0 { Some(ts) } else { None },
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
fn parse_session(storage: &Path, path: &Path) -> Option<SessionMeta> {
|
||||
let data = std::fs::read_to_string(path).ok()?;
|
||||
let value: Value = serde_json::from_str(&data).ok()?;
|
||||
|
||||
let session_id = value.get("id").and_then(Value::as_str)?.to_string();
|
||||
let title = value
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
let directory = value
|
||||
.get("directory")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let created_at = value
|
||||
.get("time")
|
||||
.and_then(|t| t.get("created"))
|
||||
.and_then(parse_timestamp_to_ms);
|
||||
let updated_at = value
|
||||
.get("time")
|
||||
.and_then(|t| t.get("updated"))
|
||||
.and_then(parse_timestamp_to_ms);
|
||||
|
||||
// Derive title from directory basename if no explicit title
|
||||
let display_title = title.or_else(|| {
|
||||
directory
|
||||
.as_deref()
|
||||
.and_then(path_basename)
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
// Build source_path = message directory for this session
|
||||
let msg_dir = storage.join("message").join(&session_id);
|
||||
let source_path = msg_dir.to_string_lossy().to_string();
|
||||
|
||||
// Get summary from first user message
|
||||
let summary = get_first_user_summary(storage, &session_id);
|
||||
|
||||
Some(SessionMeta {
|
||||
provider_id: PROVIDER_ID.to_string(),
|
||||
session_id: session_id.clone(),
|
||||
title: display_title,
|
||||
summary,
|
||||
project_dir: directory,
|
||||
created_at,
|
||||
last_active_at: updated_at.or(created_at),
|
||||
source_path: Some(source_path),
|
||||
resume_command: Some(format!("opencode session resume {session_id}")),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the first user message's first text part to use as summary.
|
||||
fn get_first_user_summary(storage: &Path, session_id: &str) -> Option<String> {
|
||||
let msg_dir = storage.join("message").join(session_id);
|
||||
if !msg_dir.is_dir() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut msg_files = Vec::new();
|
||||
collect_json_files(&msg_dir, &mut msg_files);
|
||||
|
||||
// Collect user messages with timestamps for ordering
|
||||
let mut user_msgs: Vec<(i64, String)> = Vec::new();
|
||||
for msg_path in &msg_files {
|
||||
let data = match std::fs::read_to_string(msg_path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&data) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if value.get("role").and_then(Value::as_str) != Some("user") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let msg_id = match value.get("id").and_then(Value::as_str) {
|
||||
Some(id) => id.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let ts = value
|
||||
.get("time")
|
||||
.and_then(|t| t.get("created"))
|
||||
.and_then(parse_timestamp_to_ms)
|
||||
.unwrap_or(0);
|
||||
|
||||
user_msgs.push((ts, msg_id));
|
||||
}
|
||||
|
||||
user_msgs.sort_by_key(|(ts, _)| *ts);
|
||||
|
||||
// Take first user message and get its parts
|
||||
let (_, first_id) = user_msgs.first()?;
|
||||
let part_dir = storage.join("part").join(first_id);
|
||||
let text = collect_parts_text(&part_dir);
|
||||
if text.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(truncate_summary(&text, 160))
|
||||
}
|
||||
|
||||
/// Collect text content from all parts in a part directory.
|
||||
fn collect_parts_text(part_dir: &Path) -> String {
|
||||
if !part_dir.is_dir() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
collect_json_files(part_dir, &mut parts);
|
||||
|
||||
let mut texts = Vec::new();
|
||||
for part_path in &parts {
|
||||
let data = match std::fs::read_to_string(part_path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&data) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// Only include text-type parts
|
||||
if value.get("type").and_then(Value::as_str) != Some("text") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(text) = value.get("text").and_then(Value::as_str) {
|
||||
if !text.trim().is_empty() {
|
||||
texts.push(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
texts.join("\n")
|
||||
}
|
||||
|
||||
fn collect_json_files(root: &Path, files: &mut Vec<PathBuf>) {
|
||||
if !root.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let entries = match std::fs::read_dir(root) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_json_files(&path, files);
|
||||
} else if path.extension().and_then(|ext| ext.to_str()) == Some("json") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-264
@@ -1,6 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
@@ -34,8 +33,6 @@ pub struct VisibleApps {
|
||||
pub gemini: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub opencode: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub openclaw: bool,
|
||||
}
|
||||
|
||||
impl Default for VisibleApps {
|
||||
@@ -45,7 +42,6 @@ impl Default for VisibleApps {
|
||||
codex: true,
|
||||
gemini: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,111 +54,10 @@ impl VisibleApps {
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::OpenClaw => self.openclaw,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// WebDAV 同步状态(持久化同步进度信息)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebDavSyncStatus {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_sync_at: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_error: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_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`,不随数据库同步。
|
||||
@@ -187,15 +82,6 @@ pub struct AppSettings {
|
||||
/// 静默启动(程序启动时不显示主窗口,仅托盘运行)
|
||||
#[serde(default)]
|
||||
pub silent_startup: bool,
|
||||
/// 是否在主页面启用本地代理功能(默认关闭)
|
||||
#[serde(default)]
|
||||
pub enable_local_proxy: bool,
|
||||
/// User has confirmed the local proxy first-run notice
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_confirmed: Option<bool>,
|
||||
/// User has confirmed the usage query first-run notice
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub usage_confirmed: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<String>,
|
||||
|
||||
@@ -212,8 +98,6 @@ pub struct AppSettings {
|
||||
pub gemini_config_dir: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opencode_config_dir: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub openclaw_config_dir: Option<String>,
|
||||
|
||||
// ===== 当前供应商 ID(设备级)=====
|
||||
/// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current)
|
||||
@@ -228,31 +112,12 @@ pub struct AppSettings {
|
||||
/// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_provider_opencode: Option<String>,
|
||||
/// 当前 OpenClaw 供应商 ID(本地存储,对 OpenClaw 可能无意义,但保持结构一致)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_provider_openclaw: Option<String>,
|
||||
|
||||
// ===== Skill 同步设置 =====
|
||||
/// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
|
||||
#[serde(default)]
|
||||
pub skill_sync_method: SyncMethod,
|
||||
|
||||
// ===== WebDAV 同步设置 =====
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub webdav_sync: Option<WebDavSyncSettings>,
|
||||
|
||||
// ===== WebDAV 备份设置(旧版,保留向后兼容)=====
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub webdav_backup: Option<serde_json::Value>,
|
||||
|
||||
// ===== 备份策略设置 =====
|
||||
/// Auto-backup interval in hours (default 24, 0 = disabled)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub backup_interval_hours: Option<u32>,
|
||||
/// Maximum number of backup files to retain (default 10)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub backup_retain_count: Option<u32>,
|
||||
|
||||
// ===== 终端设置 =====
|
||||
/// 首选终端应用(可选,默认使用系统默认终端)
|
||||
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
|
||||
@@ -279,26 +144,17 @@ impl Default for AppSettings {
|
||||
skip_claude_onboarding: false,
|
||||
launch_on_startup: false,
|
||||
silent_startup: false,
|
||||
enable_local_proxy: false,
|
||||
proxy_confirmed: None,
|
||||
usage_confirmed: None,
|
||||
language: None,
|
||||
visible_apps: None,
|
||||
claude_config_dir: None,
|
||||
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,
|
||||
backup_interval_hours: None,
|
||||
backup_retain_count: None,
|
||||
preferred_terminal: None,
|
||||
}
|
||||
}
|
||||
@@ -343,26 +199,12 @@ impl AppSettings {
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
self.openclaw_config_dir = self
|
||||
.openclaw_config_dir
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
self.language = self
|
||||
.language
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| matches!(*s, "en" | "zh" | "ja"))
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if let Some(sync) = &mut self.webdav_sync {
|
||||
sync.normalize();
|
||||
if sync.is_empty() {
|
||||
self.webdav_sync = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_from_file() -> Self {
|
||||
@@ -403,27 +245,7 @@ fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
|
||||
|
||||
let json = serde_json::to_string_pretty(&normalized)
|
||||
.map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::fs::OpenOptions;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&path)
|
||||
.map_err(|e| AppError::io(&path, e))?;
|
||||
file.write_all(json.as_bytes())
|
||||
.map_err(|e| AppError::io(&path, e))?;
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;
|
||||
}
|
||||
|
||||
fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -461,15 +283,6 @@ pub fn get_settings() -> AppSettings {
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn get_settings_for_frontend() -> AppSettings {
|
||||
let mut settings = get_settings();
|
||||
if let Some(sync) = &mut settings.webdav_sync {
|
||||
sync.password.clear();
|
||||
}
|
||||
settings.webdav_backup = None;
|
||||
settings
|
||||
}
|
||||
|
||||
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
|
||||
new_settings.normalize_paths();
|
||||
save_settings_file(&new_settings)?;
|
||||
@@ -482,22 +295,6 @@ pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mutate_settings<F>(mutator: F) -> Result<(), AppError>
|
||||
where
|
||||
F: FnOnce(&mut AppSettings),
|
||||
{
|
||||
let mut guard = settings_store().write().unwrap_or_else(|e| {
|
||||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||||
e.into_inner()
|
||||
});
|
||||
let mut next = guard.clone();
|
||||
mutator(&mut next);
|
||||
next.normalize_paths();
|
||||
save_settings_file(&next)?;
|
||||
*guard = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从文件重新加载设置到内存缓存
|
||||
/// 用于导入配置等场景,确保内存缓存与文件同步
|
||||
pub fn reload_settings() -> Result<(), AppError> {
|
||||
@@ -542,14 +339,6 @@ pub fn get_opencode_override_dir() -> Option<PathBuf> {
|
||||
.map(|p| resolve_override_path(p))
|
||||
}
|
||||
|
||||
pub fn get_openclaw_override_dir() -> Option<PathBuf> {
|
||||
let settings = settings_store().read().ok()?;
|
||||
settings
|
||||
.openclaw_config_dir
|
||||
.as_ref()
|
||||
.map(|p| resolve_override_path(p))
|
||||
}
|
||||
|
||||
// ===== 当前供应商管理函数 =====
|
||||
|
||||
/// 获取指定应用类型的当前供应商 ID(从本地 settings 读取)
|
||||
@@ -563,7 +352,6 @@ pub fn get_current_provider(app_type: &AppType) -> Option<String> {
|
||||
AppType::Codex => settings.current_provider_codex.clone(),
|
||||
AppType::Gemini => settings.current_provider_gemini.clone(),
|
||||
AppType::OpenCode => settings.current_provider_opencode.clone(),
|
||||
AppType::OpenClaw => settings.current_provider_openclaw.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,7 +367,6 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(),
|
||||
AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()),
|
||||
AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()),
|
||||
AppType::OpenCode => settings.current_provider_opencode = id.map(|s| s.to_string()),
|
||||
AppType::OpenClaw => settings.current_provider_openclaw = id.map(|s| s.to_string()),
|
||||
}
|
||||
|
||||
update_settings(settings)
|
||||
@@ -633,33 +420,6 @@ pub fn get_skill_sync_method() -> SyncMethod {
|
||||
.skill_sync_method
|
||||
}
|
||||
|
||||
// ===== 备份策略管理函数 =====
|
||||
|
||||
/// Get the effective auto-backup interval in hours (default 24)
|
||||
pub fn effective_backup_interval_hours() -> u32 {
|
||||
settings_store()
|
||||
.read()
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||||
e.into_inner()
|
||||
})
|
||||
.backup_interval_hours
|
||||
.unwrap_or(24)
|
||||
}
|
||||
|
||||
/// Get the effective backup retain count (default 10, minimum 1)
|
||||
pub fn effective_backup_retain_count() -> usize {
|
||||
settings_store()
|
||||
.read()
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||||
e.into_inner()
|
||||
})
|
||||
.backup_retain_count
|
||||
.map(|n| (n as usize).max(1))
|
||||
.unwrap_or(10)
|
||||
}
|
||||
|
||||
// ===== 终端设置管理函数 =====
|
||||
|
||||
/// 获取首选终端应用
|
||||
@@ -673,26 +433,3 @@ pub fn get_preferred_terminal() -> Option<String> {
|
||||
.preferred_terminal
|
||||
.clone()
|
||||
}
|
||||
|
||||
// ===== WebDAV 同步设置管理函数 =====
|
||||
|
||||
/// 获取 WebDAV 同步设置
|
||||
pub fn get_webdav_sync_settings() -> Option<WebDavSyncSettings> {
|
||||
settings_store().read().ok()?.webdav_sync.clone()
|
||||
}
|
||||
|
||||
/// 保存 WebDAV 同步设置
|
||||
pub fn set_webdav_sync_settings(settings: Option<WebDavSyncSettings>) -> Result<(), AppError> {
|
||||
mutate_settings(|current| {
|
||||
current.webdav_sync = settings;
|
||||
})
|
||||
}
|
||||
|
||||
/// 仅更新 WebDAV 同步状态,避免覆写 credentials/root/profile 等字段
|
||||
pub fn update_webdav_sync_status(status: WebDavSyncStatus) -> Result<(), AppError> {
|
||||
mutate_settings(|current| {
|
||||
if let Some(sync) = current.webdav_sync.as_mut() {
|
||||
sync.status = status;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+23
-9
@@ -15,7 +15,7 @@ pub struct TrayTexts {
|
||||
pub show_main: &'static str,
|
||||
pub no_provider_hint: &'static str,
|
||||
pub quit: &'static str,
|
||||
pub _auto_label: &'static str,
|
||||
pub auto_label: &'static str,
|
||||
}
|
||||
|
||||
impl TrayTexts {
|
||||
@@ -25,20 +25,20 @@ impl TrayTexts {
|
||||
show_main: "Open main window",
|
||||
no_provider_hint: " (No providers yet, please add them from the main window)",
|
||||
quit: "Quit",
|
||||
_auto_label: "Auto (Failover)",
|
||||
auto_label: "Auto (Failover)",
|
||||
},
|
||||
"ja" => Self {
|
||||
show_main: "メインウィンドウを開く",
|
||||
no_provider_hint:
|
||||
" (プロバイダーがまだありません。メイン画面から追加してください)",
|
||||
quit: "終了",
|
||||
_auto_label: "自動 (フェイルオーバー)",
|
||||
auto_label: "自動 (フェイルオーバー)",
|
||||
},
|
||||
_ => Self {
|
||||
show_main: "打开主界面",
|
||||
no_provider_hint: " (无供应商,请在主界面添加)",
|
||||
quit: "退出",
|
||||
_auto_label: "自动 (故障转移)",
|
||||
auto_label: "自动 (故障转移)",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ fn append_provider_section<'a>(
|
||||
manager: Option<&crate::provider::ProviderManager>,
|
||||
section: &TrayAppSection,
|
||||
tray_texts: &TrayTexts,
|
||||
_app_state: &AppState,
|
||||
app_state: &AppState,
|
||||
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
|
||||
let Some(manager) = manager else {
|
||||
return Ok(menu_builder);
|
||||
@@ -119,9 +119,22 @@ fn append_provider_section<'a>(
|
||||
return Ok(menu_builder.item(&empty_hint));
|
||||
}
|
||||
|
||||
// Auto (Failover) menu item is hidden from tray; the feature is still
|
||||
// accessible from the Settings page. Keep the surrounding code intact so
|
||||
// it can be re-enabled easily in the future.
|
||||
// 获取 proxy 状态,决定 Auto 是否选中
|
||||
let (proxy_enabled, auto_failover) =
|
||||
app_state.db.get_proxy_flags_sync(section.app_type.as_str());
|
||||
let auto_mode = proxy_enabled && auto_failover;
|
||||
|
||||
// 添加 Auto 菜单项(始终显示在供应商列表前)
|
||||
let auto_item = CheckMenuItem::with_id(
|
||||
app,
|
||||
format!("{}{}", section.prefix, AUTO_SUFFIX),
|
||||
tray_texts.auto_label,
|
||||
true,
|
||||
auto_mode,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建{}Auto菜单项失败: {e}", section.log_name)))?;
|
||||
menu_builder = menu_builder.item(&auto_item);
|
||||
|
||||
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
|
||||
sorted_providers.sort_by(|(_, a), (_, b)| {
|
||||
@@ -143,7 +156,8 @@ fn append_provider_section<'a>(
|
||||
});
|
||||
|
||||
for (id, provider) in sorted_providers {
|
||||
let is_current = manager.current == *id;
|
||||
// Auto 模式下所有供应商都不选中
|
||||
let is_current = !auto_mode && manager.current == *id;
|
||||
let item = CheckMenuItem::with_id(
|
||||
app,
|
||||
format!("{}{}", section.prefix, id),
|
||||
|
||||
@@ -2,7 +2,10 @@ use serde_json::json;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cc_switch_lib::{AppError, AppType, ConfigService, MultiAppConfig, Provider};
|
||||
use cc_switch_lib::{
|
||||
get_claude_settings_path, read_json_file, AppError, AppType, ConfigService, MultiAppConfig,
|
||||
Provider, ProviderMeta,
|
||||
};
|
||||
|
||||
#[path = "support.rs"]
|
||||
mod support;
|
||||
@@ -10,6 +13,132 @@ use support::{
|
||||
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn sync_claude_provider_writes_live_settings() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
let provider_config = json!({
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "test-key",
|
||||
"ANTHROPIC_BASE_URL": "https://api.test"
|
||||
},
|
||||
"ui": {
|
||||
"displayName": "Test Provider"
|
||||
}
|
||||
});
|
||||
|
||||
let provider = Provider::with_id(
|
||||
"prov-1".to_string(),
|
||||
"Test Claude".to_string(),
|
||||
provider_config.clone(),
|
||||
None,
|
||||
);
|
||||
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Claude)
|
||||
.expect("claude manager");
|
||||
manager.providers.insert("prov-1".to_string(), provider);
|
||||
manager.current = "prov-1".to_string();
|
||||
|
||||
ConfigService::sync_current_providers_to_live(&mut config).expect("sync live settings");
|
||||
|
||||
let settings_path = get_claude_settings_path();
|
||||
assert!(
|
||||
settings_path.exists(),
|
||||
"live settings should be written to {}",
|
||||
settings_path.display()
|
||||
);
|
||||
|
||||
let live_value: serde_json::Value = read_json_file(&settings_path).expect("read live file");
|
||||
assert_eq!(live_value, provider_config);
|
||||
|
||||
// 确认 SSOT 中的供应商也同步了最新内容
|
||||
let updated = config
|
||||
.get_manager(&AppType::Claude)
|
||||
.and_then(|m| m.providers.get("prov-1"))
|
||||
.expect("provider in config");
|
||||
assert_eq!(updated.settings_config, provider_config);
|
||||
|
||||
// 额外确认写入位置位于测试 HOME 下
|
||||
assert!(
|
||||
settings_path.starts_with(home),
|
||||
"settings path {settings_path:?} should reside under test HOME {home:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_codex_provider_writes_auth_and_config() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
|
||||
// 注意:v3.7.0 后 MCP 同步由 McpService 独立处理,不再通过 provider 切换触发
|
||||
// 此测试仅验证 auth.json 和 config.toml 基础配置的写入
|
||||
|
||||
let provider_config = json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": "codex-key"
|
||||
},
|
||||
"config": r#"base_url = "https://codex.test""#
|
||||
});
|
||||
|
||||
let provider = Provider::with_id(
|
||||
"codex-1".to_string(),
|
||||
"Codex Test".to_string(),
|
||||
provider_config.clone(),
|
||||
None,
|
||||
);
|
||||
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Codex)
|
||||
.expect("codex manager");
|
||||
manager.providers.insert("codex-1".to_string(), provider);
|
||||
manager.current = "codex-1".to_string();
|
||||
|
||||
ConfigService::sync_current_providers_to_live(&mut config).expect("sync codex live");
|
||||
|
||||
let auth_path = cc_switch_lib::get_codex_auth_path();
|
||||
let config_path = cc_switch_lib::get_codex_config_path();
|
||||
|
||||
assert!(
|
||||
auth_path.exists(),
|
||||
"auth.json should exist at {}",
|
||||
auth_path.display()
|
||||
);
|
||||
assert!(
|
||||
config_path.exists(),
|
||||
"config.toml should exist at {}",
|
||||
config_path.display()
|
||||
);
|
||||
|
||||
let auth_value: serde_json::Value = read_json_file(&auth_path).expect("read auth");
|
||||
assert_eq!(
|
||||
auth_value,
|
||||
provider_config.get("auth").cloned().expect("auth object")
|
||||
);
|
||||
|
||||
let toml_text = fs::read_to_string(&config_path).expect("read config.toml");
|
||||
// 验证基础配置正确写入
|
||||
assert!(
|
||||
toml_text.contains("base_url"),
|
||||
"config.toml should contain base_url from provider config"
|
||||
);
|
||||
|
||||
// 当前供应商应同步最新 config 文本
|
||||
let manager = config.get_manager(&AppType::Codex).expect("codex manager");
|
||||
let synced = manager.providers.get("codex-1").expect("codex provider");
|
||||
let synced_cfg = synced
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("config string");
|
||||
assert_eq!(synced_cfg, toml_text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_enabled_to_codex_writes_enabled_servers() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
@@ -209,6 +338,46 @@ fn sync_enabled_to_codex_returns_error_on_invalid_toml() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_codex_provider_missing_auth_returns_error() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
let provider = Provider::with_id(
|
||||
"codex-missing-auth".to_string(),
|
||||
"No Auth".to_string(),
|
||||
json!({
|
||||
"config": "model = \"test\""
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Codex)
|
||||
.expect("codex manager");
|
||||
manager.providers.insert(provider.id.clone(), provider);
|
||||
manager.current = "codex-missing-auth".to_string();
|
||||
|
||||
let err = ConfigService::sync_current_providers_to_live(&mut config)
|
||||
.expect_err("sync should fail when auth missing");
|
||||
match err {
|
||||
cc_switch_lib::AppError::Config(msg) => {
|
||||
assert!(msg.contains("auth"), "error message should mention auth");
|
||||
}
|
||||
other => panic!("unexpected error variant: {other:?}"),
|
||||
}
|
||||
|
||||
// 确认未产生任何 live 配置文件
|
||||
assert!(
|
||||
!cc_switch_lib::get_codex_auth_path().exists(),
|
||||
"auth.json should not be created on failure"
|
||||
);
|
||||
assert!(
|
||||
!cc_switch_lib::get_codex_config_path().exists(),
|
||||
"config.toml should not be created on failure"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_codex_live_atomic_persists_auth_and_config() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
@@ -647,6 +816,107 @@ fn create_backup_retains_only_latest_entries() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_gemini_packycode_sets_security_selected_type() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
{
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Gemini)
|
||||
.expect("gemini manager");
|
||||
manager.current = "packy-1".to_string();
|
||||
manager.providers.insert(
|
||||
"packy-1".to_string(),
|
||||
Provider::with_id(
|
||||
"packy-1".to_string(),
|
||||
"PackyCode".to_string(),
|
||||
json!({
|
||||
"env": {
|
||||
"GEMINI_API_KEY": "pk-key",
|
||||
"GOOGLE_GEMINI_BASE_URL": "https://api-slb.packyapi.com"
|
||||
}
|
||||
}),
|
||||
Some("https://www.packyapi.com".to_string()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ConfigService::sync_current_providers_to_live(&mut config)
|
||||
.expect("syncing gemini live should succeed");
|
||||
|
||||
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
|
||||
let gemini_settings = home.join(".gemini").join("settings.json");
|
||||
assert!(
|
||||
gemini_settings.exists(),
|
||||
"Gemini settings.json should exist at {}",
|
||||
gemini_settings.display()
|
||||
);
|
||||
|
||||
let raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings.json");
|
||||
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse gemini settings.json");
|
||||
assert_eq!(
|
||||
value
|
||||
.pointer("/security/auth/selectedType")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("gemini-api-key"),
|
||||
"syncing PackyCode Gemini should enforce security.auth.selectedType in Gemini settings"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_gemini_google_official_sets_oauth_security() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
{
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Gemini)
|
||||
.expect("gemini manager");
|
||||
manager.current = "google-official".to_string();
|
||||
let mut provider = Provider::with_id(
|
||||
"google-official".to_string(),
|
||||
"Google".to_string(),
|
||||
json!({
|
||||
"env": {}
|
||||
}),
|
||||
Some("https://ai.google.dev".to_string()),
|
||||
);
|
||||
provider.meta = Some(ProviderMeta {
|
||||
partner_promotion_key: Some("google-official".to_string()),
|
||||
..ProviderMeta::default()
|
||||
});
|
||||
manager
|
||||
.providers
|
||||
.insert("google-official".to_string(), provider);
|
||||
}
|
||||
|
||||
ConfigService::sync_current_providers_to_live(&mut config)
|
||||
.expect("syncing google official gemini should succeed");
|
||||
|
||||
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
|
||||
let gemini_settings = home.join(".gemini").join("settings.json");
|
||||
assert!(
|
||||
gemini_settings.exists(),
|
||||
"Gemini settings should exist at {}",
|
||||
gemini_settings.display()
|
||||
);
|
||||
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
|
||||
let gemini_value: serde_json::Value =
|
||||
serde_json::from_str(&gemini_raw).expect("parse gemini settings json");
|
||||
assert_eq!(
|
||||
gemini_value
|
||||
.pointer("/security/auth/selectedType")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("oauth-personal"),
|
||||
"Gemini settings should record oauth-personal for Google Official"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_sql_writes_to_target_path() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
|
||||
@@ -100,12 +100,9 @@ command = "say"
|
||||
);
|
||||
|
||||
let config_text = std::fs::read_to_string(get_codex_config_path()).expect("read config.toml");
|
||||
// With partial merge, only key fields (model, provider, model_providers) are
|
||||
// merged into config.toml. The existing MCP section should be preserved.
|
||||
// MCP sync from DB is handled separately (at startup or explicit sync).
|
||||
assert!(
|
||||
config_text.contains("mcp_servers.legacy"),
|
||||
"config.toml should preserve existing MCP servers after partial merge"
|
||||
config_text.contains("mcp_servers.echo-server"),
|
||||
"config.toml should contain synced MCP servers"
|
||||
);
|
||||
|
||||
let current_id = app_state
|
||||
@@ -129,9 +126,12 @@ command = "say"
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
// With partial merge, only key fields (model_provider, model, model_providers)
|
||||
// are written to the live file. MCP servers are synced separately.
|
||||
// The provider's stored config should still contain mcp_servers.latest.
|
||||
// 供应商配置应该包含在 live 文件中
|
||||
// 注意:live 文件还会包含 MCP 同步后的内容
|
||||
assert!(
|
||||
config_text.contains("mcp_servers.latest"),
|
||||
"live file should contain provider's original config"
|
||||
);
|
||||
assert!(
|
||||
new_config_text.contains("mcp_servers.latest"),
|
||||
"provider snapshot should contain provider's original config"
|
||||
@@ -268,22 +268,11 @@ fn switch_provider_updates_claude_live_and_state() {
|
||||
let legacy_provider = providers
|
||||
.get("old-provider")
|
||||
.expect("legacy provider still exists");
|
||||
// Backfill mechanism: before switching, the live config's key fields are
|
||||
// backfilled to the current provider. With partial merge, only key fields
|
||||
// (auth, model, endpoint) are extracted — non-key fields like workspace
|
||||
// are NOT included in the backfill.
|
||||
// 回填机制:切换前会将 live 配置回填到当前供应商
|
||||
// 这保护了用户在 live 文件中的手动修改
|
||||
assert_eq!(
|
||||
legacy_provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||
.and_then(|key| key.as_str()),
|
||||
Some("legacy-key"),
|
||||
"previous provider should be backfilled with live auth key"
|
||||
);
|
||||
assert!(
|
||||
legacy_provider.settings_config.get("workspace").is_none(),
|
||||
"backfill should NOT include non-key fields like workspace"
|
||||
legacy_provider.settings_config, legacy_live,
|
||||
"previous provider should be backfilled with live config"
|
||||
);
|
||||
|
||||
let new_provider = providers.get("new-provider").expect("new provider exists");
|
||||
|
||||
@@ -112,12 +112,9 @@ command = "say"
|
||||
|
||||
let config_text =
|
||||
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
|
||||
// With partial merge, only key fields (model, provider, model_providers) are
|
||||
// merged into config.toml. The existing MCP section should be preserved.
|
||||
// MCP sync from DB is handled separately (at startup or explicit sync).
|
||||
assert!(
|
||||
config_text.contains("mcp_servers.legacy"),
|
||||
"config.toml should preserve existing MCP servers after partial merge"
|
||||
config_text.contains("mcp_servers.echo-server"),
|
||||
"config.toml should contain synced MCP servers"
|
||||
);
|
||||
|
||||
let current_id = state
|
||||
@@ -146,6 +143,11 @@ command = "say"
|
||||
new_config_text.contains("mcp_servers.latest"),
|
||||
"provider config should contain original MCP servers"
|
||||
);
|
||||
// live 文件额外包含同步的 MCP 服务器
|
||||
assert!(
|
||||
config_text.contains("mcp_servers.echo-server"),
|
||||
"live config should include synced MCP servers"
|
||||
);
|
||||
|
||||
let legacy = providers
|
||||
.get("old-provider")
|
||||
@@ -412,19 +414,9 @@ fn provider_service_switch_claude_updates_live_and_state() {
|
||||
let legacy_provider = providers
|
||||
.get("old-provider")
|
||||
.expect("legacy provider still exists");
|
||||
// With partial merge backfill, only key fields are extracted from live config
|
||||
assert_eq!(
|
||||
legacy_provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||
.and_then(|key| key.as_str()),
|
||||
Some("legacy-key"),
|
||||
"previous provider should receive backfilled auth key"
|
||||
);
|
||||
assert!(
|
||||
legacy_provider.settings_config.get("workspace").is_none(),
|
||||
"backfill should NOT include non-key fields like workspace"
|
||||
legacy_provider.settings_config, legacy_live,
|
||||
"previous provider should receive backfilled live config"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+177
-387
@@ -3,7 +3,6 @@ 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,
|
||||
@@ -17,10 +16,6 @@ import {
|
||||
Download,
|
||||
FolderArchive,
|
||||
Search,
|
||||
FolderOpen,
|
||||
KeyRound,
|
||||
Shield,
|
||||
Cpu,
|
||||
} from "lucide-react";
|
||||
import type { Provider, VisibleApps } from "@/types";
|
||||
import type { EnvConflict } from "@/types/env";
|
||||
@@ -33,9 +28,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 { useAutoCompact } from "@/hooks/useAutoCompact";
|
||||
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { isTextEditableTarget } from "@/utils/domUtils";
|
||||
@@ -62,14 +55,7 @@ import { UniversalProviderPanel } from "@/components/universal";
|
||||
import { McpIcon } from "@/components/BrandIcons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
|
||||
import {
|
||||
useDisableCurrentOmo,
|
||||
useDisableCurrentOmoSlim,
|
||||
} 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";
|
||||
import { useDisableCurrentOmo } from "@/lib/query/omo";
|
||||
|
||||
type View =
|
||||
| "providers"
|
||||
@@ -80,30 +66,14 @@ type View =
|
||||
| "mcp"
|
||||
| "agents"
|
||||
| "universal"
|
||||
| "sessions"
|
||||
| "workspace"
|
||||
| "openclawEnv"
|
||||
| "openclawTools"
|
||||
| "openclawAgents";
|
||||
|
||||
interface WebDavSyncStatusUpdatedPayload {
|
||||
source?: string;
|
||||
status?: string;
|
||||
error?: string;
|
||||
}
|
||||
| "sessions";
|
||||
|
||||
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
|
||||
const HEADER_HEIGHT = 64; // px
|
||||
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
|
||||
|
||||
const STORAGE_KEY = "cc-switch-last-app";
|
||||
const VALID_APPS: AppId[] = [
|
||||
"claude",
|
||||
"codex",
|
||||
"gemini",
|
||||
"opencode",
|
||||
"openclaw",
|
||||
];
|
||||
const VALID_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"];
|
||||
|
||||
const getInitialApp = (): AppId => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY) as AppId | null;
|
||||
@@ -124,10 +94,6 @@ const VALID_VIEWS: View[] = [
|
||||
"agents",
|
||||
"universal",
|
||||
"sessions",
|
||||
"workspace",
|
||||
"openclawEnv",
|
||||
"openclawTools",
|
||||
"openclawAgents",
|
||||
];
|
||||
|
||||
const getInitialView = (): View => {
|
||||
@@ -157,7 +123,6 @@ function App() {
|
||||
codex: true,
|
||||
gemini: true,
|
||||
opencode: true,
|
||||
openclaw: true,
|
||||
};
|
||||
|
||||
const getFirstVisibleApp = (): AppId => {
|
||||
@@ -165,7 +130,6 @@ function App() {
|
||||
if (visibleApps.codex) return "codex";
|
||||
if (visibleApps.gemini) return "gemini";
|
||||
if (visibleApps.opencode) return "opencode";
|
||||
if (visibleApps.openclaw) return "openclaw";
|
||||
return "claude"; // fallback
|
||||
};
|
||||
|
||||
@@ -180,10 +144,7 @@ function App() {
|
||||
if (
|
||||
currentView === "sessions" &&
|
||||
activeApp !== "claude" &&
|
||||
activeApp !== "codex" &&
|
||||
activeApp !== "opencode" &&
|
||||
activeApp !== "openclaw" &&
|
||||
activeApp !== "gemini"
|
||||
activeApp !== "codex"
|
||||
) {
|
||||
setCurrentView("providers");
|
||||
}
|
||||
@@ -201,9 +162,6 @@ function App() {
|
||||
const effectiveEditingProvider = useLastValidValue(editingProvider);
|
||||
const effectiveUsageProvider = useLastValidValue(usageProvider);
|
||||
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const isToolbarCompact = useAutoCompact(toolbarRef);
|
||||
|
||||
const promptPanelRef = useRef<any>(null);
|
||||
const mcpPanelRef = useRef<any>(null);
|
||||
const skillsPageRef = useRef<any>(null);
|
||||
@@ -230,12 +188,7 @@ function App() {
|
||||
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
||||
const currentProviderId = data?.currentProviderId ?? "";
|
||||
const hasSkillsSupport = true;
|
||||
const hasSessionSupport =
|
||||
activeApp === "claude" ||
|
||||
activeApp === "codex" ||
|
||||
activeApp === "opencode" ||
|
||||
activeApp === "openclaw" ||
|
||||
activeApp === "gemini";
|
||||
const hasSessionSupport = activeApp === "claude" || activeApp === "codex";
|
||||
|
||||
const {
|
||||
addProvider,
|
||||
@@ -243,7 +196,6 @@ function App() {
|
||||
switchProvider,
|
||||
deleteProvider,
|
||||
saveUsageScript,
|
||||
setAsDefaultModel,
|
||||
} = useProviderActions(activeApp);
|
||||
|
||||
const disableOmoMutation = useDisableCurrentOmo();
|
||||
@@ -263,23 +215,6 @@ function App() {
|
||||
});
|
||||
};
|
||||
|
||||
const disableOmoSlimMutation = useDisableCurrentOmoSlim();
|
||||
const handleDisableOmoSlim = () => {
|
||||
disableOmoSlimMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
toast.success(t("omo.disabled", { defaultValue: "OMO 已停用" }));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(
|
||||
t("omo.disableFailed", {
|
||||
defaultValue: "停用 OMO 失败: {{error}}",
|
||||
error: extractErrorMessage(error),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
@@ -331,50 +266,6 @@ 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 {
|
||||
@@ -532,19 +423,10 @@ function App() {
|
||||
const { provider, action } = confirmAction;
|
||||
|
||||
if (action === "remove") {
|
||||
// Remove from live config only (for additive mode apps like OpenCode/OpenClaw)
|
||||
// Does NOT delete from database - provider remains in the list
|
||||
await providersApi.removeFromLiveConfig(provider.id, activeApp);
|
||||
// Invalidate queries to refresh the isInConfig state
|
||||
if (activeApp === "opencode") {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["opencodeLiveProviderIds"],
|
||||
});
|
||||
} else if (activeApp === "openclaw") {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: openclawKeys.liveProviderIds,
|
||||
});
|
||||
}
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["opencodeLiveProviderIds"],
|
||||
});
|
||||
toast.success(
|
||||
t("notifications.removeFromConfigSuccess", {
|
||||
defaultValue: "已从配置移除",
|
||||
@@ -704,11 +586,7 @@ function App() {
|
||||
return (
|
||||
<SkillsPage
|
||||
ref={skillsPageRef}
|
||||
initialApp={
|
||||
activeApp === "opencode" || activeApp === "openclaw"
|
||||
? "claude"
|
||||
: activeApp
|
||||
}
|
||||
initialApp={activeApp === "opencode" ? "claude" : activeApp}
|
||||
/>
|
||||
);
|
||||
case "mcp":
|
||||
@@ -730,15 +608,7 @@ function App() {
|
||||
);
|
||||
|
||||
case "sessions":
|
||||
return <SessionManagerPage key={activeApp} appId={activeApp} />;
|
||||
case "workspace":
|
||||
return <WorkspaceFilesPanel />;
|
||||
case "openclawEnv":
|
||||
return <EnvPanel />;
|
||||
case "openclawTools":
|
||||
return <ToolsPanel />;
|
||||
case "openclawAgents":
|
||||
return <AgentsDefaultsPanel />;
|
||||
return <SessionManagerPage />;
|
||||
default:
|
||||
return (
|
||||
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
@@ -770,7 +640,7 @@ function App() {
|
||||
setConfirmAction({ provider, action: "delete" })
|
||||
}
|
||||
onRemoveFromConfig={
|
||||
activeApp === "opencode" || activeApp === "openclaw"
|
||||
activeApp === "opencode"
|
||||
? (provider) =>
|
||||
setConfirmAction({ provider, action: "remove" })
|
||||
: undefined
|
||||
@@ -778,11 +648,6 @@ function App() {
|
||||
onDisableOmo={
|
||||
activeApp === "opencode" ? handleDisableOmo : undefined
|
||||
}
|
||||
onDisableOmoSlim={
|
||||
activeApp === "opencode"
|
||||
? handleDisableOmoSlim
|
||||
: undefined
|
||||
}
|
||||
onDuplicate={handleDuplicateProvider}
|
||||
onConfigureUsage={setUsageProvider}
|
||||
onOpenWebsite={handleOpenWebsite}
|
||||
@@ -790,9 +655,6 @@ function App() {
|
||||
activeApp === "claude" ? handleOpenTerminal : undefined
|
||||
}
|
||||
onCreate={() => setIsAddOpen(true)}
|
||||
onSetAsDefault={
|
||||
activeApp === "openclaw" ? setAsDefaultModel : undefined
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
@@ -902,11 +764,6 @@ function App() {
|
||||
defaultValue: "统一供应商",
|
||||
})}
|
||||
{currentView === "sessions" && t("sessionManager.title")}
|
||||
{currentView === "workspace" && t("workspace.title")}
|
||||
{currentView === "openclawEnv" && t("openclaw.env.title")}
|
||||
{currentView === "openclawTools" && t("openclaw.tools.title")}
|
||||
{currentView === "openclawAgents" &&
|
||||
t("openclaw.agents.title")}
|
||||
</h1>
|
||||
</div>
|
||||
) : (
|
||||
@@ -965,257 +822,190 @@ function App() {
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
className="flex flex-1 min-w-0 overflow-x-hidden justify-end items-center"
|
||||
className="flex items-center gap-1.5 h-[32px]"
|
||||
style={{ WebkitAppRegion: "no-drag" } as any}
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1.5"
|
||||
style={{ WebkitAppRegion: "no-drag" } as any}
|
||||
>
|
||||
{currentView === "prompts" && (
|
||||
{currentView === "prompts" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => promptPanelRef.current?.openAdd()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t("prompts.add")}
|
||||
</Button>
|
||||
)}
|
||||
{currentView === "mcp" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => promptPanelRef.current?.openAdd()}
|
||||
onClick={() => mcpPanelRef.current?.openImport()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t("mcp.importExisting")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => mcpPanelRef.current?.openAdd()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t("prompts.add")}
|
||||
{t("mcp.addMcp")}
|
||||
</Button>
|
||||
)}
|
||||
{currentView === "mcp" && (
|
||||
<>
|
||||
</>
|
||||
)}
|
||||
{currentView === "skills" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
unifiedSkillsPanelRef.current?.openInstallFromZip()
|
||||
}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<FolderArchive className="w-4 h-4 mr-2" />
|
||||
{t("skills.installFromZip.button")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => unifiedSkillsPanelRef.current?.openImport()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t("skills.import")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("skillsDiscovery")}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
{t("skills.discover")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{currentView === "skillsDiscovery" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => skillsPageRef.current?.refresh()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{t("skills.refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => skillsPageRef.current?.openRepoManager()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
{t("skills.repoManager")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{currentView === "providers" && (
|
||||
<>
|
||||
{activeApp !== "opencode" && (
|
||||
<>
|
||||
<ProxyToggle activeApp={activeApp} />
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||
isCurrentAppTakeoverActive
|
||||
? "opacity-100 max-w-[100px] scale-100"
|
||||
: "opacity-0 max-w-0 scale-75 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<FailoverToggle activeApp={activeApp} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<AppSwitcher
|
||||
activeApp={activeApp}
|
||||
onSwitch={setActiveApp}
|
||||
visibleApps={visibleApps}
|
||||
compact={
|
||||
isCurrentAppTakeoverActive &&
|
||||
Object.values(visibleApps).filter(Boolean).length >= 4
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => mcpPanelRef.current?.openImport()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t("mcp.importExisting")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => mcpPanelRef.current?.openAdd()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t("mcp.addMcp")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{currentView === "skills" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
unifiedSkillsPanelRef.current?.openInstallFromZip()
|
||||
}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<FolderArchive className="w-4 h-4 mr-2" />
|
||||
{t("skills.installFromZip.button")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => unifiedSkillsPanelRef.current?.openImport()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t("skills.import")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("skillsDiscovery")}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
{t("skills.discover")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{currentView === "skillsDiscovery" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => skillsPageRef.current?.refresh()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{t("skills.refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => skillsPageRef.current?.openRepoManager()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
{t("skills.repoManager")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{currentView === "providers" && (
|
||||
<>
|
||||
{activeApp !== "opencode" &&
|
||||
activeApp !== "openclaw" &&
|
||||
settingsData?.enableLocalProxy && (
|
||||
<>
|
||||
<ProxyToggle activeApp={activeApp} />
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||
isCurrentAppTakeoverActive
|
||||
? "opacity-100 max-w-[100px] scale-100"
|
||||
: "opacity-0 max-w-0 scale-75 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<FailoverToggle activeApp={activeApp} />
|
||||
</div>
|
||||
</>
|
||||
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",
|
||||
)}
|
||||
|
||||
<AppSwitcher
|
||||
activeApp={activeApp}
|
||||
onSwitch={setActiveApp}
|
||||
visibleApps={visibleApps}
|
||||
compact={isToolbarCompact}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeApp === "openclaw" ? "openclaw" : "default"}
|
||||
className="flex items-center gap-1"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{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("sessions")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("sessionManager.title")}
|
||||
>
|
||||
<History 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>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setIsAddOpen(true)}
|
||||
size="icon"
|
||||
className={`ml-2 ${addActionButtonClass}`}
|
||||
title={t("skills.manage")}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<Wrench className="flex-shrink-0 w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<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
|
||||
onClick={() => setIsAddOpen(true)}
|
||||
size="icon"
|
||||
className={`ml-2 ${addActionButtonClass}`}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 min-h-0 flex flex-col overflow-y-auto animate-fade-in">
|
||||
<main className="flex-1 min-h-0 flex flex-col animate-fade-in">
|
||||
{renderContent()}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { AppId } from "@/lib/api";
|
||||
import type { VisibleApps } from "@/types";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AppSwitcherProps {
|
||||
activeApp: AppId;
|
||||
@@ -10,7 +9,7 @@ interface AppSwitcherProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const ALL_APPS: AppId[] = ["claude", "codex", "gemini", "opencode", "openclaw"];
|
||||
const ALL_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"];
|
||||
const STORAGE_KEY = "cc-switch-last-app";
|
||||
|
||||
export function AppSwitcher({
|
||||
@@ -30,14 +29,12 @@ export function AppSwitcher({
|
||||
codex: "openai",
|
||||
gemini: "gemini",
|
||||
opencode: "opencode",
|
||||
openclaw: "openclaw",
|
||||
};
|
||||
const appDisplayName: Record<AppId, string> = {
|
||||
claude: "Claude",
|
||||
codex: "Codex",
|
||||
gemini: "Gemini",
|
||||
opencode: "OpenCode",
|
||||
openclaw: "OpenClaw",
|
||||
};
|
||||
|
||||
// Filter apps based on visibility settings (default all visible)
|
||||
@@ -53,28 +50,18 @@ export function AppSwitcher({
|
||||
key={app}
|
||||
type="button"
|
||||
onClick={() => handleSwitch(app)}
|
||||
className={cn(
|
||||
"group inline-flex items-center px-3 h-8 rounded-md text-sm font-medium transition-all duration-200",
|
||||
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||
activeApp === app
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50",
|
||||
)}
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
||||
}`}
|
||||
>
|
||||
<ProviderIcon
|
||||
icon={appIconName[app]}
|
||||
name={appDisplayName[app]}
|
||||
size={iconSize}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"transition-all duration-200 whitespace-nowrap overflow-hidden",
|
||||
compact
|
||||
? "max-w-0 opacity-0 ml-0"
|
||||
: "max-w-[80px] opacity-100 ml-2",
|
||||
)}
|
||||
>
|
||||
{appDisplayName[app]}
|
||||
</span>
|
||||
{!compact && <span>{appDisplayName[app]}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ interface IconProps {
|
||||
import ClaudeSvg from "@/icons/extracted/claude.svg?url";
|
||||
import OpenAISvg from "@/icons/extracted/openai.svg?url";
|
||||
import GeminiSvg from "@/icons/extracted/gemini.svg?url";
|
||||
import OpenClawSvg from "@/icons/extracted/claw.svg?url";
|
||||
|
||||
export function ClaudeIcon({ size = 16, className = "" }: IconProps) {
|
||||
return (
|
||||
@@ -48,19 +47,6 @@ export function GeminiIcon({ size = 16, className = "" }: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenClawIcon({ size = 16, className = "" }: IconProps) {
|
||||
return (
|
||||
<img
|
||||
src={OpenClawSvg}
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
alt="OpenClaw"
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// MCP icon uses inline SVG to support currentColor for hover effects
|
||||
export function McpIcon({ size = 16, className = "" }: IconProps) {
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertTriangle, Info } from "lucide-react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
@@ -16,7 +16,6 @@ interface ConfirmDialogProps {
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: "destructive" | "info";
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -27,16 +26,11 @@ export function ConfirmDialog({
|
||||
message,
|
||||
confirmText,
|
||||
cancelText,
|
||||
variant = "destructive",
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const IconComponent = variant === "info" ? Info : AlertTriangle;
|
||||
const iconClass =
|
||||
variant === "info" ? "h-5 w-5 text-blue-500" : "h-5 w-5 text-destructive";
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
@@ -49,7 +43,7 @@ export function ConfirmDialog({
|
||||
<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">
|
||||
<IconComponent className={iconClass} />
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="whitespace-pre-line text-sm leading-relaxed">
|
||||
@@ -60,10 +54,7 @@ export function ConfirmDialog({
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
{cancelText || t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={variant === "info" ? "default" : "destructive"}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<Button variant="destructive" onClick={onConfirm}>
|
||||
{confirmText || t("common.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -53,15 +53,12 @@ export function DeepLinkImportDialog() {
|
||||
const unlistenImport = listen<DeepLinkImportRequest>(
|
||||
"deeplink-import",
|
||||
async (event) => {
|
||||
console.log("Deep link import event received:", event.payload);
|
||||
|
||||
// If config is present, merge it to get the complete configuration
|
||||
if (event.payload.config || event.payload.configUrl) {
|
||||
try {
|
||||
const mergedRequest = await deeplinkApi.mergeDeeplinkConfig(
|
||||
event.payload,
|
||||
);
|
||||
console.log("Config merged successfully:", mergedRequest);
|
||||
setRequest(mergedRequest);
|
||||
} catch (error) {
|
||||
console.error("Failed to merge config:", error);
|
||||
|
||||
@@ -22,6 +22,10 @@ interface JsonEditorProps {
|
||||
language?: "json" | "javascript";
|
||||
height?: string | number;
|
||||
showMinimap?: boolean; // 添加此属性以防未来使用
|
||||
/** 只读模式 */
|
||||
readOnly?: boolean;
|
||||
/** 自动高度模式:根据内容自动调整高度,rows 作为最小行数 */
|
||||
autoHeight?: boolean;
|
||||
}
|
||||
|
||||
const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
@@ -33,6 +37,8 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
showValidation = true,
|
||||
language = "json",
|
||||
height,
|
||||
readOnly = false,
|
||||
autoHeight = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
@@ -82,7 +88,14 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
if (!editorRef.current) return;
|
||||
|
||||
// 创建编辑器扩展
|
||||
const minHeightPx = height ? undefined : Math.max(1, rows) * 18;
|
||||
const lineHeight = 18;
|
||||
const minHeightPx = height ? undefined : Math.max(1, rows) * lineHeight;
|
||||
|
||||
// 自动高度模式:计算内容行数
|
||||
const contentLines = value ? value.split("\n").length : 1;
|
||||
const autoHeightPx = autoHeight
|
||||
? Math.max(rows, contentLines) * lineHeight + 10 // +10 for padding
|
||||
: undefined;
|
||||
|
||||
// 使用 baseTheme 定义基础样式,优先级低于 oneDark,但可以正确响应主题
|
||||
const baseTheme = EditorView.baseTheme({
|
||||
@@ -123,9 +136,17 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
? `${height}px`
|
||||
: height
|
||||
: undefined;
|
||||
|
||||
// 确定最终高度:优先级 height > autoHeight > minHeight
|
||||
const finalHeight = heightValue
|
||||
? heightValue
|
||||
: autoHeightPx
|
||||
? `${autoHeightPx}px`
|
||||
: undefined;
|
||||
|
||||
const sizingTheme = EditorView.theme({
|
||||
"&": heightValue
|
||||
? { height: heightValue }
|
||||
"&": finalHeight
|
||||
? { height: finalHeight }
|
||||
: { minHeight: `${minHeightPx}px` },
|
||||
".cm-scroller": { overflow: "auto" },
|
||||
".cm-content": {
|
||||
@@ -150,6 +171,22 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
}),
|
||||
];
|
||||
|
||||
// 如果是只读模式,添加只读扩展
|
||||
if (readOnly) {
|
||||
extensions.push(EditorState.readOnly.of(true));
|
||||
extensions.push(
|
||||
EditorView.theme({
|
||||
".cm-editor": {
|
||||
opacity: "0.8",
|
||||
cursor: "default",
|
||||
},
|
||||
".cm-content": {
|
||||
cursor: "default",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 如果启用深色模式,添加深色主题
|
||||
if (darkMode) {
|
||||
extensions.push(oneDark);
|
||||
@@ -208,7 +245,16 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, [darkMode, rows, height, language, jsonLinter]); // 依赖项中不包含 onChange 和 placeholder,避免不必要的重建
|
||||
}, [
|
||||
darkMode,
|
||||
rows,
|
||||
height,
|
||||
language,
|
||||
jsonLinter,
|
||||
readOnly,
|
||||
autoHeight,
|
||||
autoHeight ? value.split("\n").length : 0,
|
||||
]); // 依赖项中不包含 onChange 和 placeholder,避免不必要的重建;autoHeight 模式下根据行数变化重建
|
||||
|
||||
// 当 value 从外部改变时更新编辑器内容
|
||||
useEffect(() => {
|
||||
@@ -261,7 +307,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
style={{ width: "100%", height: isFullHeight ? undefined : "auto" }}
|
||||
className={isFullHeight ? "flex-1 min-h-0" : ""}
|
||||
/>
|
||||
{language === "json" && (
|
||||
{language === "json" && !readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFormat}
|
||||
|
||||
@@ -4,8 +4,7 @@ import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Provider, UsageScript, UsageData } from "@/types";
|
||||
import { usageApi, settingsApi, type AppId } from "@/lib/api";
|
||||
import { useSettingsQuery } from "@/lib/query";
|
||||
import { usageApi, type AppId } from "@/lib/api";
|
||||
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
|
||||
import JsonEditor from "./JsonEditor";
|
||||
import * as prettier from "prettier/standalone";
|
||||
@@ -16,7 +15,6 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface UsageScriptModalProps {
|
||||
@@ -114,8 +112,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: settingsData } = useSettingsQuery();
|
||||
const [showUsageConfirm, setShowUsageConfirm] = useState(false);
|
||||
|
||||
// 生成带国际化的预设模板
|
||||
const PRESET_TEMPLATES = generatePresetTemplates(t);
|
||||
@@ -251,27 +247,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [showAccessToken, setShowAccessToken] = useState(false);
|
||||
|
||||
const handleEnableToggle = (checked: boolean) => {
|
||||
if (checked && !settingsData?.usageConfirmed) {
|
||||
setShowUsageConfirm(true);
|
||||
} else {
|
||||
setScript({ ...script, enabled: checked });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUsageConfirm = async () => {
|
||||
setShowUsageConfirm(false);
|
||||
try {
|
||||
if (settingsData) {
|
||||
await settingsApi.save({ ...settingsData, usageConfirmed: true });
|
||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save usage confirmed:", error);
|
||||
}
|
||||
setScript({ ...script, enabled: true });
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (script.enabled && !script.code.trim()) {
|
||||
toast.error(t("usageScript.scriptEmpty"));
|
||||
@@ -461,7 +436,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
</p>
|
||||
<Switch
|
||||
checked={script.enabled}
|
||||
onCheckedChange={handleEnableToggle}
|
||||
onCheckedChange={(checked) =>
|
||||
setScript({ ...script, enabled: checked })
|
||||
}
|
||||
aria-label={t("usageScript.enableUsageQuery")}
|
||||
/>
|
||||
</div>
|
||||
@@ -867,16 +844,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={showUsageConfirm}
|
||||
variant="info"
|
||||
title={t("confirm.usage.title")}
|
||||
message={t("confirm.usage.message")}
|
||||
confirmText={t("confirm.usage.confirm")}
|
||||
onConfirm={() => void handleUsageConfirm()}
|
||||
onCancel={() => setShowUsageConfirm(false)}
|
||||
/>
|
||||
</FullScreenPanel>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,13 +6,11 @@ import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
|
||||
interface AppCountBarProps {
|
||||
totalLabel: string;
|
||||
counts: Record<AppId, number>;
|
||||
appIds?: AppId[];
|
||||
}
|
||||
|
||||
export const AppCountBar: React.FC<AppCountBarProps> = ({
|
||||
totalLabel,
|
||||
counts,
|
||||
appIds = APP_IDS,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6 flex items-center justify-between gap-4">
|
||||
@@ -20,7 +18,7 @@ export const AppCountBar: React.FC<AppCountBarProps> = ({
|
||||
{totalLabel}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar">
|
||||
{appIds.map((app) => (
|
||||
{APP_IDS.map((app) => (
|
||||
<Badge
|
||||
key={app}
|
||||
variant="secondary"
|
||||
|
||||
@@ -10,17 +10,15 @@ import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
|
||||
interface AppToggleGroupProps {
|
||||
apps: Record<AppId, boolean>;
|
||||
onToggle: (app: AppId, enabled: boolean) => void;
|
||||
appIds?: AppId[];
|
||||
}
|
||||
|
||||
export const AppToggleGroup: React.FC<AppToggleGroupProps> = ({
|
||||
apps,
|
||||
onToggle,
|
||||
appIds = APP_IDS,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{appIds.map((app) => {
|
||||
{APP_IDS.map((app) => {
|
||||
const { label, icon, activeClass } = APP_ICON_MAP[app];
|
||||
const enabled = apps[app];
|
||||
return (
|
||||
|
||||
@@ -66,7 +66,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
opencode: boolean;
|
||||
openclaw: boolean;
|
||||
}>(() => {
|
||||
if (initialData?.apps) {
|
||||
return { ...initialData.apps };
|
||||
@@ -76,7 +75,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
codex: defaultEnabledApps.includes("codex"),
|
||||
gemini: defaultEnabledApps.includes("gemini"),
|
||||
opencode: defaultEnabledApps.includes("opencode"),
|
||||
openclaw: defaultEnabledApps.includes("openclaw"),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -563,22 +561,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
{t("mcp.unifiedPanel.apps.gemini")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="enable-opencode"
|
||||
checked={enabledApps.opencode}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setEnabledApps({ ...enabledApps, opencode: checked })
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="enable-opencode"
|
||||
className="text-sm text-foreground cursor-pointer select-none"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.opencode")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Edit3, Trash2, ExternalLink } from "lucide-react";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import { mcpPresets } from "@/config/mcpPresets";
|
||||
import { toast } from "sonner";
|
||||
import { MCP_SKILLS_APP_IDS } from "@/config/appConfig";
|
||||
import { APP_IDS } from "@/config/appConfig";
|
||||
import { AppCountBar } from "@/components/common/AppCountBar";
|
||||
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
|
||||
import { ListItemRow } from "@/components/common/ListItemRow";
|
||||
@@ -56,9 +56,9 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
}, [serversMap]);
|
||||
|
||||
const enabledCounts = useMemo(() => {
|
||||
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };
|
||||
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0 };
|
||||
serverEntries.forEach(([_, server]) => {
|
||||
for (const app of MCP_SKILLS_APP_IDS) {
|
||||
for (const app of APP_IDS) {
|
||||
if (server.apps[app]) counts[app]++;
|
||||
}
|
||||
});
|
||||
@@ -136,7 +136,6 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
<AppCountBar
|
||||
totalLabel={t("mcp.serverCount", { count: serverEntries.length })}
|
||||
counts={enabledCounts}
|
||||
appIds={MCP_SKILLS_APP_IDS}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
||||
@@ -278,7 +277,6 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||
<AppToggleGroup
|
||||
apps={server.apps}
|
||||
onToggle={(app, enabled) => onToggleApp(id, app, enabled)}
|
||||
appIds={MCP_SKILLS_APP_IDS}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useOpenClawAgentsDefaults,
|
||||
useSaveOpenClawAgentsDefaults,
|
||||
} from "@/hooks/useOpenClaw";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { OpenClawAgentsDefaults } from "@/types";
|
||||
|
||||
const AgentsDefaultsPanel: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: agentsData, isLoading } = useOpenClawAgentsDefaults();
|
||||
const saveAgentsMutation = useSaveOpenClawAgentsDefaults();
|
||||
const [defaults, setDefaults] = useState<OpenClawAgentsDefaults | null>(null);
|
||||
const [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("");
|
||||
|
||||
// Primary model is read-only — set via the "Set as default model" button on provider cards
|
||||
const primaryModel = agentsData?.model?.primary ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
// agentsData is undefined while loading, null when config section is absent
|
||||
if (agentsData === undefined) return;
|
||||
setDefaults(agentsData);
|
||||
|
||||
if (agentsData) {
|
||||
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 — primary is read-only, preserve original value
|
||||
const fallbackList = fallbacks
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const origPrimary = defaults?.model?.primary;
|
||||
if (origPrimary) {
|
||||
updated.model = {
|
||||
primary: origPrimary,
|
||||
...(fallbackList.length > 0 ? { fallbacks: fallbackList } : {}),
|
||||
};
|
||||
} else if (fallbackList.length > 0) {
|
||||
// No primary set but user provided fallbacks — keep fallbacks only
|
||||
updated.model = { primary: "", 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>
|
||||
<div className="h-9 px-3 flex items-center rounded-md border border-input bg-muted/50 font-mono text-xs text-muted-foreground">
|
||||
{primaryModel || t("openclaw.agents.notSet")}
|
||||
</div>
|
||||
<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;
|
||||
@@ -1,182 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2, Save, Eye, EyeOff } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useOpenClawEnv, useSaveOpenClawEnv } from "@/hooks/useOpenClaw";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { OpenClawEnvConfig } from "@/types";
|
||||
|
||||
interface EnvEntry {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
const EnvPanel: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: envData, isLoading } = useOpenClawEnv();
|
||||
const saveEnvMutation = useSaveOpenClawEnv();
|
||||
const [entries, setEntries] = useState<EnvEntry[]>([]);
|
||||
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (envData) {
|
||||
const items: EnvEntry[] = Object.entries(envData).map(([key, value]) => ({
|
||||
id: crypto.randomUUID(),
|
||||
key,
|
||||
value: String(value ?? ""),
|
||||
}));
|
||||
setEntries(items.length > 0 ? items : []);
|
||||
}
|
||||
}, [envData]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const env: OpenClawEnvConfig = {};
|
||||
const seen = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
const trimmedKey = entry.key.trim();
|
||||
if (trimmedKey) {
|
||||
if (seen.has(trimmedKey)) {
|
||||
toast.error(t("openclaw.env.duplicateKey", { key: trimmedKey }));
|
||||
return;
|
||||
}
|
||||
seen.add(trimmedKey);
|
||||
env[trimmedKey] = entry.value;
|
||||
}
|
||||
}
|
||||
await saveEnvMutation.mutateAsync(env);
|
||||
toast.success(t("openclaw.env.saveSuccess"));
|
||||
} catch (error) {
|
||||
const detail = extractErrorMessage(error);
|
||||
toast.error(t("openclaw.env.saveFailed"), {
|
||||
description: detail || undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addEntry = () => {
|
||||
setEntries((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), key: "", value: "", isNew: true },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeEntry = (index: number) => {
|
||||
setEntries((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateEntry = (index: number, field: "key" | "value", val: string) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, [field]: val } : entry,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleVisibility = (key: string) => {
|
||||
setVisibleKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isApiKey = (key: string) => /key|token|secret|password/i.test(key);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-6 pt-4 pb-8 flex items-center justify-center min-h-[200px]">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 pt-4 pb-8">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t("openclaw.env.description")}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{entries.map((entry, index) => {
|
||||
const sensitive = isApiKey(entry.key);
|
||||
const visibilityId = entry.key || `__new_${index}`;
|
||||
const visible = visibleKeys.has(visibilityId);
|
||||
|
||||
return (
|
||||
<div key={entry.id} className="flex items-center gap-2">
|
||||
<div className="w-[200px] flex-shrink-0">
|
||||
<Input
|
||||
value={entry.key}
|
||||
onChange={(e) => updateEntry(index, "key", e.target.value)}
|
||||
placeholder={t("openclaw.env.keyPlaceholder")}
|
||||
className="font-mono text-xs"
|
||||
autoFocus={entry.isNew}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center gap-1">
|
||||
<Input
|
||||
type={sensitive && !visible ? "password" : "text"}
|
||||
value={entry.value}
|
||||
onChange={(e) => updateEntry(index, "value", e.target.value)}
|
||||
placeholder={t("openclaw.env.valuePlaceholder")}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{sensitive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="flex-shrink-0 h-9 w-9 text-muted-foreground"
|
||||
onClick={() => toggleVisibility(visibilityId)}
|
||||
>
|
||||
{visible ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="flex-shrink-0 h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeEntry(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<Button variant="outline" size="sm" onClick={addEntry}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t("openclaw.env.add")}
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saveEnvMutation.isPending}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
{saveEnvMutation.isPending ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvPanel;
|
||||
@@ -1,221 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useOpenClawTools, useSaveOpenClawTools } from "@/hooks/useOpenClaw";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { OpenClawToolsConfig } from "@/types";
|
||||
|
||||
interface ListItem {
|
||||
id: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const PROFILE_OPTIONS = ["default", "strict", "permissive", "custom"];
|
||||
|
||||
const ToolsPanel: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: toolsData, isLoading } = useOpenClawTools();
|
||||
const saveToolsMutation = useSaveOpenClawTools();
|
||||
const [config, setConfig] = useState<OpenClawToolsConfig>({});
|
||||
const [allowList, setAllowList] = useState<ListItem[]>([]);
|
||||
const [denyList, setDenyList] = useState<ListItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (toolsData) {
|
||||
setConfig(toolsData);
|
||||
setAllowList(
|
||||
(toolsData.allow ?? []).map((v) => ({
|
||||
id: crypto.randomUUID(),
|
||||
value: v,
|
||||
})),
|
||||
);
|
||||
setDenyList(
|
||||
(toolsData.deny ?? []).map((v) => ({
|
||||
id: crypto.randomUUID(),
|
||||
value: v,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}, [toolsData]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const { profile, allow, deny, ...other } = config;
|
||||
const newConfig: OpenClawToolsConfig = {
|
||||
...other,
|
||||
profile: config.profile,
|
||||
allow: allowList.map((item) => item.value).filter((s) => s.trim()),
|
||||
deny: denyList.map((item) => item.value).filter((s) => s.trim()),
|
||||
};
|
||||
await saveToolsMutation.mutateAsync(newConfig);
|
||||
toast.success(t("openclaw.tools.saveSuccess"));
|
||||
} catch (error) {
|
||||
const detail = extractErrorMessage(error);
|
||||
toast.error(t("openclaw.tools.saveFailed"), {
|
||||
description: detail || undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updateListItem = (
|
||||
setList: React.Dispatch<React.SetStateAction<ListItem[]>>,
|
||||
index: number,
|
||||
value: string,
|
||||
) => {
|
||||
setList((prev) =>
|
||||
prev.map((item, i) => (i === index ? { ...item, value } : item)),
|
||||
);
|
||||
};
|
||||
|
||||
const removeListItem = (
|
||||
setList: React.Dispatch<React.SetStateAction<ListItem[]>>,
|
||||
index: number,
|
||||
) => {
|
||||
setList((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-6 pt-4 pb-8 flex items-center justify-center min-h-[200px]">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 pt-4 pb-8">
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
{t("openclaw.tools.description")}
|
||||
</p>
|
||||
|
||||
{/* Profile selector */}
|
||||
<div className="mb-6">
|
||||
<Label className="mb-2 block">{t("openclaw.tools.profile")}</Label>
|
||||
<Select
|
||||
value={config.profile ?? "default"}
|
||||
onValueChange={(val) =>
|
||||
setConfig((prev) => ({ ...prev, profile: val }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROFILE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{t(`openclaw.tools.profiles.${opt}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Allow list */}
|
||||
<div className="mb-6">
|
||||
<Label className="mb-2 block">{t("openclaw.tools.allowList")}</Label>
|
||||
<div className="space-y-2">
|
||||
{allowList.map((item, index) => (
|
||||
<div key={item.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={item.value}
|
||||
onChange={(e) =>
|
||||
updateListItem(setAllowList, index, e.target.value)
|
||||
}
|
||||
placeholder={t("openclaw.tools.patternPlaceholder")}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="flex-shrink-0 h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeListItem(setAllowList, index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setAllowList((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), value: "" },
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t("openclaw.tools.addAllow")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deny list */}
|
||||
<div className="mb-6">
|
||||
<Label className="mb-2 block">{t("openclaw.tools.denyList")}</Label>
|
||||
<div className="space-y-2">
|
||||
{denyList.map((item, index) => (
|
||||
<div key={item.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={item.value}
|
||||
onChange={(e) =>
|
||||
updateListItem(setDenyList, index, e.target.value)
|
||||
}
|
||||
placeholder={t("openclaw.tools.patternPlaceholder")}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="flex-shrink-0 h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeListItem(setDenyList, index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setDenyList((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), value: "" },
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t("openclaw.tools.addDeny")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saveToolsMutation.isPending}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
{saveToolsMutation.isPending ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolsPanel;
|
||||
@@ -30,13 +30,13 @@ const PromptFormModal: React.FC<PromptFormModalProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const appName = t(`apps.${appId}`);
|
||||
const filenameMap: Record<Exclude<AppId, "openclaw">, string> = {
|
||||
const filenameMap: Record<AppId, string> = {
|
||||
claude: "CLAUDE.md",
|
||||
codex: "AGENTS.md",
|
||||
gemini: "GEMINI.md",
|
||||
opencode: "AGENTS.md",
|
||||
};
|
||||
const filename = filenameMap[appId as Exclude<AppId, "openclaw">];
|
||||
const filename = filenameMap[appId];
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
@@ -29,7 +29,6 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
|
||||
codex: "AGENTS.md",
|
||||
gemini: "GEMINI.md",
|
||||
opencode: "AGENTS.md",
|
||||
openclaw: "AGENTS.md",
|
||||
};
|
||||
const filename = filenameMap[appId];
|
||||
const [name, setName] = useState("");
|
||||
|
||||
@@ -17,7 +17,6 @@ import { UniversalProviderPanel } from "@/components/universal";
|
||||
import { providerPresets } from "@/config/claudeProviderPresets";
|
||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
||||
import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets";
|
||||
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
||||
|
||||
interface AddProviderDialogProps {
|
||||
@@ -25,10 +24,7 @@ interface AddProviderDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
appId: AppId;
|
||||
onSubmit: (
|
||||
provider: Omit<Provider, "id"> & {
|
||||
providerKey?: string;
|
||||
suggestedDefaults?: OpenClawSuggestedDefaults;
|
||||
},
|
||||
provider: Omit<Provider, "id"> & { providerKey?: string },
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
|
||||
@@ -39,8 +35,7 @@ export function AddProviderDialog({
|
||||
onSubmit,
|
||||
}: AddProviderDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// OpenCode and OpenClaw don't support universal providers
|
||||
const showUniversalTab = appId !== "opencode" && appId !== "openclaw";
|
||||
const showUniversalTab = appId !== "opencode";
|
||||
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
|
||||
"app-specific",
|
||||
);
|
||||
@@ -87,11 +82,7 @@ export function AddProviderDialog({
|
||||
unknown
|
||||
>;
|
||||
|
||||
// 构造基础提交数据
|
||||
const providerData: Omit<Provider, "id"> & {
|
||||
providerKey?: string;
|
||||
suggestedDefaults?: OpenClawSuggestedDefaults;
|
||||
} = {
|
||||
const providerData: Omit<Provider, "id"> & { providerKey?: string } = {
|
||||
name: values.name.trim(),
|
||||
notes: values.notes?.trim() || undefined,
|
||||
websiteUrl: values.websiteUrl?.trim() || undefined,
|
||||
@@ -102,11 +93,7 @@ export function AddProviderDialog({
|
||||
...(values.meta ? { meta: values.meta } : {}),
|
||||
};
|
||||
|
||||
// OpenCode/OpenClaw: pass providerKey for ID generation
|
||||
if (
|
||||
(appId === "opencode" || appId === "openclaw") &&
|
||||
values.providerKey
|
||||
) {
|
||||
if (appId === "opencode" && values.providerKey) {
|
||||
providerData.providerKey = values.providerKey;
|
||||
}
|
||||
|
||||
@@ -198,11 +185,6 @@ export function AddProviderDialog({
|
||||
if (options?.baseURL) {
|
||||
addUrl(options.baseURL);
|
||||
}
|
||||
} else if (appId === "openclaw") {
|
||||
// OpenClaw uses baseUrl directly
|
||||
if (parsedConfig.baseUrl) {
|
||||
addUrl(parsedConfig.baseUrl as string);
|
||||
}
|
||||
}
|
||||
|
||||
const urls = Array.from(urlSet);
|
||||
@@ -224,11 +206,6 @@ export function AddProviderDialog({
|
||||
}
|
||||
}
|
||||
|
||||
// OpenClaw: pass suggestedDefaults for model registration
|
||||
if (appId === "openclaw" && values.suggestedDefaults) {
|
||||
providerData.suggestedDefaults = values.suggestedDefaults;
|
||||
}
|
||||
|
||||
await onSubmit(providerData);
|
||||
onOpenChange(false);
|
||||
},
|
||||
@@ -309,7 +286,6 @@ export function AddProviderDialog({
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
// OpenCode/OpenClaw: directly show form without tabs
|
||||
<ProviderForm
|
||||
appId={appId}
|
||||
submitLabel={t("common.add")}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Save } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
@@ -8,7 +9,13 @@ import {
|
||||
ProviderForm,
|
||||
type ProviderFormValues,
|
||||
} from "@/components/providers/forms/ProviderForm";
|
||||
import { providersApi, vscodeApi, type AppId } from "@/lib/api";
|
||||
import { providersApi, vscodeApi, configApi, type AppId } from "@/lib/api";
|
||||
import { extractDifference, isPlainObject } from "@/utils/configMerge";
|
||||
import { extractTomlDifference } from "@/utils/tomlConfigMerge";
|
||||
import {
|
||||
parseGeminiCommonConfigSnippet,
|
||||
mapGeminiWarningToI18n,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
|
||||
interface EditProviderDialogProps {
|
||||
open: boolean;
|
||||
@@ -81,7 +88,103 @@ export function EditProviderDialog({
|
||||
appId,
|
||||
)) as Record<string, unknown>;
|
||||
if (!cancelled && live && typeof live === "object") {
|
||||
setLiveSettings(live);
|
||||
// 检查是否启用了通用配置
|
||||
const metaByApp = provider.meta?.commonConfigEnabledByApp;
|
||||
const commonConfigEnabled =
|
||||
metaByApp?.[appId] ??
|
||||
provider.meta?.commonConfigEnabled ??
|
||||
false;
|
||||
|
||||
if (commonConfigEnabled) {
|
||||
// 从 live 配置中提取自定义部分(去除通用配置)
|
||||
try {
|
||||
const commonSnippet =
|
||||
await configApi.getCommonConfigSnippet(appId);
|
||||
if (commonSnippet && commonSnippet.trim()) {
|
||||
if (appId === "codex") {
|
||||
// Codex: 处理 TOML 格式的 config 字段
|
||||
const liveConfig =
|
||||
(live as { auth?: unknown; config?: string }).config ??
|
||||
"";
|
||||
const { customToml, error } = extractTomlDifference(
|
||||
liveConfig,
|
||||
commonSnippet.trim(),
|
||||
);
|
||||
if (!error) {
|
||||
setLiveSettings({
|
||||
...live,
|
||||
config: customToml,
|
||||
});
|
||||
} else {
|
||||
setLiveSettings(live);
|
||||
}
|
||||
} else if (appId === "gemini") {
|
||||
// Gemini: common config supports three formats:
|
||||
// - ENV format: KEY=VALUE lines
|
||||
// - Flat JSON: {"KEY": "VALUE", ...}
|
||||
// - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
|
||||
const liveEnv =
|
||||
(live as { env?: Record<string, string> }).env ?? {};
|
||||
|
||||
// Use shared parser with validation
|
||||
const parseResult = parseGeminiCommonConfigSnippet(
|
||||
commonSnippet,
|
||||
{ strictForbiddenKeys: false },
|
||||
);
|
||||
|
||||
if (parseResult.error) {
|
||||
console.warn(
|
||||
"[EditProviderDialog] Gemini common config parse error:",
|
||||
parseResult.error,
|
||||
);
|
||||
setLiveSettings(live);
|
||||
} else {
|
||||
// Show warning toast if keys were filtered
|
||||
if (parseResult.warning) {
|
||||
toast.warning(
|
||||
mapGeminiWarningToI18n(parseResult.warning, t),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isPlainObject(liveEnv) &&
|
||||
Object.keys(parseResult.env).length > 0
|
||||
) {
|
||||
const { customConfig } = extractDifference(
|
||||
liveEnv,
|
||||
parseResult.env,
|
||||
);
|
||||
setLiveSettings({
|
||||
...live,
|
||||
env: customConfig,
|
||||
});
|
||||
} else {
|
||||
setLiveSettings(live);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Claude: 处理 JSON 格式
|
||||
const commonConfig = JSON.parse(commonSnippet.trim());
|
||||
if (isPlainObject(live) && isPlainObject(commonConfig)) {
|
||||
const { customConfig } = extractDifference(
|
||||
live,
|
||||
commonConfig,
|
||||
);
|
||||
setLiveSettings(customConfig);
|
||||
} else {
|
||||
setLiveSettings(live);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setLiveSettings(live);
|
||||
}
|
||||
} catch {
|
||||
// 提取失败时使用原始 live 配置
|
||||
setLiveSettings(live);
|
||||
}
|
||||
} else {
|
||||
setLiveSettings(live);
|
||||
}
|
||||
setHasLoadedLive(true);
|
||||
}
|
||||
} catch {
|
||||
@@ -105,7 +208,14 @@ export function EditProviderDialog({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, provider?.id, appId, hasLoadedLive, isProxyTakeover]); // 只依赖 provider.id,不依赖整个 provider 对象
|
||||
}, [
|
||||
open,
|
||||
provider?.id,
|
||||
provider?.meta,
|
||||
appId,
|
||||
hasLoadedLive,
|
||||
isProxyTakeover,
|
||||
]); // 添加 provider?.meta 依赖
|
||||
|
||||
const initialSettingsConfig = useMemo(() => {
|
||||
return (liveSettings ?? provider?.settingsConfig ?? {}) as Record<
|
||||
|
||||
@@ -3,14 +3,13 @@ import {
|
||||
Check,
|
||||
Copy,
|
||||
Edit,
|
||||
// Loader2, // Hidden: stream check feature disabled
|
||||
Loader2,
|
||||
Minus,
|
||||
Play,
|
||||
Plus,
|
||||
Terminal,
|
||||
// TestTube2, // Hidden: stream check feature disabled
|
||||
TestTube2,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -37,23 +36,20 @@ interface ProviderActionsProps {
|
||||
isAutoFailoverEnabled?: boolean;
|
||||
isInFailoverQueue?: boolean;
|
||||
onToggleFailover?: (enabled: boolean) => void;
|
||||
// OpenClaw: default model
|
||||
isDefaultModel?: boolean;
|
||||
onSetAsDefault?: () => void;
|
||||
}
|
||||
|
||||
export function ProviderActions({
|
||||
appId,
|
||||
isCurrent,
|
||||
isInConfig = false,
|
||||
isTesting: _isTesting, // Hidden: stream check feature disabled
|
||||
isTesting,
|
||||
isProxyTakeover = false,
|
||||
isOmo = false,
|
||||
isLastOmo = false,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onTest: _onTest, // Hidden: stream check feature disabled
|
||||
onTest,
|
||||
onConfigureUsage,
|
||||
onDelete,
|
||||
onRemoveFromConfig,
|
||||
@@ -62,20 +58,14 @@ export function ProviderActions({
|
||||
isAutoFailoverEnabled = false,
|
||||
isInFailoverQueue = false,
|
||||
onToggleFailover,
|
||||
// OpenClaw: default model
|
||||
isDefaultModel = false,
|
||||
onSetAsDefault,
|
||||
}: ProviderActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const iconButtonClass = "h-8 w-8 p-1";
|
||||
|
||||
// 累加模式应用(OpenCode 非 OMO 和 OpenClaw)
|
||||
const isAdditiveMode =
|
||||
(appId === "opencode" && !isOmo) || appId === "openclaw";
|
||||
const isOpenCodeMode = appId === "opencode" && !isOmo;
|
||||
|
||||
// 故障转移模式下的按钮逻辑(累加模式和 OMO 应用不支持故障转移)
|
||||
const isFailoverMode =
|
||||
!isAdditiveMode && !isOmo && isAutoFailoverEnabled && onToggleFailover;
|
||||
!isOpenCodeMode && !isOmo && isAutoFailoverEnabled && onToggleFailover;
|
||||
|
||||
const handleMainButtonClick = () => {
|
||||
if (isOmo) {
|
||||
@@ -84,8 +74,7 @@ export function ProviderActions({
|
||||
} else {
|
||||
onSwitch();
|
||||
}
|
||||
} else if (isAdditiveMode) {
|
||||
// 累加模式:切换配置状态(添加/移除)
|
||||
} else if (isOpenCodeMode) {
|
||||
if (isInConfig) {
|
||||
if (onRemoveFromConfig) {
|
||||
onRemoveFromConfig();
|
||||
@@ -123,16 +112,13 @@ export function ProviderActions({
|
||||
};
|
||||
}
|
||||
|
||||
// 累加模式(OpenCode 非 OMO / OpenClaw)
|
||||
if (isAdditiveMode) {
|
||||
if (isOpenCodeMode) {
|
||||
if (isInConfig) {
|
||||
return {
|
||||
disabled: isDefaultModel === true,
|
||||
disabled: false,
|
||||
variant: "secondary" as const,
|
||||
className: cn(
|
||||
className:
|
||||
"bg-orange-100 text-orange-600 hover:bg-orange-200 dark:bg-orange-900/50 dark:text-orange-400 dark:hover:bg-orange-900/70",
|
||||
isDefaultModel && "opacity-40 cursor-not-allowed",
|
||||
),
|
||||
icon: <Minus className="h-4 w-4" />,
|
||||
text: t("provider.removeFromConfig", { defaultValue: "移除" }),
|
||||
};
|
||||
@@ -194,32 +180,12 @@ export function ProviderActions({
|
||||
|
||||
const canDelete = isOmo
|
||||
? !(isLastOmo && isCurrent)
|
||||
: isAdditiveMode
|
||||
: isOpenCodeMode
|
||||
? true
|
||||
: !isCurrent;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{appId === "openclaw" && isInConfig && onSetAsDefault && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isDefaultModel ? "secondary" : "default"}
|
||||
onClick={isDefaultModel ? undefined : onSetAsDefault}
|
||||
disabled={isDefaultModel}
|
||||
className={cn(
|
||||
"w-fit 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}
|
||||
@@ -252,7 +218,6 @@ export function ProviderActions({
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Hidden: stream check feature disabled
|
||||
{onTest && (
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -269,7 +234,6 @@ export function ProviderActions({
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
*/}
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
|
||||
@@ -29,14 +29,11 @@ interface ProviderCardProps {
|
||||
isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json
|
||||
isOmo?: boolean;
|
||||
isLastOmo?: boolean;
|
||||
isOmoSlim?: boolean;
|
||||
isLastOmoSlim?: boolean;
|
||||
onSwitch: (provider: Provider) => void;
|
||||
onEdit: (provider: Provider) => void;
|
||||
onDelete: (provider: Provider) => void;
|
||||
onRemoveFromConfig?: (provider: Provider) => void;
|
||||
onDisableOmo?: () => void;
|
||||
onDisableOmoSlim?: () => void;
|
||||
onConfigureUsage: (provider: Provider) => void;
|
||||
onOpenWebsite: (url: string) => void;
|
||||
onDuplicate: (provider: Provider) => void;
|
||||
@@ -51,9 +48,6 @@ interface ProviderCardProps {
|
||||
isInFailoverQueue?: boolean; // 是否在故障转移队列中
|
||||
onToggleFailover?: (enabled: boolean) => void; // 切换故障转移队列
|
||||
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
|
||||
// OpenClaw: default model
|
||||
isDefaultModel?: boolean;
|
||||
onSetAsDefault?: () => void;
|
||||
}
|
||||
|
||||
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
||||
@@ -95,14 +89,11 @@ export function ProviderCard({
|
||||
isInConfig = true,
|
||||
isOmo = false,
|
||||
isLastOmo = false,
|
||||
isOmoSlim = false,
|
||||
isLastOmoSlim = false,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRemoveFromConfig,
|
||||
onDisableOmo,
|
||||
onDisableOmoSlim,
|
||||
onConfigureUsage,
|
||||
onOpenWebsite,
|
||||
onDuplicate,
|
||||
@@ -117,17 +108,9 @@ export function ProviderCard({
|
||||
isInFailoverQueue = false,
|
||||
onToggleFailover,
|
||||
activeProviderId,
|
||||
// OpenClaw: default model
|
||||
isDefaultModel,
|
||||
onSetAsDefault,
|
||||
}: ProviderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// OMO and OMO Slim share the same card behavior
|
||||
const isAnyOmo = isOmo || isOmoSlim;
|
||||
const isLastAnyOmo = isOmo ? isLastOmo : isLastOmoSlim;
|
||||
const handleDisableAnyOmo = isOmoSlim ? onDisableOmoSlim : onDisableOmo;
|
||||
|
||||
const { data: health } = useProviderHealth(provider.id, appId);
|
||||
|
||||
const fallbackUrlText = t("provider.notConfigured", {
|
||||
@@ -150,10 +133,7 @@ export function ProviderCard({
|
||||
|
||||
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
||||
|
||||
// 获取用量数据以判断是否有多套餐
|
||||
// 累加模式应用(OpenCode/OpenClaw):使用 isInConfig 代替 isCurrent
|
||||
const shouldAutoQuery =
|
||||
appId === "opencode" || appId === "openclaw" ? isInConfig : isCurrent;
|
||||
const shouldAutoQuery = appId === "opencode" ? isInConfig : isCurrent;
|
||||
const autoQueryInterval = shouldAutoQuery
|
||||
? provider.meta?.usage_script?.autoQueryInterval || 0
|
||||
: 0;
|
||||
@@ -196,23 +176,18 @@ export function ProviderCard({
|
||||
onOpenWebsite(displayUrl);
|
||||
};
|
||||
|
||||
// 判断是否是"当前使用中"的供应商
|
||||
// - OMO/OMO Slim 供应商:使用 isCurrent
|
||||
// - 累加模式应用(OpenCode 非 OMO / OpenClaw):不存在"当前"概念,始终返回 false
|
||||
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
||||
// - 普通模式:isCurrent
|
||||
const isActiveProvider = isAnyOmo
|
||||
const isActiveProvider = isOmo
|
||||
? isCurrent
|
||||
: appId === "opencode" || appId === "openclaw"
|
||||
: appId === "opencode"
|
||||
? false
|
||||
: isAutoFailoverEnabled
|
||||
? activeProviderId === provider.id
|
||||
: isCurrent;
|
||||
|
||||
const shouldUseGreen = !isAnyOmo && isProxyTakeover && isActiveProvider;
|
||||
const shouldUseGreen = !isOmo && isProxyTakeover && isActiveProvider;
|
||||
const shouldUseBlue =
|
||||
(isAnyOmo && isActiveProvider) ||
|
||||
(!isAnyOmo && !isProxyTakeover && isActiveProvider);
|
||||
(isOmo && isActiveProvider) ||
|
||||
(!isOmo && !isProxyTakeover && isActiveProvider);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -276,12 +251,6 @@ export function ProviderCard({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isOmoSlim && (
|
||||
<span className="inline-flex items-center rounded-md bg-indigo-100 px-1.5 py-0.5 text-[10px] font-semibold text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
|
||||
Slim
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isProxyRunning && isInFailoverQueue && health && (
|
||||
<ProviderHealthBadge
|
||||
consecutiveFailures={health.consecutive_failures}
|
||||
@@ -389,8 +358,8 @@ export function ProviderCard({
|
||||
isInConfig={isInConfig}
|
||||
isTesting={isTesting}
|
||||
isProxyTakeover={isProxyTakeover}
|
||||
isOmo={isAnyOmo}
|
||||
isLastOmo={isLastAnyOmo}
|
||||
isOmo={isOmo}
|
||||
isLastOmo={isLastOmo}
|
||||
onSwitch={() => onSwitch(provider)}
|
||||
onEdit={() => onEdit(provider)}
|
||||
onDuplicate={() => onDuplicate(provider)}
|
||||
@@ -402,16 +371,13 @@ export function ProviderCard({
|
||||
? () => onRemoveFromConfig(provider)
|
||||
: undefined
|
||||
}
|
||||
onDisableOmo={handleDisableAnyOmo}
|
||||
onDisableOmo={onDisableOmo}
|
||||
onOpenTerminal={
|
||||
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
|
||||
}
|
||||
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||||
isInFailoverQueue={isInFailoverQueue}
|
||||
onToggleFailover={onToggleFailover}
|
||||
// OpenClaw: default model
|
||||
isDefaultModel={isDefaultModel}
|
||||
onSetAsDefault={onSetAsDefault}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { Download, Users } from "lucide-react";
|
||||
import { Users } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ProviderEmptyStateProps {
|
||||
onCreate?: () => void;
|
||||
onImport?: () => void;
|
||||
}
|
||||
|
||||
export function ProviderEmptyState({
|
||||
onCreate,
|
||||
onImport,
|
||||
}: ProviderEmptyStateProps) {
|
||||
export function ProviderEmptyState({ onCreate }: ProviderEmptyStateProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -22,19 +18,11 @@ export function ProviderEmptyState({
|
||||
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
||||
{t("provider.noProvidersDescription")}
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
{onImport && (
|
||||
<Button onClick={onImport}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t("provider.importCurrent")}
|
||||
</Button>
|
||||
)}
|
||||
{onCreate && (
|
||||
<Button variant={onImport ? "outline" : "default"} onClick={onCreate}>
|
||||
{t("provider.addProvider")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{onCreate && (
|
||||
<Button className="mt-6" onClick={onCreate}>
|
||||
{t("provider.addProvider")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,17 +15,11 @@ import {
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
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 {
|
||||
@@ -34,12 +28,7 @@ import {
|
||||
useAddToFailoverQueue,
|
||||
useRemoveFromFailoverQueue,
|
||||
} from "@/lib/query/failover";
|
||||
import {
|
||||
useCurrentOmoProviderId,
|
||||
useOmoProviderCount,
|
||||
useCurrentOmoSlimProviderId,
|
||||
useOmoSlimProviderCount,
|
||||
} from "@/lib/query/omo";
|
||||
import { useCurrentOmoProviderId, useOmoProviderCount } from "@/lib/query/omo";
|
||||
import { useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -53,7 +42,6 @@ interface ProviderListProps {
|
||||
onDelete: (provider: Provider) => void;
|
||||
onRemoveFromConfig?: (provider: Provider) => void;
|
||||
onDisableOmo?: () => void;
|
||||
onDisableOmoSlim?: () => void;
|
||||
onDuplicate: (provider: Provider) => void;
|
||||
onConfigureUsage?: (provider: Provider) => void;
|
||||
onOpenWebsite: (url: string) => void;
|
||||
@@ -63,7 +51,6 @@ interface ProviderListProps {
|
||||
isProxyRunning?: boolean; // 代理服务运行状态
|
||||
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管)
|
||||
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
|
||||
onSetAsDefault?: (provider: Provider) => void; // OpenClaw: set as default model
|
||||
}
|
||||
|
||||
export function ProviderList({
|
||||
@@ -75,7 +62,6 @@ export function ProviderList({
|
||||
onDelete,
|
||||
onRemoveFromConfig,
|
||||
onDisableOmo,
|
||||
onDisableOmoSlim,
|
||||
onDuplicate,
|
||||
onConfigureUsage,
|
||||
onOpenWebsite,
|
||||
@@ -85,7 +71,6 @@ export function ProviderList({
|
||||
isProxyRunning = false,
|
||||
isProxyTakeover = false,
|
||||
activeProviderId,
|
||||
onSetAsDefault,
|
||||
}: ProviderListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
|
||||
@@ -99,39 +84,14 @@ export function ProviderList({
|
||||
enabled: appId === "opencode",
|
||||
});
|
||||
|
||||
// OpenClaw: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig
|
||||
const { data: openclawLiveIds } = useOpenClawLiveProviderIds(
|
||||
appId === "openclaw",
|
||||
);
|
||||
|
||||
// 判断供应商是否已添加到配置(累加模式应用:OpenCode/OpenClaw)
|
||||
const isProviderInConfig = useCallback(
|
||||
(providerId: string): boolean => {
|
||||
if (appId === "opencode") {
|
||||
return opencodeLiveIds?.includes(providerId) ?? false;
|
||||
}
|
||||
if (appId === "openclaw") {
|
||||
return openclawLiveIds?.includes(providerId) ?? false;
|
||||
}
|
||||
return true; // 其他应用始终返回 true
|
||||
if (appId !== "opencode") return true; // 非 OpenCode 应用始终返回 true
|
||||
return opencodeLiveIds?.includes(providerId) ?? false;
|
||||
},
|
||||
[appId, opencodeLiveIds, openclawLiveIds],
|
||||
[appId, opencodeLiveIds],
|
||||
);
|
||||
|
||||
// OpenClaw: query default model to determine which provider is default
|
||||
const { data: openclawDefaultModel } = useOpenClawDefaultModel(
|
||||
appId === "openclaw",
|
||||
);
|
||||
|
||||
const isProviderDefaultModel = useCallback(
|
||||
(providerId: string): boolean => {
|
||||
if (appId !== "openclaw" || !openclawDefaultModel?.primary) return false;
|
||||
return openclawDefaultModel.primary.startsWith(providerId + "/");
|
||||
},
|
||||
[appId, openclawDefaultModel],
|
||||
);
|
||||
|
||||
// 故障转移相关
|
||||
const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId);
|
||||
const { data: failoverQueue } = useFailoverQueue(appId);
|
||||
const addToQueue = useAddToFailoverQueue();
|
||||
@@ -143,8 +103,6 @@ export function ProviderList({
|
||||
const isOpenCode = appId === "opencode";
|
||||
const { data: currentOmoId } = useCurrentOmoProviderId(isOpenCode);
|
||||
const { data: omoProviderCount } = useOmoProviderCount(isOpenCode);
|
||||
const { data: currentOmoSlimId } = useCurrentOmoSlimProviderId(isOpenCode);
|
||||
const { data: omoSlimProviderCount } = useOmoSlimProviderCount(isOpenCode);
|
||||
|
||||
const getFailoverPriority = useCallback(
|
||||
(providerId: string): number | undefined => {
|
||||
@@ -180,23 +138,6 @@ export function ProviderList({
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Import current live config as default provider
|
||||
const queryClient = useQueryClient();
|
||||
const importMutation = useMutation({
|
||||
mutationFn: () => providersApi.importDefault(appId),
|
||||
onSuccess: (imported) => {
|
||||
if (imported) {
|
||||
queryClient.invalidateQueries({ queryKey: ["providers", appId] });
|
||||
toast.success(t("provider.importCurrentDescription"));
|
||||
} else {
|
||||
toast.info(t("provider.noProviders"));
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
@@ -249,17 +190,8 @@ export function ProviderList({
|
||||
);
|
||||
}
|
||||
|
||||
// Only show import button for standard apps (not additive-mode apps like OpenCode/OpenClaw)
|
||||
const showImportButton =
|
||||
appId === "claude" || appId === "codex" || appId === "gemini";
|
||||
|
||||
if (sortedProviders.length === 0) {
|
||||
return (
|
||||
<ProviderEmptyState
|
||||
onCreate={onCreate}
|
||||
onImport={showImportButton ? () => importMutation.mutate() : undefined}
|
||||
/>
|
||||
);
|
||||
return <ProviderEmptyState onCreate={onCreate} />;
|
||||
}
|
||||
|
||||
const renderProviderList = () => (
|
||||
@@ -275,20 +207,13 @@ export function ProviderList({
|
||||
<div className="space-y-3">
|
||||
{filteredProviders.map((provider) => {
|
||||
const isOmo = provider.category === "omo";
|
||||
const isOmoSlim = provider.category === "omo-slim";
|
||||
const isOmoCurrent = isOmo && provider.id === (currentOmoId || "");
|
||||
const isOmoSlimCurrent =
|
||||
isOmoSlim && provider.id === (currentOmoSlimId || "");
|
||||
return (
|
||||
<SortableProviderCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
isCurrent={
|
||||
isOmo
|
||||
? isOmoCurrent
|
||||
: isOmoSlim
|
||||
? isOmoSlimCurrent
|
||||
: provider.id === currentProviderId
|
||||
isOmo ? isOmoCurrent : provider.id === currentProviderId
|
||||
}
|
||||
appId={appId}
|
||||
isInConfig={isProviderInConfig(provider.id)}
|
||||
@@ -296,18 +221,11 @@ export function ProviderList({
|
||||
isLastOmo={
|
||||
isOmo && (omoProviderCount ?? 0) <= 1 && isOmoCurrent
|
||||
}
|
||||
isOmoSlim={isOmoSlim}
|
||||
isLastOmoSlim={
|
||||
isOmoSlim &&
|
||||
(omoSlimProviderCount ?? 0) <= 1 &&
|
||||
isOmoSlimCurrent
|
||||
}
|
||||
onSwitch={onSwitch}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onRemoveFromConfig={onRemoveFromConfig}
|
||||
onDisableOmo={onDisableOmo}
|
||||
onDisableOmoSlim={onDisableOmoSlim}
|
||||
onDuplicate={onDuplicate}
|
||||
onConfigureUsage={onConfigureUsage}
|
||||
onOpenWebsite={onOpenWebsite}
|
||||
@@ -322,11 +240,6 @@ export function ProviderList({
|
||||
handleToggleFailover(provider.id, enabled)
|
||||
}
|
||||
activeProviderId={activeProviderId}
|
||||
// OpenClaw: default model
|
||||
isDefaultModel={isProviderDefaultModel(provider.id)}
|
||||
onSetAsDefault={
|
||||
onSetAsDefault ? () => onSetAsDefault(provider) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -421,14 +334,11 @@ interface SortableProviderCardProps {
|
||||
isInConfig: boolean;
|
||||
isOmo: boolean;
|
||||
isLastOmo: boolean;
|
||||
isOmoSlim: boolean;
|
||||
isLastOmoSlim: boolean;
|
||||
onSwitch: (provider: Provider) => void;
|
||||
onEdit: (provider: Provider) => void;
|
||||
onDelete: (provider: Provider) => void;
|
||||
onRemoveFromConfig?: (provider: Provider) => void;
|
||||
onDisableOmo?: () => void;
|
||||
onDisableOmoSlim?: () => void;
|
||||
onDuplicate: (provider: Provider) => void;
|
||||
onConfigureUsage?: (provider: Provider) => void;
|
||||
onOpenWebsite: (url: string) => void;
|
||||
@@ -442,9 +352,6 @@ interface SortableProviderCardProps {
|
||||
isInFailoverQueue: boolean;
|
||||
onToggleFailover: (enabled: boolean) => void;
|
||||
activeProviderId?: string;
|
||||
// OpenClaw: default model
|
||||
isDefaultModel?: boolean;
|
||||
onSetAsDefault?: () => void;
|
||||
}
|
||||
|
||||
function SortableProviderCard({
|
||||
@@ -454,14 +361,11 @@ function SortableProviderCard({
|
||||
isInConfig,
|
||||
isOmo,
|
||||
isLastOmo,
|
||||
isOmoSlim,
|
||||
isLastOmoSlim,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRemoveFromConfig,
|
||||
onDisableOmo,
|
||||
onDisableOmoSlim,
|
||||
onDuplicate,
|
||||
onConfigureUsage,
|
||||
onOpenWebsite,
|
||||
@@ -475,8 +379,6 @@ function SortableProviderCard({
|
||||
isInFailoverQueue,
|
||||
onToggleFailover,
|
||||
activeProviderId,
|
||||
isDefaultModel,
|
||||
onSetAsDefault,
|
||||
}: SortableProviderCardProps) {
|
||||
const {
|
||||
setNodeRef,
|
||||
@@ -501,14 +403,11 @@ function SortableProviderCard({
|
||||
isInConfig={isInConfig}
|
||||
isOmo={isOmo}
|
||||
isLastOmo={isLastOmo}
|
||||
isOmoSlim={isOmoSlim}
|
||||
isLastOmoSlim={isLastOmoSlim}
|
||||
onSwitch={onSwitch}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onRemoveFromConfig={onRemoveFromConfig}
|
||||
onDisableOmo={onDisableOmo}
|
||||
onDisableOmoSlim={onDisableOmoSlim}
|
||||
onDuplicate={onDuplicate}
|
||||
onConfigureUsage={
|
||||
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
||||
@@ -529,9 +428,6 @@ function SortableProviderCard({
|
||||
isInFailoverQueue={isInFailoverQueue}
|
||||
onToggleFailover={onToggleFailover}
|
||||
activeProviderId={activeProviderId}
|
||||
// OpenClaw: default model
|
||||
isDefaultModel={isDefaultModel}
|
||||
onSetAsDefault={onSetAsDefault}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,11 +10,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||
import { ApiKeySection, EndpointField } from "./shared";
|
||||
import type {
|
||||
ProviderCategory,
|
||||
ClaudeApiFormat,
|
||||
ClaudeApiKeyField,
|
||||
} from "@/types";
|
||||
import type { ProviderCategory, ClaudeApiFormat } from "@/types";
|
||||
import type { TemplateValueConfig } from "@/config/claudeProviderPresets";
|
||||
|
||||
interface EndpointCandidate {
|
||||
@@ -72,10 +68,6 @@ interface ClaudeFormFieldsProps {
|
||||
// API Format (for third-party providers that use OpenAI Chat Completions format)
|
||||
apiFormat: ClaudeApiFormat;
|
||||
onApiFormatChange: (format: ClaudeApiFormat) => void;
|
||||
|
||||
// Auth Key Field (ANTHROPIC_AUTH_TOKEN vs ANTHROPIC_API_KEY)
|
||||
apiKeyField: ClaudeApiKeyField;
|
||||
onApiKeyFieldChange: (field: ClaudeApiKeyField) => void;
|
||||
}
|
||||
|
||||
export function ClaudeFormFields({
|
||||
@@ -110,8 +102,6 @@ export function ClaudeFormFields({
|
||||
speedTestEndpoints,
|
||||
apiFormat,
|
||||
onApiFormatChange,
|
||||
apiKeyField,
|
||||
onApiKeyFieldChange,
|
||||
}: ClaudeFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -229,41 +219,6 @@ export function ClaudeFormFields({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 认证字段选择(仅非官方供应商显示) */}
|
||||
{shouldShowModelSelector && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="apiKeyField">
|
||||
{t("providerForm.authField", { defaultValue: "认证字段" })}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={apiKeyField}
|
||||
onValueChange={(v) => onApiKeyFieldChange(v as ClaudeApiKeyField)}
|
||||
>
|
||||
<SelectTrigger id="apiKeyField" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ANTHROPIC_AUTH_TOKEN">
|
||||
{t("providerForm.authFieldAuthToken", {
|
||||
defaultValue: "Auth Token (默认)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="ANTHROPIC_API_KEY">
|
||||
{t("providerForm.authFieldApiKey", {
|
||||
defaultValue: "API Key",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.authFieldHint", {
|
||||
defaultValue:
|
||||
"大多数第三方供应商使用 Auth Token;少数供应商需要 API Key",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 模型选择器 */}
|
||||
{shouldShowModelSelector && (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
type ToggleKey = "hideAttribution" | "alwaysThinking" | "enableTeammates";
|
||||
|
||||
interface ClaudeQuickTogglesProps {
|
||||
/** Called after a patch is applied to the live file, so the caller can mirror it in the JSON editor. */
|
||||
onPatchApplied?: (patch: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const defaultStates: Record<ToggleKey, boolean> = {
|
||||
hideAttribution: false,
|
||||
alwaysThinking: false,
|
||||
enableTeammates: false,
|
||||
};
|
||||
|
||||
function deriveStates(
|
||||
cfg: Record<string, unknown>,
|
||||
): Record<ToggleKey, boolean> {
|
||||
const env = cfg?.env as Record<string, unknown> | undefined;
|
||||
const attr = cfg?.attribution as Record<string, unknown> | undefined;
|
||||
return {
|
||||
hideAttribution: attr?.commit === "" && attr?.pr === "",
|
||||
alwaysThinking: cfg?.alwaysThinkingEnabled === true,
|
||||
enableTeammates: env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1",
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply RFC 7396 JSON Merge Patch in-place: null = delete, object = recurse, else overwrite. */
|
||||
function jsonMergePatch(
|
||||
target: Record<string, unknown>,
|
||||
patch: Record<string, unknown>,
|
||||
) {
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null || value === undefined) {
|
||||
delete target[key];
|
||||
} else if (typeof value === "object" && !Array.isArray(value)) {
|
||||
if (
|
||||
typeof target[key] !== "object" ||
|
||||
target[key] === null ||
|
||||
Array.isArray(target[key])
|
||||
) {
|
||||
target[key] = {};
|
||||
}
|
||||
jsonMergePatch(
|
||||
target[key] as Record<string, unknown>,
|
||||
value as Record<string, unknown>,
|
||||
);
|
||||
if (Object.keys(target[key] as Record<string, unknown>).length === 0) {
|
||||
delete target[key];
|
||||
}
|
||||
} else {
|
||||
target[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { jsonMergePatch };
|
||||
|
||||
export function ClaudeQuickToggles({
|
||||
onPatchApplied,
|
||||
}: ClaudeQuickTogglesProps) {
|
||||
const { t } = useTranslation();
|
||||
const [states, setStates] = useState(defaultStates);
|
||||
|
||||
const readLive = useCallback(async () => {
|
||||
try {
|
||||
const cfg = await invoke<Record<string, unknown>>(
|
||||
"read_live_provider_settings",
|
||||
{ app: "claude" },
|
||||
);
|
||||
setStates(deriveStates(cfg));
|
||||
} catch {
|
||||
// Live file missing or unreadable — show all unchecked
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
readLive();
|
||||
}, [readLive]);
|
||||
|
||||
const toggle = useCallback(
|
||||
async (key: ToggleKey) => {
|
||||
let patch: Record<string, unknown>;
|
||||
if (key === "hideAttribution") {
|
||||
patch = states.hideAttribution
|
||||
? { attribution: null }
|
||||
: { attribution: { commit: "", pr: "" } };
|
||||
} else if (key === "alwaysThinking") {
|
||||
patch = states.alwaysThinking
|
||||
? { alwaysThinkingEnabled: null }
|
||||
: { alwaysThinkingEnabled: true };
|
||||
} else {
|
||||
patch = states.enableTeammates
|
||||
? { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: null } }
|
||||
: { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: "1" } };
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
setStates((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
try {
|
||||
await invoke("patch_claude_live_settings", { patch });
|
||||
onPatchApplied?.(patch);
|
||||
} catch {
|
||||
// Revert on failure
|
||||
readLive();
|
||||
}
|
||||
},
|
||||
[states, readLive, onPatchApplied],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{(
|
||||
[
|
||||
["hideAttribution", "claudeConfig.hideAttribution"],
|
||||
["alwaysThinking", "claudeConfig.alwaysThinking"],
|
||||
["enableTeammates", "claudeConfig.enableTeammates"],
|
||||
] as const
|
||||
).map(([key, i18nKey]) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-1.5 text-sm cursor-pointer"
|
||||
>
|
||||
<Checkbox checked={states[key]} onCheckedChange={() => toggle(key)} />
|
||||
{t(i18nKey)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Save, Download, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
|
||||
interface CodexCommonConfigModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
onExtract?: () => void;
|
||||
isExtracting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CodexCommonConfigModal - Common Codex configuration editor modal
|
||||
* Allows editing of common TOML configuration shared across providers
|
||||
*/
|
||||
export const CodexCommonConfigModal: React.FC<CodexCommonConfigModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
onExtract,
|
||||
isExtracting,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
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();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FullScreenPanel
|
||||
isOpen={isOpen}
|
||||
title={t("codexConfig.editCommonConfigTitle")}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
{onExtract && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onExtract}
|
||||
disabled={isExtracting}
|
||||
className="gap-2"
|
||||
>
|
||||
{isExtracting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-4 h-4" />
|
||||
)}
|
||||
{t("codexConfig.extractFromCurrent", {
|
||||
defaultValue: "从编辑内容提取",
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={onClose} className="gap-2">
|
||||
<Save className="w-4 h-4" />
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("codexConfig.commonConfigHint")}
|
||||
</p>
|
||||
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={`# Common Codex config
|
||||
|
||||
# Add your common TOML configuration here`}
|
||||
darkMode={isDarkMode}
|
||||
rows={16}
|
||||
showValidation={false}
|
||||
language="javascript"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</FullScreenPanel>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { CodexAuthSection, CodexConfigSection } from "./CodexConfigSections";
|
||||
import { CodexCommonConfigModal } from "./CodexCommonConfigModal";
|
||||
|
||||
interface CodexConfigEditorProps {
|
||||
authValue: string;
|
||||
@@ -12,9 +13,26 @@ interface CodexConfigEditorProps {
|
||||
|
||||
onAuthBlur?: () => void;
|
||||
|
||||
useCommonConfig: boolean;
|
||||
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
|
||||
commonConfigSnippet: string;
|
||||
|
||||
onCommonConfigSnippetChange: (value: string) => void;
|
||||
|
||||
commonConfigError: string;
|
||||
|
||||
authError: string;
|
||||
|
||||
configError: string;
|
||||
configError: string; // config.toml 错误提示
|
||||
|
||||
onExtract?: () => void;
|
||||
|
||||
isExtracting?: boolean;
|
||||
|
||||
/** 最终合并后的配置(只读预览) */
|
||||
finalConfig?: string;
|
||||
}
|
||||
|
||||
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||
@@ -23,9 +41,26 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||
onAuthChange,
|
||||
onConfigChange,
|
||||
onAuthBlur,
|
||||
useCommonConfig,
|
||||
onCommonConfigToggle,
|
||||
commonConfigSnippet,
|
||||
onCommonConfigSnippetChange,
|
||||
commonConfigError,
|
||||
authError,
|
||||
configError,
|
||||
onExtract,
|
||||
isExtracting,
|
||||
finalConfig,
|
||||
}) => {
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
// Auto-open common config modal if there's an error
|
||||
useEffect(() => {
|
||||
if (commonConfigError && !isCommonConfigModalOpen) {
|
||||
setIsCommonConfigModalOpen(true);
|
||||
}
|
||||
}, [commonConfigError, isCommonConfigModalOpen]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Auth JSON Section */}
|
||||
@@ -40,7 +75,23 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||
<CodexConfigSection
|
||||
value={configValue}
|
||||
onChange={onConfigChange}
|
||||
useCommonConfig={useCommonConfig}
|
||||
onCommonConfigToggle={onCommonConfigToggle}
|
||||
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
|
||||
commonConfigError={commonConfigError}
|
||||
configError={configError}
|
||||
finalConfig={finalConfig}
|
||||
/>
|
||||
|
||||
{/* Common Config Modal */}
|
||||
<CodexCommonConfigModal
|
||||
isOpen={isCommonConfigModalOpen}
|
||||
onClose={() => setIsCommonConfigModalOpen(false)}
|
||||
value={commonConfigSnippet}
|
||||
onChange={onCommonConfigSnippetChange}
|
||||
error={commonConfigError}
|
||||
onExtract={onExtract}
|
||||
isExtracting={isExtracting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useDarkMode } from "@/hooks/useDarkMode";
|
||||
|
||||
interface CodexAuthSectionProps {
|
||||
value: string;
|
||||
@@ -19,22 +22,7 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
|
||||
error,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
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();
|
||||
}, []);
|
||||
const isDarkMode = useDarkMode();
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
onChange(newValue);
|
||||
@@ -57,7 +45,8 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
|
||||
onChange={handleChange}
|
||||
placeholder={t("codexConfig.authJsonPlaceholder")}
|
||||
darkMode={isDarkMode}
|
||||
rows={6}
|
||||
rows={3}
|
||||
autoHeight={true}
|
||||
showValidation={true}
|
||||
language="json"
|
||||
/>
|
||||
@@ -78,7 +67,13 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
|
||||
interface CodexConfigSectionProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
useCommonConfig: boolean;
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
onEditCommonConfig: () => void;
|
||||
commonConfigError?: string;
|
||||
configError?: string;
|
||||
/** 最终合并后的配置(只读预览) */
|
||||
finalConfig?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,44 +82,138 @@ interface CodexConfigSectionProps {
|
||||
export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
useCommonConfig,
|
||||
onCommonConfigToggle,
|
||||
onEditCommonConfig,
|
||||
commonConfigError,
|
||||
configError,
|
||||
finalConfig,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const isDarkMode = useDarkMode();
|
||||
const [showPreview, setShowPreview] = 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();
|
||||
}, []);
|
||||
if (useCommonConfig && finalConfig) {
|
||||
setShowPreview(true);
|
||||
}
|
||||
}, [useCommonConfig, finalConfig]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="codexConfig"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
{t("codexConfig.configToml")}
|
||||
</label>
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="codexConfig"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
{t("codexConfig.configToml")}
|
||||
</label>
|
||||
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder=""
|
||||
darkMode={isDarkMode}
|
||||
rows={8}
|
||||
showValidation={false}
|
||||
language="javascript"
|
||||
/>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useCommonConfig}
|
||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
{t("codexConfig.writeCommonConfig")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{useCommonConfig && finalConfig && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="inline-flex items-center gap-1 text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{showPreview ? (
|
||||
<>
|
||||
<EyeOff className="w-3 h-3" />
|
||||
{t("codexConfig.hidePreview", {
|
||||
defaultValue: "隐藏合并预览",
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-3 h-3" />
|
||||
{t("codexConfig.showPreview", {
|
||||
defaultValue: "显示合并预览",
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditCommonConfig}
|
||||
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{t("codexConfig.editCommonConfig")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{commonConfigError && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 自定义配置编辑器 */}
|
||||
<div className="space-y-1">
|
||||
{useCommonConfig && showPreview && (
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t("codexConfig.customConfig", {
|
||||
defaultValue: "自定义配置(覆盖通用配置)",
|
||||
})}
|
||||
</Label>
|
||||
)}
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder=""
|
||||
darkMode={isDarkMode}
|
||||
rows={useCommonConfig && showPreview ? 3 : 8}
|
||||
autoHeight={useCommonConfig && showPreview}
|
||||
showValidation={false}
|
||||
language="javascript"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 合并预览(只读)- 放在自定义配置下面 */}
|
||||
{useCommonConfig && showPreview && finalConfig && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t("codexConfig.mergedPreview", {
|
||||
defaultValue: "合并预览(只读)",
|
||||
})}
|
||||
</Label>
|
||||
<span className="text-xs text-green-500 dark:text-green-400">
|
||||
{t("codexConfig.mergedPreviewHint", {
|
||||
defaultValue: "通用配置 + 自定义配置 = 最终配置",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<JsonEditor
|
||||
value={finalConfig}
|
||||
onChange={() => {}} // 只读
|
||||
darkMode={isDarkMode}
|
||||
rows={6}
|
||||
showValidation={false}
|
||||
language="javascript"
|
||||
readOnly={true}
|
||||
/>
|
||||
<div className="absolute top-2 right-2 px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 text-xs rounded">
|
||||
{t("common.readonly", { defaultValue: "只读" })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{configError && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{configError}</p>
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Save, Download, Loader2, Eye, EyeOff } from "lucide-react";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
import { useDarkMode } from "@/hooks/useDarkMode";
|
||||
|
||||
interface CommonConfigEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
useCommonConfig: boolean;
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
commonConfigSnippet: string;
|
||||
onCommonConfigSnippetChange: (value: string) => void;
|
||||
commonConfigError: string;
|
||||
onEditClick: () => void;
|
||||
isModalOpen: boolean;
|
||||
onModalClose: () => void;
|
||||
onExtract?: () => void;
|
||||
isExtracting?: boolean;
|
||||
/** 最终合并后的配置(只读预览) */
|
||||
finalConfig?: string;
|
||||
}
|
||||
|
||||
export function CommonConfigEditor({
|
||||
value,
|
||||
onChange,
|
||||
useCommonConfig,
|
||||
onCommonConfigToggle,
|
||||
commonConfigSnippet,
|
||||
onCommonConfigSnippetChange,
|
||||
commonConfigError,
|
||||
onEditClick,
|
||||
isModalOpen,
|
||||
onModalClose,
|
||||
onExtract,
|
||||
isExtracting,
|
||||
finalConfig,
|
||||
}: CommonConfigEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const isDarkMode = useDarkMode();
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
// 当启用通用配置时,自动显示预览
|
||||
useEffect(() => {
|
||||
if (useCommonConfig && finalConfig) {
|
||||
setShowPreview(true);
|
||||
}
|
||||
}, [useCommonConfig, finalConfig]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="settingsConfig">{t("provider.configJson")}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="useCommonConfig"
|
||||
checked={useCommonConfig}
|
||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
<span>
|
||||
{t("claudeConfig.writeCommonConfig", {
|
||||
defaultValue: "写入通用配置",
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{useCommonConfig && finalConfig && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="inline-flex items-center gap-1 text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{showPreview ? (
|
||||
<>
|
||||
<EyeOff className="w-3 h-3" />
|
||||
{t("claudeConfig.hidePreview", {
|
||||
defaultValue: "隐藏合并预览",
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-3 h-3" />
|
||||
{t("claudeConfig.showPreview", {
|
||||
defaultValue: "显示合并预览",
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditClick}
|
||||
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{t("claudeConfig.editCommonConfig", {
|
||||
defaultValue: "编辑通用配置",
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
{commonConfigError && !isModalOpen && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 自定义配置编辑器 */}
|
||||
<div className="space-y-1">
|
||||
{useCommonConfig && showPreview && (
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t("claudeConfig.customConfig", {
|
||||
defaultValue: "自定义配置(覆盖通用配置)",
|
||||
})}
|
||||
</Label>
|
||||
)}
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={`{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
|
||||
}
|
||||
}`}
|
||||
darkMode={isDarkMode}
|
||||
rows={useCommonConfig && showPreview ? 3 : 14}
|
||||
autoHeight={useCommonConfig && showPreview}
|
||||
showValidation={true}
|
||||
language="json"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 合并预览(只读)- 放在自定义配置下面 */}
|
||||
{useCommonConfig && showPreview && finalConfig && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t("claudeConfig.mergedPreview", {
|
||||
defaultValue: "合并预览(只读)",
|
||||
})}
|
||||
</Label>
|
||||
<span className="text-xs text-green-500 dark:text-green-400">
|
||||
{t("claudeConfig.mergedPreviewHint", {
|
||||
defaultValue: "通用配置 + 自定义配置 = 最终配置",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<JsonEditor
|
||||
value={finalConfig}
|
||||
onChange={() => {}} // 只读
|
||||
darkMode={isDarkMode}
|
||||
rows={8}
|
||||
showValidation={false}
|
||||
language="json"
|
||||
readOnly={true}
|
||||
/>
|
||||
<div className="absolute top-2 right-2 px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 text-xs rounded">
|
||||
{t("common.readonly", { defaultValue: "只读" })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FullScreenPanel
|
||||
isOpen={isModalOpen}
|
||||
title={t("claudeConfig.editCommonConfigTitle", {
|
||||
defaultValue: "编辑通用配置片段",
|
||||
})}
|
||||
onClose={onModalClose}
|
||||
footer={
|
||||
<>
|
||||
{onExtract && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onExtract}
|
||||
disabled={isExtracting}
|
||||
className="gap-2"
|
||||
>
|
||||
{isExtracting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-4 h-4" />
|
||||
)}
|
||||
{t("claudeConfig.extractFromCurrent", {
|
||||
defaultValue: "从编辑内容提取",
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onModalClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={onModalClose} className="gap-2">
|
||||
<Save className="w-4 h-4" />
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("claudeConfig.commonConfigHint", {
|
||||
defaultValue: "通用配置片段将合并到所有启用它的供应商配置中",
|
||||
})}
|
||||
</p>
|
||||
<JsonEditor
|
||||
value={commonConfigSnippet}
|
||||
onChange={onCommonConfigSnippetChange}
|
||||
placeholder={`{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com"
|
||||
}
|
||||
}`}
|
||||
darkMode={isDarkMode}
|
||||
rows={16}
|
||||
showValidation={true}
|
||||
language="json"
|
||||
/>
|
||||
{commonConfigError && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FullScreenPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,6 @@ const ENDPOINT_TIMEOUT_SECS: Record<AppId, number> = {
|
||||
claude: 8,
|
||||
gemini: 8,
|
||||
opencode: 8,
|
||||
openclaw: 8,
|
||||
};
|
||||
|
||||
interface TestResult {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Save, Download, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
|
||||
interface GeminiCommonConfigModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
onExtract?: () => void;
|
||||
isExtracting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* GeminiCommonConfigModal - Common Gemini configuration editor modal
|
||||
* Allows editing of common env snippet shared across Gemini providers
|
||||
* Uses ENV format (KEY=VALUE) instead of JSON
|
||||
*/
|
||||
export const GeminiCommonConfigModal: React.FC<
|
||||
GeminiCommonConfigModalProps
|
||||
> = ({ isOpen, onClose, value, onChange, error, onExtract, isExtracting }) => {
|
||||
const { t } = useTranslation();
|
||||
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();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FullScreenPanel
|
||||
isOpen={isOpen}
|
||||
title={t("geminiConfig.editCommonConfigTitle", {
|
||||
defaultValue: "编辑 Gemini 通用配置片段",
|
||||
})}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
{onExtract && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onExtract}
|
||||
disabled={isExtracting}
|
||||
className="gap-2"
|
||||
>
|
||||
{isExtracting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-4 h-4" />
|
||||
)}
|
||||
{t("geminiConfig.extractFromCurrent", {
|
||||
defaultValue: "从编辑内容提取",
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={onClose} className="gap-2">
|
||||
<Save className="w-4 h-4" />
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("geminiConfig.commonConfigHint", {
|
||||
defaultValue:
|
||||
"该片段会写入 Gemini 的 .env(不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY)",
|
||||
})}
|
||||
</p>
|
||||
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={`# Gemini 通用配置
|
||||
# 格式: KEY=VALUE
|
||||
|
||||
GEMINI_MODEL=gemini-2.5-pro`}
|
||||
darkMode={isDarkMode}
|
||||
rows={16}
|
||||
showValidation={false}
|
||||
language="javascript"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</FullScreenPanel>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { GeminiEnvSection, GeminiConfigSection } from "./GeminiConfigSections";
|
||||
import { GeminiCommonConfigModal } from "./GeminiCommonConfigModal";
|
||||
|
||||
interface GeminiConfigEditorProps {
|
||||
envValue: string;
|
||||
@@ -7,8 +8,17 @@ interface GeminiConfigEditorProps {
|
||||
onEnvChange: (value: string) => void;
|
||||
onConfigChange: (value: string) => void;
|
||||
onEnvBlur?: () => void;
|
||||
useCommonConfig: boolean;
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
commonConfigSnippet: string;
|
||||
onCommonConfigSnippetChange: (value: string) => void;
|
||||
commonConfigError: string;
|
||||
envError: string;
|
||||
configError: string;
|
||||
onExtract?: () => void;
|
||||
isExtracting?: boolean;
|
||||
/** 最终合并后的 env 配置(只读预览) */
|
||||
finalEnv?: string;
|
||||
}
|
||||
|
||||
const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
||||
@@ -17,9 +27,26 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
||||
onEnvChange,
|
||||
onConfigChange,
|
||||
onEnvBlur,
|
||||
useCommonConfig,
|
||||
onCommonConfigToggle,
|
||||
commonConfigSnippet,
|
||||
onCommonConfigSnippetChange,
|
||||
commonConfigError,
|
||||
envError,
|
||||
configError,
|
||||
onExtract,
|
||||
isExtracting,
|
||||
finalEnv,
|
||||
}) => {
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
// Auto-open common config modal if there's an error
|
||||
useEffect(() => {
|
||||
if (commonConfigError && !isCommonConfigModalOpen) {
|
||||
setIsCommonConfigModalOpen(true);
|
||||
}
|
||||
}, [commonConfigError, isCommonConfigModalOpen]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Env Section */}
|
||||
@@ -28,6 +55,11 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
||||
onChange={onEnvChange}
|
||||
onBlur={onEnvBlur}
|
||||
error={envError}
|
||||
useCommonConfig={useCommonConfig}
|
||||
onCommonConfigToggle={onCommonConfigToggle}
|
||||
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
|
||||
commonConfigError={commonConfigError}
|
||||
finalEnv={finalEnv}
|
||||
/>
|
||||
|
||||
{/* Config JSON Section */}
|
||||
@@ -36,6 +68,17 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
||||
onChange={onConfigChange}
|
||||
configError={configError}
|
||||
/>
|
||||
|
||||
{/* Common Config Modal */}
|
||||
<GeminiCommonConfigModal
|
||||
isOpen={isCommonConfigModalOpen}
|
||||
onClose={() => setIsCommonConfigModalOpen(false)}
|
||||
value={commonConfigSnippet}
|
||||
onChange={onCommonConfigSnippetChange}
|
||||
error={commonConfigError}
|
||||
onExtract={onExtract}
|
||||
isExtracting={isExtracting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useDarkMode } from "@/hooks/useDarkMode";
|
||||
|
||||
interface GeminiEnvSectionProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
error?: string;
|
||||
useCommonConfig: boolean;
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
onEditCommonConfig: () => void;
|
||||
commonConfigError?: string;
|
||||
/** 最终合并后的 env 配置(只读预览) */
|
||||
finalEnv?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,24 +26,22 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
||||
onChange,
|
||||
onBlur,
|
||||
error,
|
||||
useCommonConfig,
|
||||
onCommonConfigToggle,
|
||||
onEditCommonConfig,
|
||||
commonConfigError,
|
||||
finalEnv,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const isDarkMode = useDarkMode();
|
||||
const [showPreview, setShowPreview] = 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();
|
||||
}, []);
|
||||
if (useCommonConfig && finalEnv) {
|
||||
setShowPreview(true);
|
||||
}
|
||||
}, [useCommonConfig, finalEnv]);
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
onChange(newValue);
|
||||
@@ -45,24 +52,124 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="geminiEnv"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
|
||||
</label>
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="geminiEnv"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
|
||||
</label>
|
||||
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={`GOOGLE_GEMINI_BASE_URL=https://your-api-endpoint.com/
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useCommonConfig}
|
||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
{t("geminiConfig.writeCommonConfig", {
|
||||
defaultValue: "写入通用配置",
|
||||
})}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{useCommonConfig && finalEnv && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="inline-flex items-center gap-1 text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{showPreview ? (
|
||||
<>
|
||||
<EyeOff className="w-3 h-3" />
|
||||
{t("geminiConfig.hidePreview", {
|
||||
defaultValue: "隐藏合并预览",
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-3 h-3" />
|
||||
{t("geminiConfig.showPreview", {
|
||||
defaultValue: "显示合并预览",
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditCommonConfig}
|
||||
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{t("geminiConfig.editCommonConfig", {
|
||||
defaultValue: "编辑通用配置",
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{commonConfigError && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 自定义配置编辑器 */}
|
||||
<div className="space-y-1">
|
||||
{useCommonConfig && showPreview && (
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t("geminiConfig.customConfig", {
|
||||
defaultValue: "自定义配置(覆盖通用配置)",
|
||||
})}
|
||||
</Label>
|
||||
)}
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={`GOOGLE_GEMINI_BASE_URL=https://your-api-endpoint.com/
|
||||
GEMINI_API_KEY=sk-your-api-key-here
|
||||
GEMINI_MODEL=gemini-3-pro-preview`}
|
||||
darkMode={isDarkMode}
|
||||
rows={6}
|
||||
showValidation={false}
|
||||
language="javascript"
|
||||
/>
|
||||
darkMode={isDarkMode}
|
||||
rows={useCommonConfig && showPreview ? 3 : 6}
|
||||
autoHeight={useCommonConfig && showPreview}
|
||||
showValidation={false}
|
||||
language="javascript"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 合并预览(只读)- 放在自定义配置下面 */}
|
||||
{useCommonConfig && showPreview && finalEnv && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t("geminiConfig.mergedPreview", {
|
||||
defaultValue: "合并预览(只读)",
|
||||
})}
|
||||
</Label>
|
||||
<span className="text-xs text-green-500 dark:text-green-400">
|
||||
{t("geminiConfig.mergedPreviewHint", {
|
||||
defaultValue: "通用配置 + 自定义配置 = 最终配置",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<JsonEditor
|
||||
value={finalEnv}
|
||||
onChange={() => {}} // 只读
|
||||
darkMode={isDarkMode}
|
||||
rows={4}
|
||||
showValidation={false}
|
||||
language="javascript"
|
||||
readOnly={true}
|
||||
/>
|
||||
<div className="absolute top-2 right-2 px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 text-xs rounded">
|
||||
{t("common.readonly", { defaultValue: "只读" })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
|
||||
@@ -94,22 +201,7 @@ export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
|
||||
configError,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
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();
|
||||
}, []);
|
||||
const isDarkMode = useDarkMode();
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -23,7 +23,6 @@ interface OmoCommonConfigEditorProps {
|
||||
onGlobalConfigStateChange: (config: OmoGlobalConfig) => void;
|
||||
globalConfigRef: React.RefObject<OmoGlobalConfigFieldsRef | null>;
|
||||
fieldsKey: number;
|
||||
isSlim?: boolean;
|
||||
}
|
||||
|
||||
export function OmoCommonConfigEditor({
|
||||
@@ -38,7 +37,6 @@ export function OmoCommonConfigEditor({
|
||||
onGlobalConfigStateChange,
|
||||
globalConfigRef,
|
||||
fieldsKey,
|
||||
isSlim = false,
|
||||
}: OmoCommonConfigEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
@@ -155,7 +153,6 @@ export function OmoCommonConfigEditor({
|
||||
ref={globalConfigRef as React.Ref<OmoGlobalConfigFieldsRef>}
|
||||
onStateChange={onGlobalConfigStateChange}
|
||||
hideSaveButtons
|
||||
isSlim={isSlim}
|
||||
/>
|
||||
</div>
|
||||
</FullScreenPanel>
|
||||
|
||||
@@ -41,11 +41,10 @@ import {
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { useReadOmoLocalFile, useReadOmoSlimLocalFile } from "@/lib/query/omo";
|
||||
import { useReadOmoLocalFile } from "@/lib/query/omo";
|
||||
import {
|
||||
OMO_BUILTIN_AGENTS,
|
||||
OMO_BUILTIN_CATEGORIES,
|
||||
OMO_SLIM_BUILTIN_AGENTS,
|
||||
type OmoAgentDef,
|
||||
type OmoCategoryDef,
|
||||
} from "@/types/omo";
|
||||
@@ -70,13 +69,12 @@ interface OmoFormFieldsProps {
|
||||
>;
|
||||
agents: Record<string, Record<string, unknown>>;
|
||||
onAgentsChange: (agents: Record<string, Record<string, unknown>>) => void;
|
||||
categories?: Record<string, Record<string, unknown>>;
|
||||
onCategoriesChange?: (
|
||||
categories: Record<string, Record<string, unknown>>;
|
||||
onCategoriesChange: (
|
||||
categories: Record<string, Record<string, unknown>>,
|
||||
) => void;
|
||||
otherFieldsStr: string;
|
||||
onOtherFieldsStrChange: (value: string) => void;
|
||||
isSlim?: boolean;
|
||||
}
|
||||
|
||||
export type CustomModelItem = {
|
||||
@@ -123,9 +121,6 @@ function DeferredKeyInput({
|
||||
}
|
||||
|
||||
const BUILTIN_AGENT_KEYS = new Set(OMO_BUILTIN_AGENTS.map((a) => a.key));
|
||||
const BUILTIN_AGENT_KEYS_SLIM = new Set(
|
||||
OMO_SLIM_BUILTIN_AGENTS.map((a) => a.key),
|
||||
);
|
||||
const BUILTIN_CATEGORY_KEYS = new Set(OMO_BUILTIN_CATEGORIES.map((c) => c.key));
|
||||
const EMPTY_VARIANT_VALUE = "__cc_switch_omo_variant_empty__";
|
||||
|
||||
@@ -308,21 +303,13 @@ export function OmoFormFields({
|
||||
presetMetaMap: _presetMetaMap = {},
|
||||
agents,
|
||||
onAgentsChange,
|
||||
categories = {},
|
||||
categories,
|
||||
onCategoriesChange,
|
||||
otherFieldsStr,
|
||||
onOtherFieldsStrChange,
|
||||
isSlim = false,
|
||||
}: OmoFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const builtinAgentDefs = isSlim
|
||||
? OMO_SLIM_BUILTIN_AGENTS
|
||||
: OMO_BUILTIN_AGENTS;
|
||||
const builtinAgentKeys = isSlim
|
||||
? BUILTIN_AGENT_KEYS_SLIM
|
||||
: BUILTIN_AGENT_KEYS;
|
||||
|
||||
const [mainAgentsOpen, setMainAgentsOpen] = useState(true);
|
||||
const [subAgentsOpen, setSubAgentsOpen] = useState(true);
|
||||
const [categoriesOpen, setCategoriesOpen] = useState(true);
|
||||
@@ -342,7 +329,7 @@ export function OmoFormFields({
|
||||
>({});
|
||||
|
||||
const [customAgents, setCustomAgents] = useState<CustomModelItem[]>(() =>
|
||||
collectCustomModels(agents, builtinAgentKeys),
|
||||
collectCustomModels(agents, BUILTIN_AGENT_KEYS),
|
||||
);
|
||||
|
||||
const [customCategories, setCustomCategories] = useState<CustomModelItem[]>(
|
||||
@@ -350,7 +337,7 @@ export function OmoFormFields({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCustomAgents(collectCustomModels(agents, builtinAgentKeys));
|
||||
setCustomAgents(collectCustomModels(agents, BUILTIN_AGENT_KEYS));
|
||||
}, [agents]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -362,18 +349,17 @@ export function OmoFormFields({
|
||||
onAgentsChange(
|
||||
mergeCustomModelsIntoStore(
|
||||
agents,
|
||||
builtinAgentKeys,
|
||||
BUILTIN_AGENT_KEYS,
|
||||
customs,
|
||||
modelVariantsMap,
|
||||
),
|
||||
);
|
||||
},
|
||||
[agents, onAgentsChange, modelVariantsMap, builtinAgentKeys],
|
||||
[agents, onAgentsChange, modelVariantsMap],
|
||||
);
|
||||
|
||||
const syncCustomCategories = useCallback(
|
||||
(customs: CustomModelItem[]) => {
|
||||
if (!onCategoriesChange) return;
|
||||
onCategoriesChange(
|
||||
mergeCustomModelsIntoStore(
|
||||
categories,
|
||||
@@ -723,7 +709,7 @@ export function OmoFormFields({
|
||||
}
|
||||
|
||||
const updatedAgents = { ...agents };
|
||||
for (const agentDef of builtinAgentDefs) {
|
||||
for (const agentDef of OMO_BUILTIN_AGENTS) {
|
||||
const recommendedValue = resolveRecommendedModel(agentDef.recommended);
|
||||
if (recommendedValue && !updatedAgents[agentDef.key]?.model) {
|
||||
updatedAgents[agentDef.key] = {
|
||||
@@ -734,35 +720,30 @@ export function OmoFormFields({
|
||||
}
|
||||
onAgentsChange(updatedAgents);
|
||||
|
||||
if (!isSlim && onCategoriesChange) {
|
||||
const updatedCategories = { ...categories };
|
||||
for (const catDef of OMO_BUILTIN_CATEGORIES) {
|
||||
const recommendedValue = resolveRecommendedModel(catDef.recommended);
|
||||
if (recommendedValue && !updatedCategories[catDef.key]?.model) {
|
||||
updatedCategories[catDef.key] = {
|
||||
...updatedCategories[catDef.key],
|
||||
model: recommendedValue,
|
||||
};
|
||||
}
|
||||
const updatedCategories = { ...categories };
|
||||
for (const catDef of OMO_BUILTIN_CATEGORIES) {
|
||||
const recommendedValue = resolveRecommendedModel(catDef.recommended);
|
||||
if (recommendedValue && !updatedCategories[catDef.key]?.model) {
|
||||
updatedCategories[catDef.key] = {
|
||||
...updatedCategories[catDef.key],
|
||||
model: recommendedValue,
|
||||
};
|
||||
}
|
||||
onCategoriesChange(updatedCategories);
|
||||
}
|
||||
onCategoriesChange(updatedCategories);
|
||||
};
|
||||
|
||||
const configuredAgentCount = Object.keys(agents).length;
|
||||
const configuredCategoryCount = isSlim ? 0 : Object.keys(categories).length;
|
||||
const mainAgents = builtinAgentDefs.filter((a) => a.group === "main");
|
||||
const subAgents = builtinAgentDefs.filter((a) => a.group === "sub");
|
||||
const configuredCategoryCount = Object.keys(categories).length;
|
||||
const mainAgents = OMO_BUILTIN_AGENTS.filter((a) => a.group === "main");
|
||||
const subAgents = OMO_BUILTIN_AGENTS.filter((a) => a.group === "sub");
|
||||
|
||||
const readLocalFile = useReadOmoLocalFile();
|
||||
const readSlimLocalFile = useReadOmoSlimLocalFile();
|
||||
const [localFilePath, setLocalFilePath] = useState<string | null>(null);
|
||||
|
||||
const handleImportFromLocal = useCallback(async () => {
|
||||
try {
|
||||
const data = isSlim
|
||||
? await readSlimLocalFile.mutateAsync()
|
||||
: await readLocalFile.mutateAsync();
|
||||
const data = await readLocalFile.mutateAsync();
|
||||
const importedAgents =
|
||||
(data.agents as Record<string, Record<string, unknown>> | undefined) ||
|
||||
{};
|
||||
@@ -772,20 +753,16 @@ export function OmoFormFields({
|
||||
| undefined) || {};
|
||||
|
||||
onAgentsChange(importedAgents);
|
||||
if (!isSlim && onCategoriesChange) {
|
||||
onCategoriesChange(importedCategories);
|
||||
}
|
||||
onCategoriesChange(importedCategories);
|
||||
onOtherFieldsStrChange(
|
||||
data.otherFields ? JSON.stringify(data.otherFields, null, 2) : "",
|
||||
);
|
||||
setAgentAdvancedDrafts({});
|
||||
setCategoryAdvancedDrafts({});
|
||||
setCustomAgents(collectCustomModels(importedAgents, builtinAgentKeys));
|
||||
if (!isSlim) {
|
||||
setCustomCategories(
|
||||
collectCustomModels(importedCategories, BUILTIN_CATEGORY_KEYS),
|
||||
);
|
||||
}
|
||||
setCustomAgents(collectCustomModels(importedAgents, BUILTIN_AGENT_KEYS));
|
||||
setCustomCategories(
|
||||
collectCustomModels(importedCategories, BUILTIN_CATEGORY_KEYS),
|
||||
);
|
||||
setLocalFilePath(data.filePath);
|
||||
toast.success(
|
||||
t("omo.importLocalReplaceSuccess", {
|
||||
@@ -815,7 +792,7 @@ export function OmoFormFields({
|
||||
) => {
|
||||
const isAgent = scope === "agent";
|
||||
const store = isAgent ? agents : categories;
|
||||
const setter = isAgent ? onAgentsChange : onCategoriesChange!;
|
||||
const setter = isAgent ? onAgentsChange : onCategoriesChange;
|
||||
const drafts = isAgent ? agentAdvancedDrafts : categoryAdvancedDrafts;
|
||||
const expanded = isAgent ? expandedAgents : expandedCategories;
|
||||
|
||||
@@ -889,7 +866,7 @@ export function OmoFormFields({
|
||||
) => {
|
||||
const isAgent = scope === "agent";
|
||||
const store = isAgent ? agents : categories;
|
||||
const setter = isAgent ? onAgentsChange : onCategoriesChange!;
|
||||
const setter = isAgent ? onAgentsChange : onCategoriesChange;
|
||||
const drafts = isAgent ? agentAdvancedDrafts : categoryAdvancedDrafts;
|
||||
const expanded = isAgent ? expandedAgents : expandedCategories;
|
||||
const customs = isAgent ? customAgents : customCategories;
|
||||
@@ -1176,31 +1153,30 @@ export function OmoFormFields({
|
||||
),
|
||||
})}
|
||||
|
||||
{!isSlim &&
|
||||
renderModelSection({
|
||||
title: t("omo.categories", { defaultValue: "Categories" }),
|
||||
isOpen: categoriesOpen,
|
||||
onToggle: () => setCategoriesOpen(!categoriesOpen),
|
||||
badge: `${OMO_BUILTIN_CATEGORIES.length + customCategories.length}`,
|
||||
action: renderCustomAddButton(() => addCustomModel("category")),
|
||||
children: (
|
||||
<>
|
||||
{OMO_BUILTIN_CATEGORIES.map(renderCategoryRow)}
|
||||
{customCategories.length > 0 && (
|
||||
<>
|
||||
{renderCustomDivider(
|
||||
t("omo.customCategories", {
|
||||
defaultValue: "Custom Categories",
|
||||
}),
|
||||
)}
|
||||
{customCategories.map((c, i) =>
|
||||
renderCustomModelRow("category", c, i),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
{renderModelSection({
|
||||
title: t("omo.categories", { defaultValue: "Categories" }),
|
||||
isOpen: categoriesOpen,
|
||||
onToggle: () => setCategoriesOpen(!categoriesOpen),
|
||||
badge: `${OMO_BUILTIN_CATEGORIES.length + customCategories.length}`,
|
||||
action: renderCustomAddButton(() => addCustomModel("category")),
|
||||
children: (
|
||||
<>
|
||||
{OMO_BUILTIN_CATEGORIES.map(renderCategoryRow)}
|
||||
{customCategories.length > 0 && (
|
||||
<>
|
||||
{renderCustomDivider(
|
||||
t("omo.customCategories", {
|
||||
defaultValue: "Custom Categories",
|
||||
}),
|
||||
)}
|
||||
{customCategories.map((c, i) =>
|
||||
renderCustomModelRow("category", c, i),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
|
||||
{renderModelSection({
|
||||
title: t("omo.otherFieldsJson", {
|
||||
|
||||
@@ -40,18 +40,11 @@ import {
|
||||
OMO_BACKGROUND_TASK_PLACEHOLDER,
|
||||
OMO_BROWSER_AUTOMATION_PLACEHOLDER,
|
||||
OMO_CLAUDE_CODE_PLACEHOLDER,
|
||||
OMO_SLIM_DISABLEABLE_AGENTS,
|
||||
OMO_SLIM_DISABLEABLE_MCPS,
|
||||
OMO_SLIM_DISABLEABLE_HOOKS,
|
||||
OMO_SLIM_DEFAULT_SCHEMA_URL,
|
||||
} from "@/types/omo";
|
||||
import {
|
||||
useOmoGlobalConfig,
|
||||
useSaveOmoGlobalConfig,
|
||||
useReadOmoLocalFile,
|
||||
useOmoSlimGlobalConfig,
|
||||
useSaveOmoSlimGlobalConfig,
|
||||
useReadOmoSlimLocalFile,
|
||||
} from "@/lib/query/omo";
|
||||
|
||||
interface PresetOption {
|
||||
@@ -68,7 +61,6 @@ export interface OmoGlobalConfigFieldsRef {
|
||||
interface OmoGlobalConfigFieldsProps {
|
||||
onStateChange?: (config: OmoGlobalConfig) => void;
|
||||
hideSaveButtons?: boolean;
|
||||
isSlim?: boolean;
|
||||
}
|
||||
|
||||
type OmoAdvancedFieldKey =
|
||||
@@ -122,11 +114,6 @@ const OMO_ADVANCED_JSON_FIELDS: ReadonlyArray<{
|
||||
},
|
||||
];
|
||||
|
||||
const OMO_SLIM_ADVANCED_KEYS: ReadonlySet<OmoAdvancedFieldKey> = new Set([
|
||||
"lspStr",
|
||||
"experimentalStr",
|
||||
]);
|
||||
|
||||
function TagListEditor({
|
||||
label,
|
||||
values,
|
||||
@@ -323,25 +310,12 @@ function JsonTextareaField({
|
||||
export const OmoGlobalConfigFields = forwardRef<
|
||||
OmoGlobalConfigFieldsRef,
|
||||
OmoGlobalConfigFieldsProps
|
||||
>(function OmoGlobalConfigFields(
|
||||
{ onStateChange, hideSaveButtons, isSlim = false },
|
||||
ref,
|
||||
) {
|
||||
>(function OmoGlobalConfigFields({ onStateChange, hideSaveButtons }, ref) {
|
||||
const { t } = useTranslation();
|
||||
const { data: standardConfig } = useOmoGlobalConfig(!isSlim);
|
||||
const { data: slimConfig } = useOmoSlimGlobalConfig(isSlim);
|
||||
const config = isSlim ? slimConfig : standardConfig;
|
||||
const standardSaveMutation = useSaveOmoGlobalConfig();
|
||||
const slimSaveMutation = useSaveOmoSlimGlobalConfig();
|
||||
const saveMutation = isSlim ? slimSaveMutation : standardSaveMutation;
|
||||
const standardReadLocal = useReadOmoLocalFile();
|
||||
const slimReadLocal = useReadOmoSlimLocalFile();
|
||||
const { data: config } = useOmoGlobalConfig();
|
||||
const saveMutation = useSaveOmoGlobalConfig();
|
||||
|
||||
const defaultSchemaUrl = isSlim
|
||||
? OMO_SLIM_DEFAULT_SCHEMA_URL
|
||||
: OMO_DEFAULT_SCHEMA_URL;
|
||||
|
||||
const [schemaUrl, setSchemaUrl] = useState(defaultSchemaUrl);
|
||||
const [schemaUrl, setSchemaUrl] = useState(OMO_DEFAULT_SCHEMA_URL);
|
||||
const [sisyphusAgentStr, setSisyphusAgentStr] = useState("");
|
||||
const [disabledAgents, setDisabledAgents] = useState<string[]>([]);
|
||||
const [disabledMcps, setDisabledMcps] = useState<string[]>([]);
|
||||
@@ -356,7 +330,7 @@ export const OmoGlobalConfigFields = forwardRef<
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const applyGlobalState = useCallback((global: OmoGlobalConfig) => {
|
||||
setSchemaUrl(global.schemaUrl || defaultSchemaUrl);
|
||||
setSchemaUrl(global.schemaUrl || OMO_DEFAULT_SCHEMA_URL);
|
||||
setSisyphusAgentStr(
|
||||
global.sisyphusAgent ? JSON.stringify(global.sisyphusAgent, null, 2) : "",
|
||||
);
|
||||
@@ -571,7 +545,7 @@ export const OmoGlobalConfigFields = forwardRef<
|
||||
placeholder: t("omo.disabledAgentsPlaceholder", {
|
||||
defaultValue: "Disabled Agents",
|
||||
}),
|
||||
presets: isSlim ? OMO_SLIM_DISABLEABLE_AGENTS : OMO_DISABLEABLE_AGENTS,
|
||||
presets: OMO_DISABLEABLE_AGENTS,
|
||||
},
|
||||
{
|
||||
key: "mcps",
|
||||
@@ -581,7 +555,7 @@ export const OmoGlobalConfigFields = forwardRef<
|
||||
placeholder: t("omo.disabledMcpsPlaceholder", {
|
||||
defaultValue: "Disabled MCPs",
|
||||
}),
|
||||
presets: isSlim ? OMO_SLIM_DISABLEABLE_MCPS : OMO_DISABLEABLE_MCPS,
|
||||
presets: OMO_DISABLEABLE_MCPS,
|
||||
},
|
||||
{
|
||||
key: "hooks",
|
||||
@@ -591,25 +565,21 @@ export const OmoGlobalConfigFields = forwardRef<
|
||||
placeholder: t("omo.disabledHooksPlaceholder", {
|
||||
defaultValue: "Disabled Hooks",
|
||||
}),
|
||||
presets: isSlim ? OMO_SLIM_DISABLEABLE_HOOKS : OMO_DISABLEABLE_HOOKS,
|
||||
presets: OMO_DISABLEABLE_HOOKS,
|
||||
},
|
||||
...(!isSlim
|
||||
? [
|
||||
{
|
||||
key: "skills" as const,
|
||||
label: t("omo.disabledSkills", { defaultValue: "Skills" }),
|
||||
values: disabledSkills,
|
||||
onChange: setDisabledSkills,
|
||||
placeholder: t("omo.disabledSkillsPlaceholder", {
|
||||
defaultValue: "Disabled Skills",
|
||||
}),
|
||||
presets: OMO_DISABLEABLE_SKILLS,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
{
|
||||
key: "skills",
|
||||
label: t("omo.disabledSkills", { defaultValue: "Skills" }),
|
||||
values: disabledSkills,
|
||||
onChange: setDisabledSkills,
|
||||
placeholder: t("omo.disabledSkillsPlaceholder", {
|
||||
defaultValue: "Disabled Skills",
|
||||
}),
|
||||
presets: OMO_DISABLEABLE_SKILLS,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const readLocalFile = isSlim ? slimReadLocal : standardReadLocal;
|
||||
const readLocalFile = useReadOmoLocalFile();
|
||||
|
||||
const handleImportGlobalFromLocal = useCallback(async () => {
|
||||
try {
|
||||
@@ -698,27 +668,25 @@ export const OmoGlobalConfigFields = forwardRef<
|
||||
<Input
|
||||
value={schemaUrl}
|
||||
onChange={(e) => setSchemaUrl(e.target.value)}
|
||||
placeholder={defaultSchemaUrl}
|
||||
placeholder={OMO_DEFAULT_SCHEMA_URL}
|
||||
className="text-sm h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isSlim && (
|
||||
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("omo.sisyphusAgentConfig", {
|
||||
defaultValue: "Sisyphus Agent",
|
||||
})}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={sisyphusAgentStr}
|
||||
onChange={(e) => setSisyphusAgentStr(e.target.value)}
|
||||
placeholder={OMO_SISYPHUS_AGENT_PLACEHOLDER}
|
||||
className="font-mono text-sm"
|
||||
style={{ minHeight: "140px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("omo.sisyphusAgentConfig", {
|
||||
defaultValue: "Sisyphus Agent",
|
||||
})}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={sisyphusAgentStr}
|
||||
onChange={(e) => setSisyphusAgentStr(e.target.value)}
|
||||
placeholder={OMO_SISYPHUS_AGENT_PLACEHOLDER}
|
||||
className="font-mono text-sm"
|
||||
style={{ minHeight: "140px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -747,9 +715,7 @@ export const OmoGlobalConfigFields = forwardRef<
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("omo.advanced", { defaultValue: "Advanced Settings" })}
|
||||
</Label>
|
||||
{OMO_ADVANCED_JSON_FIELDS.filter(
|
||||
(field) => !isSlim || OMO_SLIM_ADVANCED_KEYS.has(field.key),
|
||||
).map((field) => (
|
||||
{OMO_ADVANCED_JSON_FIELDS.map((field) => (
|
||||
<JsonTextareaField
|
||||
key={field.key}
|
||||
label={t(field.labelKey, { defaultValue: field.defaultLabel })}
|
||||
|
||||
@@ -1,472 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { ApiKeySection } from "./shared";
|
||||
import { openclawApiProtocols } from "@/config/openclawProviderPresets";
|
||||
import type { ProviderCategory, OpenClawModel } from "@/types";
|
||||
|
||||
interface OpenClawFormFieldsProps {
|
||||
// Base URL
|
||||
baseUrl: string;
|
||||
onBaseUrlChange: (value: string) => void;
|
||||
|
||||
// API Key
|
||||
apiKey: string;
|
||||
onApiKeyChange: (value: string) => void;
|
||||
category?: ProviderCategory;
|
||||
shouldShowApiKeyLink: boolean;
|
||||
websiteUrl: string;
|
||||
isPartner?: boolean;
|
||||
partnerPromotionKey?: string;
|
||||
|
||||
// API Protocol
|
||||
api: string;
|
||||
onApiChange: (value: string) => void;
|
||||
|
||||
// Models
|
||||
models: OpenClawModel[];
|
||||
onModelsChange: (models: OpenClawModel[]) => void;
|
||||
}
|
||||
|
||||
export function OpenClawFormFields({
|
||||
baseUrl,
|
||||
onBaseUrlChange,
|
||||
apiKey,
|
||||
onApiKeyChange,
|
||||
category,
|
||||
shouldShowApiKeyLink,
|
||||
websiteUrl,
|
||||
isPartner,
|
||||
partnerPromotionKey,
|
||||
api,
|
||||
onApiChange,
|
||||
models,
|
||||
onModelsChange,
|
||||
}: OpenClawFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedModels, setExpandedModels] = useState<Record<number, boolean>>(
|
||||
{},
|
||||
);
|
||||
|
||||
// Stable key tracking for models list
|
||||
const modelKeysRef = useRef<string[]>([]);
|
||||
const getModelKeys = useCallback(() => {
|
||||
// Grow keys array if models were added externally
|
||||
while (modelKeysRef.current.length < models.length) {
|
||||
modelKeysRef.current.push(crypto.randomUUID());
|
||||
}
|
||||
// Shrink if models were removed externally
|
||||
if (modelKeysRef.current.length > models.length) {
|
||||
modelKeysRef.current.length = models.length;
|
||||
}
|
||||
return modelKeysRef.current;
|
||||
}, [models.length]);
|
||||
const modelKeys = getModelKeys();
|
||||
|
||||
// Toggle advanced section for a model
|
||||
const toggleModelAdvanced = (index: number) => {
|
||||
setExpandedModels((prev) => ({ ...prev, [index]: !prev[index] }));
|
||||
};
|
||||
|
||||
// Add a new model entry
|
||||
const handleAddModel = () => {
|
||||
modelKeysRef.current.push(crypto.randomUUID());
|
||||
onModelsChange([
|
||||
...models,
|
||||
{
|
||||
id: "",
|
||||
name: "",
|
||||
contextWindow: undefined,
|
||||
maxTokens: undefined,
|
||||
cost: undefined,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// Remove a model entry
|
||||
const handleRemoveModel = (index: number) => {
|
||||
modelKeysRef.current.splice(index, 1);
|
||||
const newModels = [...models];
|
||||
newModels.splice(index, 1);
|
||||
onModelsChange(newModels);
|
||||
// Clean up expanded state
|
||||
setExpandedModels((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[index];
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// Update model field
|
||||
const handleModelChange = (
|
||||
index: number,
|
||||
field: keyof OpenClawModel,
|
||||
value: unknown,
|
||||
) => {
|
||||
const newModels = [...models];
|
||||
newModels[index] = { ...newModels[index], [field]: value };
|
||||
onModelsChange(newModels);
|
||||
};
|
||||
|
||||
// Update model cost
|
||||
const handleCostChange = (
|
||||
index: number,
|
||||
costField: "input" | "output" | "cacheRead" | "cacheWrite",
|
||||
value: string,
|
||||
) => {
|
||||
const newModels = [...models];
|
||||
const numValue = parseFloat(value);
|
||||
const currentCost = newModels[index].cost || { input: 0, output: 0 };
|
||||
newModels[index] = {
|
||||
...newModels[index],
|
||||
cost: {
|
||||
...currentCost,
|
||||
[costField]: isNaN(numValue) ? undefined : numValue,
|
||||
},
|
||||
};
|
||||
onModelsChange(newModels);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* API Protocol Selector */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="openclaw-api">
|
||||
{t("openclaw.apiProtocol", {
|
||||
defaultValue: "API 协议",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Select value={api} onValueChange={onApiChange}>
|
||||
<SelectTrigger id="openclaw-api">
|
||||
<SelectValue
|
||||
placeholder={t("openclaw.selectProtocol", {
|
||||
defaultValue: "选择 API 协议",
|
||||
})}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{openclawApiProtocols.map((protocol) => (
|
||||
<SelectItem key={protocol.value} value={protocol.value}>
|
||||
{protocol.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("openclaw.apiProtocolHint", {
|
||||
defaultValue:
|
||||
"选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Base URL */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="openclaw-baseurl">
|
||||
{t("openclaw.baseUrl", { defaultValue: "API 端点" })}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="openclaw-baseurl"
|
||||
value={baseUrl}
|
||||
onChange={(e) => onBaseUrlChange(e.target.value)}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("openclaw.baseUrlHint", {
|
||||
defaultValue: "供应商的 API 端点地址。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<ApiKeySection
|
||||
value={apiKey}
|
||||
onChange={onApiKeyChange}
|
||||
category={category}
|
||||
shouldShowLink={shouldShowApiKeyLink}
|
||||
websiteUrl={websiteUrl}
|
||||
isPartner={isPartner}
|
||||
partnerPromotionKey={partnerPromotionKey}
|
||||
/>
|
||||
|
||||
{/* Models Editor */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>
|
||||
{t("openclaw.models", { defaultValue: "模型列表" })}
|
||||
</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddModel}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("openclaw.addModel", { defaultValue: "添加模型" })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{models.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
{t("openclaw.noModels", {
|
||||
defaultValue: "暂无模型配置。点击添加模型来配置可用模型。",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{models.map((model, index) => (
|
||||
<div
|
||||
key={modelKeys[index]}
|
||||
className="p-3 border border-border/50 rounded-lg space-y-3"
|
||||
>
|
||||
{/* Model ID and Name row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.modelId", { defaultValue: "模型 ID" })}
|
||||
</label>
|
||||
<Input
|
||||
value={model.id}
|
||||
onChange={(e) =>
|
||||
handleModelChange(index, "id", e.target.value)
|
||||
}
|
||||
placeholder={t("openclaw.modelIdPlaceholder", {
|
||||
defaultValue: "claude-3-sonnet",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.modelName", { defaultValue: "显示名称" })}
|
||||
</label>
|
||||
<Input
|
||||
value={model.name}
|
||||
onChange={(e) =>
|
||||
handleModelChange(index, "name", e.target.value)
|
||||
}
|
||||
placeholder={t("openclaw.modelNamePlaceholder", {
|
||||
defaultValue: "Claude 3 Sonnet",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveModel(index)}
|
||||
className="h-9 w-9 mt-5 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Advanced Options (Collapsible) */}
|
||||
<Collapsible
|
||||
open={expandedModels[index] ?? false}
|
||||
onOpenChange={() => toggleModelAdvanced(index)}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{expandedModels[index] ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("openclaw.advancedOptions", {
|
||||
defaultValue: "高级选项",
|
||||
})}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 pt-2">
|
||||
{/* Context Window, Max Tokens and Reasoning row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.contextWindow", {
|
||||
defaultValue: "上下文窗口",
|
||||
})}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={model.contextWindow ?? ""}
|
||||
onChange={(e) =>
|
||||
handleModelChange(
|
||||
index,
|
||||
"contextWindow",
|
||||
e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
placeholder="200000"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.maxTokens", {
|
||||
defaultValue: "最大输出 Tokens",
|
||||
})}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={model.maxTokens ?? ""}
|
||||
onChange={(e) =>
|
||||
handleModelChange(
|
||||
index,
|
||||
"maxTokens",
|
||||
e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
placeholder="32000"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.reasoning", {
|
||||
defaultValue: "推理模式",
|
||||
})}
|
||||
</label>
|
||||
<div className="flex items-center h-9 gap-2">
|
||||
<Switch
|
||||
checked={model.reasoning ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleModelChange(index, "reasoning", checked)
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{model.reasoning
|
||||
? t("openclaw.reasoningOn", {
|
||||
defaultValue: "启用",
|
||||
})
|
||||
: t("openclaw.reasoningOff", {
|
||||
defaultValue: "关闭",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.inputCost", {
|
||||
defaultValue: "输入价格 ($/M tokens)",
|
||||
})}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={model.cost?.input ?? ""}
|
||||
onChange={(e) =>
|
||||
handleCostChange(index, "input", e.target.value)
|
||||
}
|
||||
placeholder="3"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.outputCost", {
|
||||
defaultValue: "输出价格 ($/M tokens)",
|
||||
})}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={model.cost?.output ?? ""}
|
||||
onChange={(e) =>
|
||||
handleCostChange(index, "output", e.target.value)
|
||||
}
|
||||
placeholder="15"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
|
||||
{/* Cache Cost row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.cacheReadCost", {
|
||||
defaultValue: "缓存读取价格 ($/M tokens)",
|
||||
})}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={model.cost?.cacheRead ?? ""}
|
||||
onChange={(e) =>
|
||||
handleCostChange(index, "cacheRead", e.target.value)
|
||||
}
|
||||
placeholder="0.3"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("openclaw.cacheWriteCost", {
|
||||
defaultValue: "缓存写入价格 ($/M tokens)",
|
||||
})}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={model.cost?.cacheWrite ?? ""}
|
||||
onChange={(e) =>
|
||||
handleCostChange(
|
||||
index,
|
||||
"cacheWrite",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="3.75"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("openclaw.cacheCostHint", {
|
||||
defaultValue:
|
||||
"缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。",
|
||||
})}
|
||||
</p>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("openclaw.modelsHint", {
|
||||
defaultValue:
|
||||
"配置该供应商支持的模型。模型 ID 用于 API 调用,显示名称用于界面展示。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user