Compare commits

..

1 Commits

Author SHA1 Message Date
YoVinchen a052af0060 style: format code and apply clippy lint fixes 2026-02-06 15:08:07 +08:00
244 changed files with 5167 additions and 28314 deletions
+3 -21
View File
@@ -15,18 +15,16 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANG
## ❤️Sponsor
[![MiniMax](assets/partners/banners/minimax-en.jpeg)](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
[![Zhipu GLM](assets/partners/banners/glm-en.jpg)](https://z.ai/subscribe?ic=8JVLJQFSKB)
MiniMax-M2.5 is a SOTA large language model designed for real-world productivity. Trained in a diverse range of complex real-world digital working environments, M2.5 builds upon the coding expertise of M2.1 to extend into general office work, reaching fluency in generating and operating Word, Excel, and Powerpoint files, context switching between diverse software environments, and working across different agent and human teams. Scoring 80.2% on SWE-Bench Verified, 51.3% on Multi-SWE-Bench, and 76.3% on BrowseComp, M2.5 is also more token efficient than previous generations, having been trained to optimize its actions and output through planning.
[Click](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link) to get an exclusive 12% off the MiniMax Coding Plan!
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)!
---
<table>
<tr>
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during first recharge to get 10% off.</td>
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during recharge to get 10% off.</td>
</tr>
<tr>
@@ -50,26 +48,11 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
<td>Thanks to DMXAPI for sponsoring this project! DMXAPI provides global large model API services to 200+ enterprise users. One API key for all global models. Features include: instant invoicing, unlimited concurrency, starting from $0.15, 24/7 technical support. GPT/Claude/Gemini all at 32% off, domestic models 20-50% off, Claude Code exclusive models at 66% off! <a href="https://www.dmxapi.cn/register?aff=bUHu">Register here</a></td>
</tr>
<tr>
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
<td>Thank you to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini. It features a highly cost-effective Codex monthly subscription plan and <strong>supports quota rollovers—unused quota from one day can be carried over and used the next day.</strong> Invoices are available upon top-up. Enterprise and team users can receive dedicated one-on-one support. Right Code also offers an exclusive discount for CC Switch users: register via <a href="https://www.right.codes/register?aff=CCSWITCH">this link</a>, and with every top-up you will receive pay-as-you-go credit equivalent to 25% of the amount paid.</td>
</tr>
<tr>
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
<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
@@ -156,7 +139,6 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
**Core Capabilities**
- **Provider Management**: One-click switching between Claude Code, Codex, and Gemini API configurations
- **AWS Bedrock Support**: Built-in AWS Bedrock provider presets with AKSK and API Key authentication, cross-region inference support (global/us/eu/apac), covering Claude Code and OpenCode
- **Speed Testing**: Measure API endpoint latency with visual quality indicators
- **Import/Export**: Backup and restore configs with auto-rotation (keep 10 most recent)
- **i18n Support**: Complete Chinese/English localization (UI, errors, tray)
+2 -20
View File
@@ -15,11 +15,9 @@
## ❤️スポンサー
[![MiniMax](assets/partners/banners/minimax-en.jpeg)](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
[![Zhipu GLM](assets/partners/banners/glm-en.jpg)](https://z.ai/subscribe?ic=8JVLJQFSKB)
MiniMax-M2.5 は、実際の生産性向上のために設計された最先端の大規模言語モデルです。多様で複雑な実環境のデジタルワークスペースでトレーニングされた M2.5 は、M2.1 のコーディング能力をベースに一般的なオフィス業務へと拡張し、Word・Excel・PowerPoint ファイルの生成と操作、多様なソフトウェア環境間のコンテキスト切り替え、異なるエージェントや人間チーム間での協働を流暢にこなします。SWE-Bench Verified で 80.2%、Multi-SWE-Bench で 51.3%、BrowseComp で 76.3% を達成し、計画的な行動と出力の最適化トレーニングにより、前世代よりもトークン効率に優れています
[こちら](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)から MiniMax Coding Plan の限定 12% オフを入手!
本プロジェクトは Z.ai の GLM CODING PLAN による支援を受けています。GLM CODING PLAN は AI コーディング向けのサブスクリプションで、月額わずか 3 ドルから。Claude Code、Cline、Roo Code など 10 以上の人気 AI コーディングツールでフラッグシップモデル GLM-4.6 を利用でき、速く安定した開発体験を提供します。[このリンク](https://z.ai/subscribe?ic=8JVLJQFSKB) から申し込むと 10% オフになります
---
@@ -50,26 +48,11 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
<td>DMXAPI のご支援に感謝します!DMXAPI は 200 社以上の企業ユーザーにグローバル大規模モデル API サービスを提供しています。1 つの API キーで全世界のモデルにアクセス可能。即時請求書発行、同時接続数無制限、最低 $0.15 から、24 時間年中無休のテクニカルサポート。GPT/Claude/Gemini が全て 32% オフ、国内モデルは 20〜50% オフ、Claude Code 専用モデルは 66% オフ実施中!<a href="https://www.dmxapi.cn/register?aff=bUHu">登録はこちら</a></td>
</tr>
<tr>
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
<td>本プロジェクトへのご支援として、Right Code にご協賛いただき誠にありがとうございます。Right Code は、Claude Code、Codex、Gemini などのモデルに対応した中継(プロキシ)サービスを安定して提供しています。特に高いコストパフォーマンスを誇る Codex の月額プランを主力としており、<strong>未使用分の利用枠を翌日に繰り越して利用できる(繰越対応)</strong>点が特長です。チャージ(入金)後に請求書の発行が可能で、企業・チーム向けには専任担当による個別対応も行っています。さらに CC Switch ユーザー向けの特別優待として、<a href="https://www.right.codes/register?aff=CCSWITCH">こちらのリンク</a>からご登録いただくと、チャージのたびに実支払額の 25% 相当の従量課金クレジットが付与されます。</td>
</tr>
<tr>
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
<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>
## スクリーンショット
@@ -156,7 +139,6 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
**コア機能**
- **プロバイダ管理**Claude Code、Codex、Gemini の API 設定をワンクリックで切り替え
- **AWS Bedrock 対応**AWS Bedrock プロバイダプリセットを内蔵、AKSK および API Key 認証に対応、クロスリージョン推論(global/us/eu/apac)をサポート、Claude Code と OpenCode に対応
- **速度テスト**:エンドポイント遅延を計測し、品質を可視化
- **インポート/エクスポート**:設定をバックアップ・復元(最新 10 件を自動ローテーション)
- **多言語対応**:UI/エラー/トレイを含む中国語・英語・日本語ローカライズ
+3 -21
View File
@@ -15,18 +15,16 @@
## ❤️赞助商
[![MiniMax](assets/partners/banners/minimax-zh.jpeg)](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)
[![智谱 GLM](assets/partners/banners/glm-zh.jpg)](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
MiniMax M2.5 在编程、工具调用与搜索、办公等核心生产力场景均达到或刷新行业 SOTA,拥有架构师级代码能力与高效任务拆解能力,推理速度较上一代提升 37%、token 消耗更优;100 token/s 连续工作一小时仅需 1 美金,让复杂 Agent 规模化部署经济可行,已在企业多职能场景深度落地,加速全民 Agent 时代到来
[点击](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)即可领取 MiniMax Coding Plan 专属 88 折优惠!
感谢智谱AI的 GLM CODING PLAN 赞助了本项目!GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠
---
<table>
<tr>
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,首次充值可以享受9折优惠!</td>
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,可以享受9折优惠!</td>
</tr>
<tr>
@@ -51,26 +49,11 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
为200多家企业用户提供全球大模型API服务。· 充值即开票 ·当天开票 ·并发不限制 ·1元起充 · 7x24 在线技术辅导,GPT/Claude/Gemini全部6.8折,国内模型5~8折,Claude Code 专属模型3.4折进行中!<a href="https://www.dmxapi.cn/register?aff=bUHu">点击这里注册</a></td>
</tr>
<tr>
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
<td>感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务。主打<strong>极高性价比</strong>的Codex包月套餐,<strong>提供额度转结,套餐当天用不完的额度,第二天还能接着用!</strong>充值即可开票,企业、团队用户一对一对接。同时为 CC Switch 的用户提供了特别优惠:通过<a href="https://www.right.codes/register?aff=CCSWITCH">此链接</a>注册,每次充值均可获得实付金额25%的按量额度!</td>
</tr>
<tr>
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
<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>
## 界面预览
@@ -157,7 +140,6 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
**核心功能**
- **供应商管理**:一键切换 Claude Code、Codex 与 Gemini 的 API 配置
- **AWS Bedrock 支持**:内置 AWS Bedrock 供应商预设,支持 AKSK 和 API Key 两种认证方式,支持跨区域推理(global/us/eu/apac),覆盖 Claude Code 和 OpenCode
- **速度测试**:测量 API 端点延迟,可视化连接质量指示器
- **导入导出**:备份和恢复配置,自动轮换(保留最近 10 个)
- **国际化支持**:完整的中英文本地化(UI、错误、托盘)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 447 KiB

@@ -1,64 +0,0 @@
# 1.1 软件介绍
## 什么是 CC Switch
CC Switch 是一款跨平台桌面应用,专为使用 AI 编程工具的开发者设计。它帮助你统一管理 **Claude Code**、**Codex**、**Gemini CLI**、**OpenCode** 四大 AI 编程工具的配置。
## 解决什么问题
在日常开发中,你可能会遇到这些痛点:
- **多供应商切换麻烦**:使用不同的 API 供应商(官方、中转服务商),需要手动修改配置文件
- **配置分散难管理**Claude、Codex、Gemini、OpenCode 各有独立的配置文件,格式不同
- **无法监控用量**:不知道 API 调用了多少次,花了多少钱
- **服务不稳定**:单一供应商出问题时,整个工作流中断
CC Switch 通过统一的界面解决这些问题。
## 核心功能
### 供应商管理
- 一键切换多个 API 供应商配置
- 支持预设模板,快速添加常用供应商
- 统一供应商功能,跨应用共享配置
- 用量查询与余额显示
- 端点速度测试
### 扩展功能
- **MCP 服务器**:管理 Model Context Protocol 服务器,扩展 AI 能力
- **Prompts**:管理系统提示词预设,快速切换不同场景
- **Skills**:安装和管理技能扩展
### 代理与高可用
- 本地代理服务,记录请求日志和用量统计
- 自动故障转移,主供应商失败时自动切换备用
- 熔断器机制,防止频繁重试失败的供应商
- 详细的 Token 用量追踪与成本估算
## 支持的应用
| 应用 | 说明 |
|------|------|
| **Claude Code** | Anthropic 官方的 AI 编程助手 |
| **Codex** | OpenAI 的代码生成工具 |
| **Gemini CLI** | Google 的 AI 命令行工具 |
| **OpenCode** | 开源 AI 编程终端工具 |
## 支持的平台
- **Windows** 10 及以上
- **macOS** 10.15 (Catalina) 及以上
- **Linux** Ubuntu 22.04+ / Debian 11+ / Fedora 34+
## 技术架构
CC Switch 使用现代化的技术栈构建:
- **前端**React 18 + TypeScript + Tailwind CSS
- **后端**Tauri 2 + Rust
- **数据存储**SQLite(供应商、MCP、Prompts+ JSON(设备设置)
这种架构确保了:
- 跨平台一致的体验
- 原生级别的性能
- 安全的本地数据存储
@@ -1,243 +0,0 @@
# 1.2 安装指南
## 前置要求
### 安装 Node.js
CC Switch 管理的 CLI 工具(Claude Code、Codex、Gemini CLI)需要 Node.js 环境。
**推荐版本**Node.js 18 LTS 或更高版本
#### Windows
1. 访问 [Node.js 官网](https://nodejs.org/)
2. 下载 LTS 版本安装包
3. 运行安装程序,按提示完成安装
4. 验证安装:
```bash
node --version
npm --version
```
#### macOS
```bash
# 使用 Homebrew 安装
brew install node
# 或使用 nvm(推荐)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
nvm install --lts
```
#### Linux
```bash
# Ubuntu/Debian
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt-get install -y nodejs
# 或使用 nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
nvm install --lts
```
### 安装 CLI 工具
#### Claude Code
**方式一:HomebrewmacOS 推荐)**
```bash
brew install claude-code
```
**方式二:npm**
```bash
npm install -g @anthropic-ai/claude-code
# 国内用户如下载慢,使用镜像源
npm install -g @anthropic-ai/claude-code --registry=https://registry.npmmirror.com
```
#### Codex
**方式一:HomebrewmacOS 推荐)**
```bash
brew install codex
```
**方式二:npm**
```bash
npm install -g @openai/codex
# 国内用户如下载慢,使用镜像源
npm install -g @openai/codex --registry=https://registry.npmmirror.com
```
#### Gemini CLI
**方式一:HomebrewmacOS 推荐)**
```bash
brew install gemini-cli
```
**方式二:npm**
```bash
npm install -g @google/gemini-cli
# 国内用户如下载慢,使用镜像源
npm install -g @google/gemini-cli --registry=https://registry.npmmirror.com
```
> 💡 **提示**:如果经常遇到下载慢的问题,可以全局设置镜像源:
> ```bash
> npm config set registry https://registry.npmmirror.com
> ```
---
## Windows
### 安装包方式
1. 访问 [Releases 页面](https://github.com/farion1231/cc-switch/releases)
2. 下载 `CC-Switch-v{版本号}-Windows.msi`
3. 双击运行安装程序
4. 按提示完成安装
### 绿色版(免安装)
1. 下载 `CC-Switch-v{版本号}-Windows-Portable.zip`
2. 解压到任意目录
3. 运行 `CC-Switch.exe`
## macOS
### 方式一:Homebrew(推荐)
```bash
# 添加 tap
brew tap farion1231/ccswitch
# 安装
brew install --cask cc-switch
```
更新到最新版本:
```bash
brew upgrade --cask cc-switch
```
### 方式二:手动下载
1. 下载 `CC-Switch-v{版本号}-macOS.zip`
2. 解压得到 `CC Switch.app`
3. 拖动到「应用程序」文件夹
### 首次打开提示
由于开发者没有 Apple 开发者账号,首次打开可能出现「未知开发者」警告:
**推荐解决方法**
打开终端执行以下命令:
```bash
sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/
```
**备选解决方法(通过系统设置)**:
1. 关闭警告弹窗
2. 打开「系统设置」→「隐私与安全性」
3. 找到 CC Switch 相关提示,点击「仍要打开」
4. 再次打开应用即可正常使用
## Linux
### ArchLinux
使用 AUR 助手安装:
```bash
# 使用 paru
paru -S cc-switch-bin
# 或使用 yay
yay -S cc-switch-bin
```
### Debian / Ubuntu
1. 下载 `CC-Switch-v{版本号}-Linux.deb`
2. 安装:
```bash
sudo dpkg -i CC-Switch-v{版本号}-Linux.deb
# 如果有依赖问题
sudo apt-get install -f
```
### AppImage(通用)
1. 下载 `CC-Switch-v{版本号}-Linux.AppImage`
2. 添加执行权限:
```bash
chmod +x CC-Switch-v{版本号}-Linux.AppImage
```
3. 运行:
```bash
./CC-Switch-v{版本号}-Linux.AppImage
```
## 验证安装
安装完成后,启动 CC Switch:
1. 应用窗口正常显示
2. 系统托盘出现 CC Switch 图标
3. 能够切换 Claude / Codex / Gemini 三个应用
## 自动更新
CC Switch 内置自动更新功能:
- 启动时自动检查更新
- 有新版本时在界面显示更新提示
- 点击即可下载并安装
也可以在「设置 → 关于」中手动检查更新。
## 卸载
### Windows
- 通过「设置 → 应用」卸载
- 或运行安装目录下的卸载程序
### macOS
- 将 `CC Switch.app` 移到废纸篓
- 可选:删除配置目录 `~/.cc-switch/`
### Linux
```bash
# Debian/Ubuntu
sudo apt remove cc-switch
# ArchLinux
paru -R cc-switch-bin
```
@@ -1,169 +0,0 @@
# 1.3 界面概览
## 主界面布局
![image-20260108001629138](../assets/image-20260108001629138.png)
## 顶部导航栏
| 序号 | 元素 | 功能说明 |
|------|------|----------|
| ① | Logo | 点击访问 GitHub 项目页 |
| ② | 设置按钮 | 打开设置页面(快捷键 `Cmd/Ctrl + ,` |
| ③ | 代理开关 | 启动/停止本地代理服务 |
| ④ | 应用切换器 | 切换 Claude / Codex / Gemini / OpenCode |
| ⑤ | 功能区 | Skills / Prompts / MCP 入口 |
| ⑥ | 添加按钮 | 添加新供应商 |
### 应用切换器
点击下拉菜单切换当前管理的应用:
- **Claude** - 管理 Claude Code 配置
- **Codex** - 管理 Codex 配置
- **Gemini** - 管理 Gemini CLI 配置
- **OpenCode** - 管理 OpenCode 配置
切换后,供应商列表会显示对应应用的配置。
### 功能区按钮
| 按钮 | 功能 | 可见条件 |
|------|------|----------|
| Skills | 技能扩展管理 | 始终可见 |
| Prompts | 系统提示词管理 | 始终可见 |
| MCP | MCP 服务器管理 | 始终可见 |
## 供应商卡片
每个供应商以卡片形式展示,从左到右依次包含以下元素:
### 卡片元素(从左到右)
| 序号 | 元素 | 图标 | 功能说明 |
|------|------|------|----------|
| ① | 拖拽手柄 | ≡ | 按住上下拖动调整供应商顺序 |
| ② | 供应商图标 | 🔷 | 显示供应商品牌图标,可自定义颜色 |
| ③ | 供应商信息 | - | 名称、备注/端点地址(可点击打开官网) |
| ④ | 用量信息 | - | 显示剩余额度,多套餐时显示套餐数量 |
| ⑤ | 启用按钮 | ▶ | 切换为当前使用的供应商 |
| ⑥ | 编辑按钮 | ✏️ | 编辑供应商配置 |
| ⑦ | 复制按钮 | 📋 | 复制供应商(创建副本) |
| ⑧ | 测速按钮 | 🧪 | 测试模型可用性和响应速度 |
| ⑨ | 用量查询 | 📊 | 配置用量查询脚本 |
| ⑩ | 删除按钮 | 🗑️ | 删除供应商(当前启用时禁用) |
> 💡 **提示**:操作按钮区域(⑤-⑩)在鼠标悬停时显示,平时隐藏以保持界面简洁。
### 按钮详细说明
| 按钮 | 状态变化 | 说明 |
|------|----------|------|
| **启用** | 已启用时显示 ✓ 并禁用 | 故障转移模式下变为「加入/已加入」 |
| **编辑** | 始终可用 | 打开编辑面板修改配置 |
| **复制** | 始终可用 | 创建供应商副本,名称后缀 `copy` |
| **测速** | 测试中显示加载动画 | 仅代理服务运行时可用 |
| **用量查询** | 始终可用 | 配置自定义用量查询脚本 |
| **删除** | 当前启用时半透明禁用 | 需先切换到其他供应商才能删除 |
### 卡片状态
| 状态 | 边框颜色 | 说明 |
|------|----------|------|
| **当前启用** | 🔵 蓝色边框 | 普通模式下当前使用的供应商 |
| **代理活跃** | 🟢 绿色边框 | 代理接管模式下实际使用的供应商 |
| **普通状态** | 默认边框 | 未启用的供应商 |
| **故障转移中** | 显示优先级徽章 | 如 P1、P2 表示故障转移优先级 |
### 健康状态徽章
在代理模式下,加入故障转移队列的供应商会显示健康状态:
| 徽章 | 颜色 | 说明 |
|------|------|------|
| 健康 | 🟢 绿色 | 连续失败 0 次 |
| 警告 | 🟡 黄色 | 连续失败 1-2 次 |
| 不健康 | 🔴 红色 | 连续失败 ≥3 次,可能触发熔断 |
## 系统托盘
CC Switch 在系统托盘显示图标,提供快速操作入口。
### 托盘菜单结构
![image-20260108002153668](../assets/image-20260108002153668.png)
### 菜单功能
| 菜单项 | 功能 |
|--------|------|
| 打开主界面 | 显示主窗口并聚焦 |
| 应用分组 | 按 Claude/Codex/Gemini/OpenCode 分组显示供应商 |
| 供应商列表 | 点击切换,当前启用的显示勾选标记 |
| 退出 | 完全退出应用 |
### 多语言支持
托盘菜单支持三种语言,根据设置自动切换:
| 语言 | 打开主界面 | 退出 |
|------|-----------|------|
| 中文 | 打开主界面 | 退出 |
| English | Open main window | Quit |
| 日本語 | メインウィンドウを開く | 終了 |
### 使用场景
托盘切换供应商无需打开主界面,适合:
- 频繁切换供应商
- 主窗口最小化时快速操作
- 后台运行时管理配置
## 设置页面
设置页面分为多个 Tab
### 通用 Tab
- 语言设置(中文/English/日本語)
- 主题设置(跟随系统/浅色/深色)
- 窗口行为(开机自启、关闭行为)
### 高级 Tab
- 配置目录设置
- 代理服务配置
- 故障转移设置
- 定价配置
- 数据导入导出
### 用量 Tab
- 请求统计概览
- 趋势图表
- 请求日志
- 供应商/模型统计
### 关于 Tab
- 版本信息
- 更新检查
- 开源协议
## 快捷键
| 快捷键 | 功能 |
|--------|------|
| `Cmd/Ctrl + ,` | 打开设置 |
| `Cmd/Ctrl + F` | 搜索供应商 |
| `Esc` | 关闭弹窗/搜索 |
## 搜索功能
`Cmd/Ctrl + F` 打开搜索框:
- 支持按名称、备注、URL 搜索
- 实时过滤供应商列表
-`Esc` 关闭搜索
@@ -1,92 +0,0 @@
# 1.4 快速上手
本节帮助你在 5 分钟内完成首次配置。
## 第一步:添加供应商
1. 点击主界面右上角的 **+** 按钮
2. 在「预设」下拉框中选择你的供应商
- 常用预设:智谱 GLM、MiniMax、DeepSeek、Kimi、PackyCode
- 或选择「自定义」手动配置
3. 填写 **API Key**
4. 点击「添加」
![image-20260108002807657](../assets/image-20260108002807657.png)
> 💡 **提示**:预设会自动填充端点地址,你只需要填写 API Key。
## 第二步:切换供应商
添加完成后,供应商会出现在列表中。
**方式一:主界面切换**
- 点击供应商卡片的「启用」按钮
**方式二:托盘快速切换**
- 右键系统托盘图标
- 直接点击供应商名称
## 第三步:生效方式
切换供应商后,各 CLI 工具的生效方式不同:
| 应用 | 生效方式 |
|------|----------|
| Claude Code | ✅ 即时生效(支持热重载) |
| Codex | 需关闭并重新打开终端 |
| Gemini | ✅ 即时生效(每次请求重新读取配置) |
### Claude Code 首次安装提示
如果 Claude Code 首次启动时提示需要**登录**或显示初始化引导,请在 CC Switch 中开启「跳过 Claude Code 初次安装确认」选项:
1. 打开 CC Switch「设置 → 通用」
2. 开启「跳过 Claude Code 初次安装确认」开关
3. 重新启动 Claude Code
![image-20260108002626389](../assets/image-20260108002626389.png)
> ⚠️ **注意**:此选项会写入 `~/.claude/settings.json` 的 `skipIntroduction` 字段,跳过官方的新手引导流程。
## 验证配置
重启后,启动对应的 CLI 工具并输入简单的问题进行测试:
```bash
# Claude Code - 启动后输入测试问题
claude
> 你好,请简单介绍一下自己
# Codex - 启动后输入测试问题
codex
> 你好,请简单介绍一下自己
# Gemini - 启动后输入测试问题
gemini
> 你好,请简单介绍一下自己
```
如果 AI 能正常回复,说明配置成功。
## 下一步
恭喜!你已经完成了基础配置。接下来可以:
- [添加更多供应商](../2-providers/2.1-add.md) - 配置多个供应商方便切换
- [配置 MCP 服务器](../3-extensions/3.1-mcp.md) - 扩展 AI 工具的能力
- [设置系统提示词](../3-extensions/3.2-prompts.md) - 自定义 AI 的行为
- [开启代理服务](../4-proxy/4.1-service.md) - 监控用量和自动故障转移
## 常见问题
### 切换后不生效?
确保重启了终端或 CLI 工具。配置文件在切换时已经更新,但运行中的程序不会自动重新加载。
### 找不到预设?
如果你的供应商不在预设列表中,选择「自定义」手动配置。参考 [添加供应商](../2-providers/2.1-add.md) 了解配置格式。
### 如何恢复官方登录?
选择「官方登录」预设(Claude/Codex)或「Google 官方」预设(Gemini),重启客户端后按登录流程操作。
@@ -1,134 +0,0 @@
# 1.5 个性化配置
本节介绍如何根据个人偏好配置 CC Switch。
## 打开设置
- 点击左上角 **⚙️** 按钮
- 或使用快捷键 `Cmd/Ctrl + ,`
## 语言设置
CC Switch 支持三种语言:
| 语言 | 说明 |
|------|------|
| 简体中文 | 默认语言 |
| English | 英文界面 |
| 日本語 | 日文界面 |
切换语言后立即生效,无需重启。
## 主题设置
| 选项 | 说明 |
|------|------|
| 跟随系统 | 自动匹配系统的深色/浅色模式 |
| 浅色 | 始终使用浅色主题 |
| 深色 | 始终使用深色主题 |
## 窗口行为
### 开机自启
开启后,系统启动时自动运行 CC Switch。
- **Windows**:通过注册表实现
- **macOS**:通过 LaunchAgent 实现
- **Linux**:通过 XDG autostart 实现
### 关闭行为
| 选项 | 说明 |
|------|------|
| 最小化到托盘 | 点击关闭按钮时隐藏到系统托盘 |
| 直接退出 | 点击关闭按钮时完全退出应用 |
推荐使用「最小化到托盘」,方便通过托盘快速切换供应商。
### Claude 插件集成
开启后,CC Switch 在切换供应商时会自动同步配置到 VS Code 中的 Claude Code 插件(写入 `~/.claude/config.json``primaryApiKey`)。
> 💡 **使用场景**:如果你同时使用 Claude Code CLI 和 VS Code 插件,开启此选项可以保持两者配置一致。
### 跳过 Claude 引导
开启后,跳过 Claude Code 的新手引导流程,适合已熟悉 Claude Code 的用户。
> ⚠️ **注意**:此选项会写入 `~/.claude/settings.json` 的 `skipIntroduction` 字段。
## 目录配置
### 应用配置目录
CC Switch 自身数据的存储位置,默认为 `~/.cc-switch/`
### CLI 工具目录
可以自定义各 CLI 工具的配置目录:
| 配置 | 默认值 | 说明 |
|------|--------|------|
| Claude 目录 | `~/.claude/` | Claude Code 配置目录 |
| Codex 目录 | `~/.codex/` | Codex 配置目录 |
| Gemini 目录 | `~/.gemini/` | Gemini CLI 配置目录 |
> ⚠️ **注意**:修改目录后需要重启应用,且对应的 CLI 工具也需要配置相同的目录。
## 数据管理
### 导出配置
点击「导出」按钮,保存包含以下内容的备份文件:
- 所有供应商配置
- MCP 服务器配置
- Prompts 预设
- 应用设置
备份文件格式为 JSON,可以用文本编辑器查看。
### 导入配置
1. 点击「选择文件」
2. 选择之前导出的备份文件
3. 点击「导入」
4. 确认覆盖现有配置
> ⚠️ **注意**:导入会覆盖现有配置,建议先导出当前配置作为备份。
## 关于页面
设置 → 关于 Tab
### 版本信息
显示当前 CC Switch 版本号,支持:
- 查看发布说明
- 检查更新
- 下载并安装新版本
### 本地环境检查
自动检测已安装的 CLI 工具版本:
| 工具 | 检测内容 |
|------|----------|
| Claude | 当前版本、最新版本 |
| Codex | 当前版本、最新版本 |
| Gemini | 当前版本、最新版本 |
点击「刷新」按钮可重新检测。
### 一键安装命令
提供快速安装/更新 CLI 工具的命令:
```bash
npm i -g @anthropic-ai/claude-code@latest
npm i -g @openai/codex@latest
npm i -g @google/gemini-cli@latest
```
点击「复制」按钮可复制到剪贴板。
-320
View File
@@ -1,320 +0,0 @@
# 2.1 添加供应商
## 打开添加面板
点击主界面右上角的 **+** 按钮,打开添加供应商面板。
面板分为两个 Tab
- **应用专属供应商**:仅用于当前选中的应用(Claude/Codex/Gemini/OpenCode
- **统一供应商**:跨应用共享的配置
## 使用预设添加
预设是预先配置好的供应商模板,只需填写 API Key 即可使用。
### 操作步骤
1. 在「预设」下拉框中选择供应商
2. 名称和端点会自动填充
3. 填写你的 **API Key**
4. (可选)填写备注
5. 点击「添加」
### 常用预设
#### Claude 预设
| 预设名称 | 说明 |
|----------|------|
| Claude 官方 | 使用 Anthropic 官方账号登录 |
| DeepSeek | DeepSeek 模型 |
| 智谱 GLM | 智谱 AI 的 GLM 模型 |
| 智谱 GLM en | 智谱 AI(英文版) |
| 百炼 | 阿里云百炼(通义千问) |
| Kimi | Moonshot Kimi 模型 |
| Kimi For Coding | Kimi 编程专用模型 |
| ModelScope | 魔搭社区 |
| KAT-Coder | KAT-Coder 模型 |
| Longcat | Longcat AI |
| MiniMax | MiniMax 模型 |
| MiniMax en | MiniMax(英文版) |
| DouBaoSeed | 豆包 Seed 模型 |
| BaiLing | 百灵 AI |
| AiHubMix | AiHubMix 聚合服务 |
| SiliconFlow | 硅基流动 |
| SiliconFlow en | 硅基流动(英文版) |
| DMXAPI | DMXAPI 中转服务 |
| PackyCode | PackyCode 中转服务 ⭐ |
| Cubence | Cubence 服务 |
| AIGoCode | AIGoCode 服务 |
| RightCode | RightCode 服务 |
| AICodeMirror | AICodeMirror 服务 |
| OpenRouter | 聚合路由服务 |
| Nvidia | Nvidia AI 服务 |
| Xiaomi MiMo | 小米 MiMo 模型 |
> ⭐ 标注为官方合作伙伴。预设列表可能随版本更新,以应用内实际显示为准。
#### Codex 预设
| 预设名称 | 说明 |
|----------|------|
| OpenAI 官方 | 使用 OpenAI 官方账号登录 |
| Azure OpenAI | Azure OpenAI 服务 |
| AiHubMix | AiHubMix 聚合服务 |
| DMXAPI | DMXAPI 中转服务 |
| PackyCode | PackyCode 中转服务 |
| Cubence | Cubence 服务 |
| AIGoCode | AIGoCode 服务 |
| RightCode | RightCode 服务 |
| AICodeMirror | AICodeMirror 服务 |
| OpenRouter | 聚合路由服务 |
#### Gemini 预设
| 预设名称 | 说明 |
|----------|------|
| Google 官方 | 使用 Google OAuth 登录 |
| PackyCode | PackyCode 中转服务 |
| Cubence | Cubence 服务 |
| AIGoCode | AIGoCode 服务 |
| AICodeMirror | AICodeMirror 服务 |
| OpenRouter | 聚合路由服务 |
| 自定义 | 手动配置所有参数 |
#### OpenCode 预设
| 预设名称 | 说明 |
|----------|------|
| DeepSeek | DeepSeek 模型 |
| 智谱 GLM | 智谱 AI 的 GLM 模型 |
| 智谱 GLM en | 智谱 AI(英文版) |
| 百炼 | 阿里云百炼 |
| Kimi k2.5 | Moonshot Kimi-k2.5 模型 |
| Kimi For Coding | Kimi 编程专用模型 |
| ModelScope | 魔搭社区 |
| KAT-Coder | KAT-Coder 模型 |
| Longcat | Longcat AI |
| MiniMax | MiniMax 模型 |
| MiniMax en | MiniMax(英文版) |
| DouBaoSeed | 豆包 Seed 模型 |
| BaiLing | 百灵 AI |
| Xiaomi MiMo | 小米 MiMo 模型 |
| AiHubMix | AiHubMix 聚合服务 |
| DMXAPI | DMXAPI 中转服务 |
| OpenRouter | 聚合路由服务 |
| Nvidia | Nvidia AI 服务 |
| PackyCode | PackyCode 中转服务 |
| Cubence | Cubence 服务 |
| AIGoCode | AIGoCode 服务 |
| RightCode | RightCode 服务 |
| AICodeMirror | AICodeMirror 服务 |
| OpenAI Compatible | OpenAI 兼容接口 |
| Oh My OpenCode | Oh My OpenCode 服务 |
> 💡 预设列表持续更新中,以应用内实际显示为准。
## 自定义配置
选择「自定义」预设后,需要手动编辑 JSON 配置。
### Claude 配置格式
```json
{
"env": {
"ANTHROPIC_API_KEY": "your-api-key",
"ANTHROPIC_BASE_URL": "https://api.example.com"
}
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `ANTHROPIC_API_KEY` | 是 | API 密钥 |
| `ANTHROPIC_BASE_URL` | 否 | 自定义端点地址 |
| `ANTHROPIC_AUTH_TOKEN` | 否 | 替代 API_KEY 的认证方式 |
### Codex 配置格式
Codex 使用两个配置文件:
**1. auth.json**`~/.codex/auth.json`- 存储 API 密钥:
```json
{
"OPENAI_API_KEY": "your-api-key"
}
```
**2. config.toml**`~/.codex/config.toml`- 存储模型和端点配置:
```toml
# 基础配置
model_provider = "custom"
model = "gpt-5.2"
model_reasoning_effort = "high"
disable_response_storage = true
# 自定义供应商配置
[model_providers.custom]
name = "custom"
base_url = "https://api.example.com/v1"
wire_api = "responses"
requires_openai_auth = true
```
**auth.json 字段说明**
| 字段 | 必填 | 说明 |
|------|------|------|
| `OPENAI_API_KEY` | 是 | API 密钥 |
**config.toml 字段说明**
| 字段 | 必填 | 说明 |
|------|------|------|
| `model_provider` | 是 | 模型提供商名称(需与 `[model_providers.xxx]` 匹配) |
| `model` | 是 | 使用的模型(如 `gpt-5.2``gpt-4o` |
| `model_reasoning_effort` | 否 | 推理强度:`low` / `medium` / `high` |
| `disable_response_storage` | 否 | 是否禁用响应存储 |
| `base_url` | 是 | API 端点地址 |
| `wire_api` | 否 | API 协议类型(通常为 `responses` |
| `requires_openai_auth` | 否 | 是否使用 OpenAI 认证方式 |
### Gemini 配置格式
```json
{
"env": {
"GEMINI_API_KEY": "your-api-key",
"GOOGLE_GEMINI_BASE_URL": "https://api.example.com"
}
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `GEMINI_API_KEY` | 是 | API 密钥 |
| `GOOGLE_GEMINI_BASE_URL` | 否 | 自定义端点地址 |
| `GEMINI_MODEL` | 否 | 指定模型 |
> 💡 认证类型由 CC Switch 自动检测(PackyCode API 代理 / Google OAuth / 通用 API Key),无需手动配置。
## 统一供应商
统一供应商可以跨 Claude/Codex/Gemini/OpenCode 共享配置,适用于支持多种 API 格式的中转服务。
### 创建统一供应商
1. 切换到「统一供应商」Tab
2. 点击「添加统一供应商」
3. 填写通用配置:
- 名称
- API Key
- 端点地址
4. 勾选要同步的应用(Claude/Codex/Gemini/OpenCode
5. 保存
### 同步机制
统一供应商会自动同步到勾选的应用:
- 修改统一供应商后,所有关联应用的配置同步更新
- 删除统一供应商后,关联的应用配置也会删除
### 保存并同步
编辑统一供应商时,可以选择:
| 操作 | 说明 |
|------|------|
| 保存 | 仅保存配置,不立即同步 |
| 保存并同步 | 保存配置并立即同步到所有启用的应用 |
### 手动同步
如果需要手动触发同步:
1. 在统一供应商卡片上点击「同步」按钮
2. 确认同步操作
3. 配置会覆盖各应用中关联的供应商
## 导入供应商
CC Switch 支持两种方式导入供应商配置:
### 方式一:深度链接导入
通过 `ccswitch://` 协议链接一键导入:
1. 点击或访问深度链接
2. CC Switch 自动打开并显示导入确认
3. 预览配置信息
4. 点击「确认导入」
**获取深度链接**
- 从他人分享获取
- 使用 [在线生成工具](https://farion1231.github.io/cc-switch/deplink.html) 创建
### 方式二:数据库备份导入
从 SQL 备份文件批量导入:
1. 打开「设置 → 高级 → 数据管理」
2. 点击「选择文件」
3. 选择之前导出的 `.sql` 备份文件
4. 点击「导入」
5. 确认覆盖现有配置
**导入内容**
- 所有供应商配置
- MCP 服务器配置
- Prompts 预设
- 用量日志
> ⚠️ **注意**:导入会覆盖现有数据库,建议先导出当前配置作为备份。导出的文件名格式为 `cc-switch-export-{时间戳}.sql`。
## 高级选项
### 自定义图标
点击名称左侧的图标区域,可以:
- 选择预设图标
- 自定义图标颜色
### 网站链接
填写供应商的官网或控制台地址,方便快速访问:
- 点击供应商卡片的链接图标可直接打开
- 用于查看余额、获取 API Key 等
### 备注
添加备注信息,如:
- 账号用途(个人/工作)
- 套餐信息
- 到期时间
备注会显示在供应商卡片上,也支持搜索。
### 端点测速
添加供应商后,可以对 API 端点进行速度测试:
1. 点击供应商卡片的「测速」按钮
2. 在测速面板中添加多个端点 URL
3. 点击「测速」执行测试
4. 选择延迟最低的端点
**测速结果**
- 🟢 绿色:延迟 < 500ms(优秀)
- 🟡 黄色:延迟 500-1000ms(一般)
- 🔴 红色:延迟 > 1000ms(较慢)
![image-20260108005327817](../assets/image-20260108005327817.png)
-111
View File
@@ -1,111 +0,0 @@
# 2.2 切换供应商
## 主界面切换
在供应商列表中,点击目标供应商卡片的「启用」按钮。
### 切换流程
1. 点击「启用」按钮
2. CC Switch 更新配置文件
3. 卡片状态变为「当前启用」
4. Claude/Gemini 即时生效,Codex 需重启终端
### 状态指示
| 状态 | 显示 | 说明 |
|------|------|------|
| 当前启用 | 蓝色边框 + 标签 | 配置文件中的当前供应商 |
| 代理活跃 | 绿色边框 | 代理模式下实际使用的供应商 |
| 普通 | 默认样式 | 未启用的供应商 |
## 托盘快速切换
通过系统托盘可以快速切换,无需打开主界面。
### 操作步骤
1. 右键点击系统托盘的 CC Switch 图标
2. 在菜单中找到对应应用(Claude/Codex/Gemini/OpenCode
3. 点击要切换的供应商名称
4. 切换完成,托盘会短暂提示
### 托盘菜单结构
![image-20260108004348993](../assets/image-20260108004348993.png)
## 生效方式
### Claude Code
**切换后即时生效**,无需重启。
Claude Code 支持热重载,会自动检测配置文件变更并重新加载。
### Codex
切换后需要重启:
- 关闭当前终端窗口
- 重新打开终端
### Gemini CLI
**切换后即时生效**,无需重启。
Gemini CLI 每次请求都会重新读取 `.env` 文件。
## 配置文件变更
切换供应商时,CC Switch 会修改以下文件:
### Claude
```
~/.claude/settings.json
```
修改内容:
```json
{
"env": {
"ANTHROPIC_API_KEY": "新的 API Key",
"ANTHROPIC_BASE_URL": "新的端点"
}
}
```
### Codex
```
~/.codex/auth.json
~/.codex/config.toml(如有额外配置)
```
### Gemini
```
~/.gemini/.env
~/.gemini/settings.json
```
## 切换失败处理
如果切换失败,可能的原因:
### 配置文件被锁定
其他程序正在使用配置文件。
**解决方法**:关闭正在运行的 CLI 工具,再尝试切换。
### 权限不足
没有写入配置文件的权限。
**解决方法**:检查配置目录的权限设置。
### 配置格式错误
供应商配置的 JSON 格式有误。
**解决方法**:编辑供应商,检查并修复 JSON 格式。
-145
View File
@@ -1,145 +0,0 @@
# 2.3 编辑供应商
## 打开编辑面板
1. 找到要编辑的供应商卡片
2. 鼠标悬停在卡片上,显示操作按钮
3. 点击「编辑」按钮
## 可编辑内容
### 基本信息
| 字段 | 说明 |
|------|------|
| 名称 | 供应商显示名称 |
| 备注 | 附加说明信息 |
| 网站链接 | 供应商官网或控制台地址 |
| 图标 | 自定义图标和颜色 |
### 图标自定义
CC Switch 提供丰富的图标自定义功能:
#### 图标选择器
1. 点击图标区域打开图标选择器
2. 使用搜索框按名称搜索图标
3. 点击选择想要的图标
图标库包含常见的 AI 服务商和技术图标,支持:
- 按名称模糊搜索
- 显示图标名称提示
- 实时预览选中效果
![image-20260108004734882](../assets/image-20260108004734882.png)
### 配置信息
JSON 格式的配置内容,包括:
- API Key
- 端点地址
- 其他环境变量
### 编辑当前启用的供应商
编辑当前启用的供应商时,有特殊的「回填」机制:
1. 打开编辑面板时,会从 live 配置文件读取最新内容
2. 如果你在 CLI 工具中手动修改过配置,这些修改会被同步回来
3. 保存后,修改会写入 live 配置文件
这确保了 CC Switch 和 CLI 工具的配置始终同步。
## 修改 API Key
编辑供应商时,可以直接在 **API Key** 输入框中修改:
1. 点击供应商卡片的「编辑」按钮
2. 在「API Key」输入框中输入新的密钥
3. 点击「保存」
> 💡 **提示**:API Key 输入框支持显示/隐藏切换,点击右侧的眼睛图标可查看完整密钥。
## 修改端点地址
编辑供应商时,可以直接在 **端点地址** 输入框中修改:
1. 点击供应商卡片的「编辑」按钮
2. 在「端点地址」输入框中输入新的 URL
3. 点击「保存」
### 端点地址格式
| 应用 | 格式示例 |
|------|----------|
| Claude | `https://api.example.com` |
| Codex | `https://api.example.com/v1` |
| Gemini | `https://api.example.com` |
## 添加自定义端点
供应商可以配置多个端点,用于:
- 速度测试时测试多个地址
- 故障转移时的备用端点
### 自动收集
添加供应商时,CC Switch 会自动从配置中提取端点地址。
### 手动添加
编辑供应商时,在「端点管理」区域可以:
- 添加新端点
- 删除现有端点
- 设置默认端点
## JSON 编辑器
配置使用 JSON 格式,编辑器提供:
- 语法高亮
- 格式校验
- 错误提示
### 常见错误
**缺少引号**
```json
// ❌ 错误
{ env: { KEY: "value" } }
// ✅ 正确
{ "env": { "KEY": "value" } }
```
**多余逗号**
```json
// ❌ 错误
{ "env": { "KEY": "value", } }
// ✅ 正确
{ "env": { "KEY": "value" } }
```
**未闭合括号**
```json
// ❌ 错误
{ "env": { "KEY": "value" }
// ✅ 正确
{ "env": { "KEY": "value" } }
```
## 保存与生效
1. 点击「保存」按钮
2. 如果是当前启用的供应商,配置立即写入 live 文件
3. 重启 CLI 工具生效
## 取消编辑
点击「取消」或按 `Esc` 键关闭编辑面板,所有修改都不会保存。
@@ -1,76 +0,0 @@
# 2.4 排序与复制
## 拖拽排序
通过拖拽调整供应商的显示顺序。
### 操作步骤
1. 将鼠标移到供应商卡片左侧的 **≡** 拖拽手柄
2. 按住鼠标左键
3. 上下拖动到目标位置
4. 松开鼠标完成排序
### 排序用途
- **常用优先**:将常用的供应商放在列表顶部
- **故障转移顺序**:排序会影响故障转移队列的默认顺序
## 复制供应商
快速创建供应商的副本,适用于:
- 基于现有配置创建变体
- 备份当前配置
- 创建测试用配置
### 操作步骤
1. 鼠标悬停在供应商卡片上,显示操作按钮
2. 点击「复制」按钮
3. 自动创建副本,名称后缀 `copy`
4. 编辑副本修改配置
### 复制内容
复制会创建完整的副本,包括:
| 内容 | 是否复制 |
|------|----------|
| 名称 | ✅ 复制(添加 `copy` 后缀) |
| 配置 | ✅ 完整复制 |
| 备注 | ✅ 复制 |
| 网站链接 | ✅ 复制 |
| 图标 | ✅ 复制 |
| 端点列表 | ✅ 复制 |
| 排序位置 | ✅ 插入到原供应商下方 |
### 复制后编辑
复制完成后,通常需要修改:
1. **名称**:改为有意义的名称
2. **API Key**:如果是不同账号
3. **端点**:如果是不同服务
## 删除供应商
### 操作步骤
1. 鼠标悬停在供应商卡片上,显示操作按钮
2. 点击「删除」按钮
3. 确认删除
### 删除确认
删除前会弹出确认对话框,显示:
- 供应商名称
- 删除后无法恢复的提示
### 删除限制
- **当前启用的供应商**:可以删除,但建议先切换到其他供应商
- **统一供应商**:删除后,关联的应用配置也会被删除
![image-20260108004946288](../assets/image-20260108004946288.png)
@@ -1,181 +0,0 @@
# 2.5 用量查询
## 功能说明
用量查询功能允许你配置自定义脚本,实时查询供应商的剩余额度、已用量等信息。
**使用场景**
- 查看 API 账户剩余余额
- 监控套餐使用情况
- 多套餐额度汇总显示
## 打开配置
1. 鼠标悬停在供应商卡片上,显示操作按钮
2. 点击「用量查询」按钮(📊 图标)
3. 打开用量查询配置面板
## 启用用量查询
在配置面板顶部,开启「启用用量查询」开关。
## 预设模板
CC Switch 提供三种预设模板:
### 自定义模板
完全自定义请求和提取逻辑,适用于特殊 API 格式。
### 通用模板
适用于大多数标准 API 格式的供应商:
```javascript
({
request: {
url: "{{baseUrl}}/user/balance",
method: "GET",
headers: {
"Authorization": "Bearer {{apiKey}}",
"User-Agent": "cc-switch/1.0"
}
},
extractor: function(response) {
return {
isValid: response.is_active || true,
remaining: response.balance,
unit: "USD"
};
}
})
```
**配置参数**
| 参数 | 说明 |
|------|------|
| API Key | 用于认证的密钥(可选,留空则使用供应商配置的 Key) |
| Base URL | API 基础地址(可选,留空则使用供应商端点) |
### New API 模板
专为 New API 类型的中转服务设计:
```javascript
({
request: {
url: "{{baseUrl}}/api/user/self",
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer {{accessToken}}",
"New-Api-User": "{{userId}}"
},
},
extractor: function (response) {
if (response.success && response.data) {
return {
planName: response.data.group || "默认套餐",
remaining: response.data.quota / 500000,
used: response.data.used_quota / 500000,
total: (response.data.quota + response.data.used_quota) / 500000,
unit: "USD",
};
}
return {
isValid: false,
invalidMessage: response.message || "查询失败"
};
},
})
```
**配置参数**
| 参数 | 说明 |
|------|------|
| Base URL | New API 服务地址 |
| Access Token | 访问令牌 |
| User ID | 用户 ID |
## 通用配置
### 超时时间
请求超时时间(秒),默认 10 秒。
### 自动查询间隔
自动刷新用量数据的间隔(分钟):
- 设为 `0` 表示禁用自动查询
- 范围:0-1440 分钟(最长 24 小时)
- 仅当供应商处于「当前启用」状态时生效
## 提取器返回格式
提取器函数需要返回包含以下字段的对象:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `isValid` | boolean | 否 | 账户是否有效,默认 true |
| `invalidMessage` | string | 否 | 无效时的提示信息 |
| `remaining` | number | 是 | 剩余额度 |
| `unit` | string | 是 | 单位(如 USD、CNY、次) |
| `planName` | string | 否 | 套餐名称(支持多套餐) |
| `total` | number | 否 | 总额度 |
| `used` | number | 否 | 已使用额度 |
| `extra` | object | 否 | 额外信息 |
## 测试脚本
配置完成后,点击「测试脚本」按钮验证:
1. 发送请求到配置的 URL
2. 执行提取器函数
3. 显示返回结果或错误信息
## 显示效果
配置成功后,供应商卡片上会显示:
- **单套餐**:直接显示剩余额度
- **多套餐**:显示套餐数量,点击展开查看详情
## 变量占位符
脚本中可使用以下占位符,运行时自动替换:
| 占位符 | 说明 |
|--------|------|
| `{{apiKey}}` | 配置的 API Key |
| `{{baseUrl}}` | 配置的 Base URL |
| `{{accessToken}}` | 配置的 Access TokenNew API |
| `{{userId}}` | 配置的 User IDNew API |
## 常见供应商配置示例
### 故障排除
### 查询失败
**检查**
1. API Key 是否正确
2. Base URL 是否正确
3. 网络是否可访问
4. 超时时间是否足够
### 返回数据为空
**检查**
1. 提取器函数是否有 `return` 语句
2. 响应数据结构是否与提取器匹配
3. 使用「测试脚本」查看原始响应
### 格式化失败
脚本语法错误时,点击「格式化」按钮会提示错误位置。
## 注意事项
- 用量查询会消耗少量 API 请求配额
- 建议设置合理的自动查询间隔,避免频繁请求
- 敏感信息(API Key、Token)会安全存储在本地
-207
View File
@@ -1,207 +0,0 @@
# 3.1 MCP 服务器管理
## 什么是 MCP
MCP (Model Context Protocol) 是一种协议,允许 AI 工具访问外部数据源和工具。通过 MCP 服务器,你可以让 AI:
- 访问文件系统
- 执行网络请求
- 查询数据库
- 调用外部 API
## 打开 MCP 面板
点击顶部导航栏的 **MCP** 按钮。
## 面板概览
![image-20260108005723522](../assets/image-20260108005723522.png)
## 添加 MCP 服务器
### 使用预设模板
1. 点击右上角 **+** 按钮
2. 在「预设」下拉框中选择模板
3. 根据需要修改配置
4. 点击「保存」
![image-20260108005739731](../assets/image-20260108005739731.png)
### 常用预设
| 预设 | 包名 | 功能说明 |
|------|------|----------|
| fetch | mcp-server-fetch | HTTP 请求工具,让 AI 能够获取网页内容 |
| time | @modelcontextprotocol/server-time | 时间工具,提供当前时间信息 |
| memory | @modelcontextprotocol/server-memory | 记忆工具,让 AI 能够存储和检索信息 |
| sequential-thinking | @modelcontextprotocol/server-sequential-thinking | 思维链工具,增强 AI 推理能力 |
| context7 | @upstash/context7-mcp | 文档搜索工具,查询技术文档 |
### 自定义配置
选择「自定义」后,需要填写:
| 字段 | 必填 | 说明 |
|------|------|------|
| 服务器 ID | 是 | 唯一标识符 |
| 名称 | 否 | 显示名称 |
| 描述 | 否 | 功能说明 |
| 传输类型 | 是 | stdio / http / sse |
| 命令 | 是* | stdio 类型必填 |
| 参数 | 否 | 命令行参数 |
| URL | 是* | http/sse 类型必填 |
| Headers | 否 | http/sse 类型的请求头 |
| 环境变量 | 否 | 传递给服务器的环境变量 |
## 传输类型
### stdio(标准输入输出)
最常用的类型,通过启动本地进程通信。
```json
{
"command": "uvx",
"args": ["mcp-server-fetch"],
"env": {}
}
```
**要求**
- 需要安装对应的命令(如 `uvx``npx`
- 服务器程序需要在 PATH 中
### http
通过 HTTP 协议与远程服务器通信。
```json
{
"url": "http://localhost:8080/mcp"
}
```
### sseServer-Sent Events
通过 SSE 协议与服务器通信,支持实时推送。
```json
{
"url": "http://localhost:8080/sse"
}
```
## 应用绑定
每个 MCP 服务器可以独立控制启用的应用。
### 开关说明
| 开关 | 作用 | 配置文件路径 |
|------|------|--------------|
| Claude | 同步到 Claude Code | `~/.claude.json``mcpServers` |
| Codex | 同步到 Codex | `~/.codex/config.toml``[mcp_servers]` |
| Gemini | 同步到 Gemini CLI | `~/.gemini/settings.json``mcpServers` |
| OpenCode | 同步到 OpenCode | `~/.opencode/config.json``mcpServers` |
### 开关实现机制
当开启某个应用的开关时,CC Switch 会:
1. **更新数据库**:将服务器的 `apps.claude/codex/gemini/opencode` 状态设为 `true`
2. **同步到 Live 配置**:将服务器配置写入对应应用的配置文件
3. **即时生效**:下次启动 CLI 工具时自动加载新的 MCP 服务器
当关闭某个应用的开关时,CC Switch 会:
1. **更新数据库**:将对应应用状态设为 `false`
2. **从 Live 配置移除**:从应用配置文件中删除该服务器
3. **即时生效**:下次启动 CLI 工具时不再加载该 MCP 服务器
### 同步条件
MCP 服务器同步仅在对应应用已安装时执行:
- **Claude**:需存在 `~/.claude/` 目录或 `~/.claude.json` 文件
- **Codex**:需存在 `~/.codex/` 目录
- **Gemini**:需存在 `~/.gemini/` 目录
- **OpenCode**:需存在 `~/.opencode/` 目录
> 💡 **提示**:如果某个 CLI 工具未安装,开启对应开关不会报错,但配置不会写入。
关闭开关后,配置会从文件中移除。
## 编辑服务器
1. 点击服务器行右侧的「编辑」按钮
2. 修改配置
3. 点击「保存」
修改会立即同步到已启用的应用配置文件。
## 删除服务器
1. 点击服务器行右侧的「删除」按钮
2. 确认删除
删除后,配置会从所有应用的配置文件中移除。
## 导入现有配置
如果你已经在 CLI 工具中配置了 MCP 服务器,可以导入到 CC Switch:
1. 点击「导入」按钮
2. 选择要导入的应用(Claude/Codex/Gemini/OpenCode
3. CC Switch 会读取现有配置并导入
## 配置文件格式
### Claude (`~/.claude.json`)
```json
{
"mcpServers": {
"mcp-fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"]
}
}
}
```
### Codex (`~/.codex/config.toml`)
```toml
[mcp_servers.mcp-fetch]
command = "uvx"
args = ["mcp-server-fetch"]
```
### Gemini (`~/.gemini/settings.json`)
```json
{
"mcpServers": {
"mcp-fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"]
}
}
}
```
## 常见问题
### 服务器启动失败
检查:
- 命令是否正确安装(如 `uvx`
- 命令是否在 PATH 中
- 参数是否正确
### 配置不生效
确保:
- 对应应用的开关已开启
- 重启了 CLI 工具
@@ -1,158 +0,0 @@
# 3.2 Prompts 提示词管理
## 功能说明
Prompts 功能用于管理系统提示词预设。系统提示词会影响 AI 的行为和回复风格。
通过 CC Switch,你可以:
- 创建多个提示词预设
- 快速切换不同场景的提示词
- 跨设备同步提示词配置
## 打开 Prompts 面板
点击顶部导航栏的 **Prompts** 按钮。
## 面板概览
![image-20260108010110382](../assets/image-20260108010110382.png)
## 创建预设
### 操作步骤
1. 点击右上角 **+** 按钮
2. 输入预设名称
3. 在 Markdown 编辑器中编写提示词
4. 点击「保存」
### Markdown 编辑器
编辑器提供:
- 语法高亮
- 实时预览
- 常用格式快捷键
### 提示词编写建议
**结构化格式**
```markdown
# 角色定义
你是一个专业的代码审查专家。
## 核心能力
- 代码质量分析
- 性能优化建议
- 安全漏洞检测
## 回复风格
- 简洁明了
- 提供具体示例
- 给出改进建议
## 注意事项
- 不要修改业务逻辑
- 保持代码风格一致
```
## 激活预设
### 操作方式
点击预设项的开关按钮,切换启用状态。
### 单一激活
同一时间只能激活一个预设。激活新预设时,之前的预设会自动停用。
### 同步目标
激活后,提示词会写入对应应用的文件:
| 应用 | 文件路径 |
|------|----------|
| Claude | `~/.claude/CLAUDE.md` |
| Codex | `~/.codex/AGENTS.md` |
| Gemini | `~/.gemini/GEMINI.md` |
| OpenCode | `~/.opencode/AGENTS.md` |
## 编辑预设
1. 点击预设项的「编辑」按钮
2. 修改名称或内容
3. 点击「保存」
如果编辑的是当前激活的预设,保存后会立即同步到配置文件。
## 删除预设
1. 点击预设项的「删除」按钮
2. 确认删除
已启用的预设不允许删除,需先停用后再删除。
## 智能回填
CC Switch 提供智能回填保护机制,确保你的手动修改不会丢失。
### 工作原理
1. 切换预设前,自动读取当前配置文件内容
2. 比较文件内容与数据库中的预设
3. 如果内容不同,说明用户手动修改过
4. 将手动修改的内容保存到当前预设
5. 然后再切换到新预设
### 保护场景
| 场景 | 处理方式 |
|------|----------|
| CLI 中直接编辑 `CLAUDE.md` | 修改自动保存到当前预设 |
| 外部编辑器修改配置文件 | 修改自动保存到当前预设 |
| 切换到其他预设 | 先保存当前修改,再切换 |
### 技术细节
回填机制在以下时机触发:
- **切换预设时**:保存当前 live 文件内容到当前预设
- **编辑当前预设时**:从 live 文件读取最新内容
- **首次启动时**:自动导入现有 live 文件内容
### 注意事项
- 回填仅在切换到不同预设时触发
- 如果当前没有激活的预设,不会触发回填
- 回填失败不会影响切换流程
## 跨应用使用
Prompts 是按应用分开管理的:
- 切换到 Claude 时,显示 Claude 的预设
- 切换到 Codex 时,显示 Codex 的预设
- 切换到 Gemini 时,显示 Gemini 的预设
- 切换到 OpenCode 时,显示 OpenCode 的预设
如需在多个应用使用相同的提示词,需要分别创建。
## 导入导出
### 通过深度链接分享
可以生成深度链接分享预设:
```
ccswitch://import/prompt?data=<base64编码的预设>
```
### 通过配置导出
导出配置时会包含所有预设,导入后可恢复。
-199
View File
@@ -1,199 +0,0 @@
# 3.3 Skills 技能管理
## 功能说明
Skills 是可复用的能力扩展,让 AI 工具获得特定领域的专业能力。
技能以文件夹形式存在,包含:
- 提示词模板
- 工具定义
- 示例代码
## 支持的应用
Skills 功能支持所有四种应用:
- **Claude Code**
- **Codex**
- **Gemini CLI**
- **OpenCode**
## 打开 Skills 页面
点击顶部导航栏的 **Skills** 按钮。
> 注意:Skills 按钮在所有应用模式下均可见。
## 页面概览
![image-20260108010253926](../assets/image-20260108010253926.png)
## 发现技能
### 预配置仓库
CC Switch 预配置了以下 GitHub 仓库:
| 仓库 | 说明 |
|------|------|
| Anthropic 官方 | Anthropic 提供的官方技能 |
| ComposioHQ | 社区维护的技能集合 |
| 社区精选 | 精选的高质量技能 |
![image-20260108010308060](../assets/image-20260108010308060.png)
### 搜索过滤
CC Switch 提供强大的搜索和过滤功能:
#### 搜索框
- 支持按技能名称搜索
- 支持按技能描述搜索
- 支持按目录名称搜索
- 实时过滤,输入即搜索
#### 状态过滤
使用下拉菜单按安装状态过滤:
| 选项 | 说明 |
|------|------|
| 全部 | 显示所有技能 |
| 已安装 | 仅显示已安装的技能 |
| 未安装 | 仅显示未安装的技能 |
![image-20260108010324583](../assets/image-20260108010324583.png)
#### 组合使用
搜索和过滤可以组合使用:
- 先选择「已安装」过滤
- 再输入关键词搜索
- 结果显示匹配数量
### 刷新列表
点击「刷新」按钮重新扫描仓库,获取最新技能。
## 安装技能
### 操作步骤
1. 找到要安装的技能卡片
2. 点击「安装」按钮
3. 等待安装完成
### 安装位置
| 应用 | 安装目录 |
|------|----------|
| Claude | `~/.claude/skills/` |
| Codex | `~/.codex/skills/` |
| Gemini | `~/.gemini/skills/` |
| OpenCode | `~/.opencode/skills/` |
### 安装内容
安装会将技能文件夹复制到本地:
```
~/.claude/skills/
└── skill-name/
├── README.md
├── prompt.md
└── tools/
└── ...
```
## 卸载技能
### 操作步骤
1. 找到已安装的技能卡片
2. 点击「卸载」按钮
3. 确认卸载
### 卸载效果
- 删除本地技能文件夹
- 更新安装状态
## 仓库管理
### 打开仓库管理
点击页面顶部的「仓库管理」按钮。
### 添加自定义仓库
1. 点击「添加仓库」
2. 填写仓库信息:
- OwnerGitHub 用户名或组织名
- Name:仓库名称
- Branch:分支名(默认 main
- Subdirectory:技能所在子目录(可选)
3. 点击「添加」
### 仓库格式
```
https://github.com/{owner}/{name}/tree/{branch}/{subdirectory}
```
示例:
```
Owner: anthropics
Name: claude-skills
Branch: main
Subdirectory: skills
```
### 删除仓库
1. 在仓库列表中找到要删除的仓库
2. 点击「删除」按钮
3. 确认删除
删除仓库后,该仓库的技能不会从列表中消失,但无法再更新。
## 技能卡片信息
每个技能卡片显示:
| 信息 | 说明 |
|------|------|
| 名称 | 技能名称 |
| 描述 | 功能说明 |
| 来源 | 所属仓库 |
| 状态 | 已安装 / 未安装 |
## 技能更新
目前不支持自动更新。如需更新技能:
1. 卸载现有技能
2. 刷新列表
3. 重新安装
### 技能列表为空
可能原因:
- 网络问题,无法访问 GitHub
- 仓库配置错误
解决方法:
- 检查网络连接
- 点击「刷新」重试
- 检查仓库配置
### 安装失败
可能原因:
- 网络问题
- 磁盘空间不足
- 权限问题
解决方法:
- 检查网络连接
- 检查磁盘空间
- 检查目录权限
-222
View File
@@ -1,222 +0,0 @@
# 4.1 代理服务
## 功能说明
代理服务在本地启动一个 HTTP 代理,所有 API 请求都通过代理转发。
**主要用途**
- 记录请求日志
- 统计 API 用量
- 支持故障转移
- 集中管理多个应用的请求
## 启动代理
### 方式一:主界面开关
点击主界面顶部的 **代理开关** 按钮。
开关状态:
- 🔴 白色:代理未运行
- 🟢 绿色:代理运行中
![image-20260108011353927](../assets/image-20260108011353927.png)
### 方式二:设置页面
1. 打开「设置 → 高级 → 代理服务」
2. 点击右上角的开关
![image-20260108011338922](../assets/image-20260108011338922.png)
## 代理配置
### 基础配置
| 配置项 | 说明 | 默认值 |
|--------|------|--------|
| 监听地址 | 代理绑定的 IP 地址 | `127.0.0.1` |
| 监听端口 | 代理监听的端口 | `15721` |
| 启用日志 | 是否记录请求日志 | 开启 |
### 修改配置
1. **停止代理服务**(必须先停止)
2. 修改监听地址或端口
3. 点击「保存」
4. 重新启动代理
> ⚠️ 修改地址/端口需要先停止代理服务
### 监听地址说明
| 地址 | 说明 |
|------|------|
| `127.0.0.1` | 仅本机可访问(推荐) |
| `0.0.0.0` | 允许局域网访问 |
## 运行状态
代理运行时,面板显示以下信息:
### 服务地址
```
http://127.0.0.1:15721
```
点击「复制」按钮可复制地址。
### 当前供应商
显示各应用当前使用的供应商:
```
Claude: PackyCode
Codex: AIGoCode
Gemini: Google 官方
```
### 统计数据
| 指标 | 说明 |
|------|------|
| 活跃连接 | 当前正在处理的请求数 |
| 总请求数 | 启动以来的总请求数 |
| 成功率 | 请求成功的百分比(>90% 绿色,≤90% 黄色) |
| 运行时间 | 代理已运行的时长 |
### 故障转移队列
代理面板会按应用类型显示故障转移队列:
```
Claude
├── 1. PackyCode [当前使用] ●
├── 2. AIGoCode ●
└── 3. 备用供应商 ○
Codex
├── 1. AIGoCode [当前使用] ●
└── 2. 备用供应商 ●
```
队列说明:
- 数字表示优先级顺序
- 「当前使用」标签表示正在使用的供应商
- 健康徽章显示供应商状态:
- 🟢 绿色:健康(连续失败 0 次)
- 🟡 黄色:降级(连续失败 1-2 次)
- 🔴 红色:不健康(连续失败 ≥3 次)
## 工作原理
### 请求流程
```mermaid
sequenceDiagram
participant CLI as CLI 工具 (Claude)
participant Proxy as 本地代理 (CC Switch)
participant API as API 供应商 (Anthropic)
participant DB as 数据存储 (Logger)
CLI->>Proxy: 发送 API 请求
Proxy->>DB: 记录请求日志/统计用量
Proxy->>API: 转发请求
API-->>Proxy: 返回响应
Proxy-->>CLI: 返回响应
```
### 配置修改
启动代理并开启应用接管后,CC Switch 会修改应用配置:
**Claude**
```json
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
}
}
```
**Codex**
```toml
base_url = "http://127.0.0.1:15721/v1"
```
**Gemini**
```
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
```
## 停止代理
### 方式一:主界面开关
点击代理开关按钮关闭。
### 方式二:设置页面
在代理服务面板中关闭开关。
### 停止后的处理
停止代理时,CC Switch 会:
1. 恢复应用配置到原始状态
2. 保存请求日志
3. 关闭所有连接
## 日志记录
### 开启日志
在代理面板中开启「启用日志」开关。
### 日志内容
每条请求记录包含:
| 字段 | 说明 |
|------|------|
| 时间 | 请求时间 |
| 应用 | Claude/Codex/Gemini/OpenCode |
| 供应商 | 使用的供应商 |
| 模型 | 请求的模型 |
| Token | 输入/输出 token 数 |
| 延迟 | 请求耗时 |
| 状态 | 成功/失败 |
### 查看日志
在「设置 → 用量」Tab 中查看请求日志。
## 常见问题
### 端口被占用
错误信息:`Address already in use`
解决方法:
1. 更换端口(如 5001
2. 或关闭占用端口的程序
### 代理启动失败
检查:
- 端口是否被占用
- 是否有足够权限
- 防火墙是否阻止
### 请求超时
可能原因:
- 网络问题
- 供应商服务器问题
- 代理配置错误
解决方法:
- 检查网络连接
- 尝试直接访问供应商 API
- 检查供应商配置
-196
View File
@@ -1,196 +0,0 @@
# 4.2 应用接管
## 功能说明
应用接管是指让 CC Switch 代理接管特定应用的 API 请求。
开启接管后:
- 应用的 API 请求会通过本地代理转发
- 可以记录请求日志和统计用量
- 可以使用故障转移功能
## 前提条件
使用应用接管功能前,需要先启动代理服务。
## 开启接管
### 操作位置
设置 → 高级 → 代理服务 → 应用接管区域
### 操作步骤
1. 确保代理服务已启动
2. 找到「应用接管」区域
3. 为需要的应用开启开关
### 接管开关
| 开关 | 作用 |
|------|------|
| Claude 接管 | 接管 Claude Code 的请求 |
| Codex 接管 | 接管 Codex 的请求 |
| Gemini 接管 | 接管 Gemini CLI 的请求 |
| OpenCode 接管 | 接管 OpenCode 的请求 |
可以同时开启多个应用的接管。
## 接管原理
### 配置修改
开启接管后,CC Switch 会修改应用的配置文件,将 API 端点指向本地代理。
**Claude 配置变更**
```json
// 接管前
{
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
}
}
// 接管后
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
}
}
```
**Codex 配置变更**
```toml
# 接管前
base_url = "https://api.openai.com/v1"
# 接管后
base_url = "http://127.0.0.1:15721/v1"
```
**Gemini 配置变更**
```bash
# 接管前
GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
# 接管后
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
```
### 请求转发
代理收到请求后:
1. 识别请求来源(Claude/Codex/Gemini/OpenCode
2. 查找该应用当前启用的供应商
3. 将请求转发到供应商的实际端点
4. 记录请求日志
5. 返回响应给应用
## 接管状态指示
### 主界面指示
开启接管后,主界面会有以下变化:
- **代理 Logo 颜色**:从无色变为绿色
- **供应商卡片**:当前活跃的供应商显示绿色边框
### 供应商卡片状态
| 状态 | 边框颜色 | 说明 |
|------|----------|------|
| 当前启用 | 蓝色 | 配置文件中的供应商(非代理模式) |
| 代理活跃 | 绿色 | 代理实际使用的供应商 |
| 普通 | 默认 | 未使用的供应商 |
## 关闭接管
### 操作步骤
1. 在代理面板中关闭对应应用的接管开关
2. 或直接停止代理服务
### 配置恢复
关闭接管时,CC Switch 会:
1. 将应用配置恢复到接管前的状态
2. 保存当前的请求日志
## 接管与供应商切换
### 接管模式下切换供应商
在接管模式下切换供应商:
1. 在主界面点击供应商的「启用」按钮
2. 代理立即使用新供应商转发请求
3. **无需重启 CLI 工具**
这是接管模式的一大优势:切换供应商即时生效。
### 非接管模式下切换
在非接管模式下切换供应商:
1. 修改配置文件
2. 需要重启 CLI 工具才能生效
## 多应用接管
可以同时接管多个应用,每个应用独立管理:
- 独立的供应商配置
- 独立的故障转移队列
- 独立的请求统计
## 使用场景
### 场景一:用量监控
开启接管 + 日志记录,监控 API 使用情况。
### 场景二:快速切换
开启接管后,切换供应商无需重启 CLI 工具。
### 场景三:故障转移
开启接管是使用故障转移功能的前提。
## 注意事项
### 性能影响
代理会增加少量延迟(通常 < 10ms),对于大多数场景可以忽略。
### 网络要求
接管模式下,CLI 工具需要能够访问本地代理地址。
### 配置备份
开启接管前,CC Switch 会备份原始配置,关闭时恢复。
## 常见问题
### 接管后请求失败
检查:
- 代理服务是否正常运行
- 供应商配置是否正确
- 网络是否正常
### 关闭接管后配置未恢复
可能原因:
- 代理异常退出
- 配置文件被其他程序修改
解决方法:
- 手动编辑供应商,重新保存
- 或重新启用再关闭接管
-233
View File
@@ -1,233 +0,0 @@
# 4.3 故障转移
## 功能说明
故障转移功能在主供应商请求失败时,自动切换到备用供应商,确保服务不中断。
**适用场景**
- 供应商服务不稳定
- 需要高可用性
- 长时间运行的任务
## 前提条件
使用故障转移功能需要:
1. ✅ 启动代理服务
2. ✅ 开启应用接管
3. ✅ 配置故障转移队列
4. ✅ 开启自动故障转移
## 配置故障转移队列
### 打开配置页面
设置 → 高级 → 故障转移
### 选择应用
页面顶部有四个 Tab
- Claude
- Codex
- Gemini
- OpenCode
选择要配置的应用。
### 添加备用供应商
1. 在「故障转移队列」区域
2. 点击「添加供应商」
3. 从下拉列表选择供应商
4. 供应商会添加到队列末尾
### 调整优先级
拖拽供应商调整顺序:
- 序号越小,优先级越高
- 主供应商失败后,按顺序尝试备用供应商
### 移除供应商
点击供应商右侧的「移除」按钮。
## 主界面快捷操作
当代理和故障转移都开启时,供应商卡片会显示故障转移开关。
### 添加到队列
1. 找到供应商卡片
2. 开启故障转移开关
3. 供应商自动添加到队列
### 从队列移除
1. 关闭供应商卡片的故障转移开关
2. 供应商从队列中移除
## 开启自动故障转移
### 操作步骤
1. 在故障转移配置页面
2. 开启「自动故障转移」开关
### 开关说明
| 状态 | 行为 |
|------|------|
| 关闭 | 仅记录失败,不自动切换 |
| 开启 | 失败时自动切换到下一个供应商 |
## 故障转移流程
```mermaid
graph TD
Start[请求到达代理] --> Send[发送到当前供应商]
Send --> CheckSuccess{成功?}
CheckSuccess -- 是 --> Return[返回响应]
CheckSuccess -- 否 --> LogFail[记录失败]
LogFail --> CheckCircuit{检查熔断状态}
CheckCircuit -- 熔断 --> Skip[跳过此供应商]
CheckCircuit -- 未熔断 --> IncFail[增加失败计数]
Skip --> Next{队列中下一个?}
IncFail --> Next
Next -- 有 --> Switch[切换供应商]
Switch --> Retry[重试请求]
Retry --> Send
Next -- 无 --> Error[返回错误]
```
## 熔断器配置
熔断器防止频繁重试失败的供应商。
### 配置项
不同应用有独立的默认配置。以下为通用默认值,Claude 有独立的宽松配置。
| 配置 | 说明 | 通用默认值 | Claude 默认值 | 范围 |
|------|------|--------|--------|------|
| 失败阈值 | 连续失败多少次触发熔断 | 4 | 8 | 1-20 |
| 恢复成功阈值 | 半开状态下成功多少次后关闭熔断器 | 2 | 3 | 1-10 |
| 恢复等待时间 | 熔断后多久尝试恢复(秒) | 60 | 90 | 0-300 |
| 错误率阈值 | 错误率超过此值时打开熔断器 | 60% | 70% | 0-100% |
| 最小请求数 | 计算错误率前的最小请求数 | 10 | 15 | 5-100 |
> 💡 Claude 由于请求耗时较长,默认配置更为宽松,容忍更多失败次数。
### 超时配置
| 配置 | 说明 | 通用默认值 | Claude 默认值 | 范围 |
|------|------|--------|--------|------|
| 流式首字节超时 | 等待首个数据块的最大时间(秒) | 60 | 90 | 1-120 |
| 流式静默超时 | 数据块之间的最大间隔(秒) | 120 | 180 | 60-600(填 0 禁用) |
| 非流式超时 | 非流式请求的总超时时间(秒) | 600 | 600 | 60-1200 |
### 重试配置
| 配置 | 说明 | 通用默认值 | Claude 默认值 | 范围 |
|------|------|--------|--------|------|
| 最大重试次数 | 请求失败时的重试次数 | 3 | 6 | 0-10 |
> 💡 Gemini 的默认最大重试次数为 5。
### 熔断状态
| 状态 | 说明 |
|------|------|
| 关闭 | 正常状态,允许请求 |
| 开启 | 熔断状态,跳过此供应商 |
| 半开 | 尝试恢复,发送试探请求 |
### 状态转换
```mermaid
stateDiagram-v2
[*] --> Closed: 初始化
Closed --> Open: 失败次数 >= 阈值
Open --> HalfOpen: 熔断时长到期
HalfOpen --> Closed: 试探成功 (>= 恢复阈值)
HalfOpen --> Open: 试探失败
```
## 健康状态指示
### 供应商卡片
卡片上显示健康状态徽章:
| 徽章 | 状态 | 说明 |
|------|------|------|
| 🟢 | 健康 | 连续失败次数为 0 |
| 🟡 | 警告 | 有失败但未触发熔断 |
| 🔴 | 熔断 | 已触发熔断,暂时跳过 |
### 队列列表
故障转移队列中也显示每个供应商的健康状态。
## 故障转移日志
每次故障转移会记录:
| 信息 | 说明 |
|------|------|
| 时间 | 发生时间 |
| 原供应商 | 失败的供应商 |
| 新供应商 | 切换到的供应商 |
| 失败原因 | 错误信息 |
在用量统计的请求日志中可以查看。
## 最佳实践
### 队列配置建议
1. **主供应商**:最稳定、最快的供应商
2. **第一备用**:次优选择
3. **第二备用**:保底选择
### 熔断器配置建议
| 场景 | 失败阈值 | 熔断时长 |
|------|----------|----------|
| 高可用要求 | 2 | 30 秒 |
| 一般场景 | 3 | 60 秒 |
| 容忍偶发失败 | 5 | 120 秒 |
### 监控建议
定期检查:
- 各供应商的健康状态
- 故障转移发生频率
- 熔断触发情况
## 常见问题
### 故障转移没有触发
检查:
1. 代理服务是否运行
2. 应用接管是否开启
3. 自动故障转移是否开启
4. 队列中是否有备用供应商
### 频繁触发故障转移
可能原因:
- 主供应商不稳定
- 网络问题
- 配置错误
解决方法:
- 检查主供应商状态
- 调整熔断器参数
- 考虑更换主供应商
### 所有供应商都熔断
等待熔断时长到期后自动恢复,或:
1. 手动重启代理服务
2. 重置熔断状态
-291
View File
@@ -1,291 +0,0 @@
# 4.4 用量统计
## 功能说明
用量统计功能记录和分析 API 请求数据,帮助你:
- 了解 API 使用情况
- 估算费用支出
- 分析使用模式
- 排查问题
## 前提条件
使用用量统计功能需要:
1. ✅ 启动代理服务
2. ✅ 开启应用接管
3. ✅ 开启日志记录
## 打开用量统计
设置 → 用量 Tab
## 统计概览
### 汇总卡片
页面顶部显示关键指标:
| 指标 | 说明 |
|------|------|
| 总请求数 | 统计周期内的请求总数 |
| 总 Token | 输入 + 输出 Token 总数 |
| 估算费用 | 基于定价配置计算的费用 |
| 成功率 | 成功请求的百分比 |
### 时间范围
可选择统计的时间范围:
| 选项 | 范围 |
|------|------|
| 今日 | 当天 00:00 至今 |
| 最近 7 天 | 过去 7 天 |
| 最近 30 天 | 过去 30 天 |
![image-20260108011730105](../assets/image-20260108011730105.png)
## 趋势图表
### 请求趋势
折线图展示请求数量的变化趋势:
- X 轴:时间
- Y 轴:请求数量
- 可按小时/天查看
- 支持缩放和拖拽
### Token 趋势
展示 Token 使用量的变化:
- 输入 Token(蓝色)- 用户发送的 prompt 内容
- 输出 Token(绿色)- AI 生成的回复内容
- 缓存创建 Token(橙色)- 首次创建缓存消耗的 Token
- 缓存命中 Token(紫色)- 复用缓存节省的 Token
- 成本(红色虚线,右侧 Y 轴)- 估算费用
> 💡 **缓存 Token 说明**Anthropic API 支持 Prompt Caching 功能。缓存创建时收取较高费用(通常为输入价格的 1.25 倍),但后续命中缓存时只收取 0.1 倍的价格,可大幅降低重复请求的成本。
### 时间粒度
- **今日**:按小时显示(24 个数据点)
- **7 天/30 天**:按天显示
![image-20260108011742847](../assets/image-20260108011742847.png)
## 详细数据
页面下方有三个数据 Tab
### 请求日志
每条请求的详细记录:
| 字段 | 说明 |
|------|------|
| 时间 | 请求时间 |
| 供应商 | 使用的供应商名称 |
| 模型 | 请求的模型(计费模型) |
| 输入 Token | 输入的 Token 数 |
| 输出 Token | 输出的 Token 数 |
| 缓存读取 | 缓存命中的 Token 数 |
| 缓存创建 | 缓存创建的 Token 数 |
| 总费用 | 估算费用(美元) |
| 耗时信息 | 请求耗时、首 Token 时间、流式/非流式 |
| 状态 | HTTP 状态码 |
#### 耗时信息说明
耗时信息列显示多个徽章:
| 徽章 | 说明 | 颜色规则 |
|------|------|----------|
| 总耗时 | 请求总时长(秒) | ≤5s 绿色,≤120s 橙色,>120s 红色 |
| 首 Token | 流式请求首个 Token 时间 | ≤5s 绿色,≤120s 橙色,>120s 红色 |
| 流式/非流式 | 请求类型 | 流式蓝色,非流式紫色 |
#### 查看详情
点击请求行可查看详细信息:
- 完整的请求参数
- 响应内容摘要
- 错误信息(如果失败)
#### 筛选日志
支持按以下条件筛选:
| 筛选项 | 选项 |
|--------|------|
| 应用类型 | 全部 / Claude / Codex / Gemini / OpenCode |
| 状态码 | 全部 / 200 / 400 / 401 / 429 / 500 |
| 供应商 | 文本搜索 |
| 模型 | 文本搜索 |
| 时间范围 | 开始时间 - 结束时间(日期时间选择器) |
操作按钮:
- **搜索**:应用筛选条件
- **重置**:恢复默认(过去 24 小时)
- **刷新**:重新加载数据
![image-20260108011859974](../assets/image-20260108011859974.png)
### 供应商统计
按供应商分组的统计数据:
| 字段 | 说明 |
|------|------|
| 供应商 | 供应商名称 |
| 请求数 | 该供应商的请求总数 |
| 成功数 | 成功的请求数 |
| 失败数 | 失败的请求数 |
| 成功率 | 成功百分比 |
| 总 Token | Token 使用总量 |
| 估算费用 | 该供应商的费用 |
![image-20260108011907928](../assets/image-20260108011907928.png)
### 模型统计
按模型分组的统计数据:
| 字段 | 说明 |
|------|------|
| 模型 | 模型名称 |
| 请求数 | 该模型的请求总数 |
| 输入 Token | 输入 Token 总量 |
| 输出 Token | 输出 Token 总量 |
| 平均延迟 | 平均响应时间 |
| 估算费用 | 该模型的费用 |
![image-20260108011915381](../assets/image-20260108011915381.png)
## 定价配置
### 打开定价配置
设置 → 高级 → 定价配置
### 配置模型价格
为每个模型设置价格(每百万 Token):
| 字段 | 说明 |
|------|------|
| 模型 ID | 模型标识符(如 claude-3-sonnet |
| 显示名称 | 自定义显示名称 |
| 输入价格 | 每百万输入 Token 的价格 |
| 输出价格 | 每百万输出 Token 的价格 |
| 缓存读取价格 | 每百万缓存命中 Token 的价格 |
| 缓存创建价格 | 每百万缓存创建 Token 的价格 |
### 操作
- **添加**:点击「添加」按钮新增模型定价
- **编辑**:点击行末的编辑图标修改
- **删除**:点击行末的删除图标移除
![image-20260108011933565](../assets/image-20260108011933565.png)
### 预设价格
CC Switch 预设了常用模型的官方价格(每百万 Token):
**Claude 系列(美元)**
| 模型 | 输入 | 输出 | 缓存读取 | 缓存创建 |
|------|------|------|----------|----------|
| **Claude 4.5 系列** | | | | |
| claude-opus-4-5 | $5 | $25 | $0.50 | $6.25 |
| claude-sonnet-4-5 | $3 | $15 | $0.30 | $3.75 |
| claude-haiku-4-5 | $1 | $5 | $0.10 | $1.25 |
| **Claude 4 系列** | | | | |
| claude-opus-4 | $15 | $75 | $1.50 | $18.75 |
| claude-opus-4-1 | $15 | $75 | $1.50 | $18.75 |
| claude-sonnet-4 | $3 | $15 | $0.30 | $3.75 |
| **Claude 3.5 系列** | | | | |
| claude-3-5-sonnet | $3 | $15 | $0.30 | $3.75 |
| claude-3-5-haiku | $0.80 | $4 | $0.08 | $1.00 |
**OpenAI 系列 / Codex(美元)**
| 模型 | 输入 | 输出 | 缓存读取 |
|------|------|------|----------|
| **GPT-5.2 系列** | | | |
| gpt-5.2 | $1.75 | $14 | $0.175 |
| **GPT-5.1 系列** | | | |
| gpt-5.1 | $1.25 | $10 | $0.125 |
| **GPT-5 系列** | | | |
| gpt-5 | $1.25 | $10 | $0.125 |
> 注:Codex 预设包含了 low/medium/high 等变体,价格与基础模型一致。
**Gemini 系列(美元)**
| 模型 | 输入 | 输出 | 缓存读取 |
|------|------|------|----------|
| **Gemini 3 系列** | | | |
| gemini-3-pro-preview | $2 | $12 | $0.20 |
| gemini-3-flash-preview | $0.50 | $3 | $0.05 |
| **Gemini 2.5 系列** | | | |
| gemini-2.5-pro | $1.25 | $10 | $0.125 |
| gemini-2.5-flash | $0.30 | $2.50 | $0.03 |
**中国厂商模型(人民币)**
| 模型 | 输入 | 输出 | 缓存读取 |
|------|------|------|----------|
| **DeepSeek** | | | |
| deepseek-v3.2 | ¥2.00 | ¥3.00 | ¥0.40 |
| deepseek-v3.1 | ¥4.00 | ¥12.00 | ¥0.80 |
| deepseek-v3 | ¥2.00 | ¥8.00 | ¥0.40 |
| **Kimi (月之暗面)** | | | |
| kimi-k2-thinking | ¥4.00 | ¥16.00 | ¥1.00 |
| kimi-k2 | ¥4.00 | ¥16.00 | ¥1.00 |
| kimi-k2-turbo | ¥8.00 | ¥58.00 | ¥1.00 |
| **MiniMax** | | | |
| minimax-m2.1 | ¥2.10 | ¥8.40 | ¥0.21 |
| minimax-m2.1-lightning | ¥2.10 | ¥16.80 | ¥0.21 |
| **其他** | | | |
| glm-4.7 | ¥2.00 | ¥8.00 | ¥0.40 |
| doubao-seed-code | ¥1.20 | ¥8.00 | ¥0.24 |
| mimo-v2-flash | 免费 | 免费 | - |
### 自定义价格
如果使用中转服务,价格可能不同:
1. 点击「编辑」按钮
2. 修改价格
3. 保存
## 常见问题
### 统计数据为空
检查:
- 代理服务是否运行
- 应用接管是否开启
- 日志记录是否开启
- 是否有请求通过代理
### 费用估算不准确
可能原因:
- 定价配置与实际不符
- 使用了中转服务的特殊定价
解决方法:
- 更新定价配置
- 参考供应商的实际账单
### Token 数量与供应商不一致
CC Switch 使用自己的方式估算 Token 数,可能与供应商的计算方式略有差异。以供应商账单为准。
-156
View File
@@ -1,156 +0,0 @@
# 4.5 模型检查
## 功能说明
模型检查功能用于验证供应商配置的模型是否可用,通过发送实际的 API 请求来测试:
- 模型是否存在
- API Key 是否有效
- 端点是否正常响应
- 响应延迟是否正常
## 打开配置
设置 → 高级 → 模型测试
## 测试模型配置
为每个应用配置用于测试的模型:
| 应用 | 配置项 | 默认值 | 说明 |
|------|--------|--------|------|
| Claude | Claude 模型 | 系统默认 | 建议使用 Haiku 系列(成本低、速度快) |
| Codex | Codex 模型 | 系统默认 | 建议使用 mini 系列 |
| Gemini | Gemini 模型 | 系统默认 | 建议使用 Flash 系列 |
### 模型选择建议
选择测试模型时考虑:
1. **成本**:选择价格较低的模型(如 Haiku、Mini、Flash
2. **速度**:选择响应快的模型
3. **可用性**:选择供应商支持的模型
## 检查参数配置
### 超时时间
| 参数 | 说明 | 默认值 | 范围 |
|------|------|--------|------|
| 超时时间 | 单次请求超时 | 45 秒 | 10-120 秒 |
设置过短可能导致误判,设置过长会延迟故障检测。
### 重试次数
| 参数 | 说明 | 默认值 | 范围 |
|------|------|--------|------|
| 最大重试 | 失败后重试次数 | 2 次 | 0-5 次 |
网络不稳定时建议增加重试次数。
### 降级阈值
| 参数 | 说明 | 默认值 | 范围 |
|------|------|--------|------|
| 降级阈值 | 响应超过此时间标记为降级 | 6000ms | 1000-30000ms |
超过阈值的供应商会被标记为「降级」状态,但仍可使用。
## 执行模型检查
### 手动测试
在供应商卡片上点击「测试」按钮:
1. 发送测试请求到配置的端点
2. 使用配置的测试模型
3. 等待响应或超时
4. 显示测试结果
### 测试内容
测试请求会:
- 发送简短的 prompt(如 "Hi"
- 限制最大输出 token(通常 10-50
- 使用流式响应检测首字节时间
## 测试结果
### 健康状态
| 状态 | 图标 | 说明 |
|------|------|------|
| 健康 | 🟢 | 响应正常,延迟在阈值内 |
| 降级 | 🟡 | 响应正常,但延迟超过阈值 |
| 不可用 | 🔴 | 请求失败或超时 |
### 结果信息
测试完成后显示:
- 响应延迟(毫秒)
- 首字节时间(TTFB
- 错误信息(如果失败)
## 与故障转移集成
模型检查与故障转移功能配合使用:
### 健康检查
开启代理服务后,系统会定期对故障转移队列中的供应商执行健康检查:
1. 使用配置的测试模型发送请求
2. 根据响应更新健康状态
3. 不健康的供应商会被暂时跳过
### 熔断恢复
当供应商从熔断状态恢复时:
1. 执行模型检查验证可用性
2. 检查通过后恢复正常状态
3. 检查失败则继续熔断
## 常见问题
### 测试失败但实际可用
**可能原因**
- 测试模型与实际使用的模型不同
- 供应商不支持配置的测试模型
**解决方法**
- 修改测试模型为供应商支持的模型
- 检查供应商的模型列表
### 延迟过高
**可能原因**
- 网络延迟
- 供应商服务器负载高
- 模型响应慢
**解决方法**
- 使用更快的测试模型
- 调整降级阈值
- 考虑使用镜像端点
### 频繁超时
**可能原因**
- 超时时间设置过短
- 网络不稳定
- 供应商服务不稳定
**解决方法**
- 增加超时时间
- 增加重试次数
- 检查网络连接
## 注意事项
- 模型检查会消耗少量 API 配额
- 建议使用低成本模型进行测试
- 测试频率不宜过高,避免浪费配额
- 不同供应商支持的模型可能不同
-278
View File
@@ -1,278 +0,0 @@
# 5.1 配置文件说明
## CC Switch 数据存储
### 存储目录
默认位置:`~/.cc-switch/`
可在设置中自定义位置(用于云同步)。
### 目录结构
```
~/.cc-switch/
├── cc-switch.db # SQLite 数据库
├── settings.json # 设备级设置
└── backups/ # 自动备份
├── backup-20251230-120000.json
├── backup-20251229-180000.json
└── ...
```
### 数据库内容
`cc-switch.db` 是 SQLite 数据库,存储:
| 表 | 内容 |
|-----|------|
| providers | 供应商配置 |
| provider_endpoints | 供应商端点候选列表 |
| mcp_servers | MCP 服务器配置 |
| prompts | 提示词预设 |
| skills | 技能安装状态 |
| skill_repos | 技能仓库配置 |
| proxy_config | 代理配置 |
| proxy_request_logs | 代理请求日志 |
| provider_health | 供应商健康状态 |
| model_pricing | 模型定价 |
| settings | 应用设置 |
### 设备设置
`settings.json` 存储设备级设置:
```json
{
"language": "zh",
"theme": "system",
"windowBehavior": "minimize",
"autoStart": false,
"claudeConfigDir": null,
"codexConfigDir": null,
"geminiConfigDir": null,
"opencodeConfigDir": null
}
```
这些设置不会跨设备同步。
### 自动备份
`backups/` 目录存储自动备份:
- 每次导入配置前自动创建
- 保留最近 10 个备份
- 文件名包含时间戳
## Claude Code 配置
### 配置目录
默认:`~/.claude/`
### 主要文件
```
~/.claude/
├── settings.json # 主配置文件
├── CLAUDE.md # 系统提示词
└── skills/ # 技能目录
└── ...
```
### settings.json
```json
{
"env": {
"ANTHROPIC_API_KEY": "sk-xxx",
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
},
"permissions": {
"allow_file_access": true
}
}
```
| 字段 | 说明 |
|------|------|
| `env.ANTHROPIC_API_KEY` | API 密钥 |
| `env.ANTHROPIC_BASE_URL` | API 端点(可选) |
| `env.ANTHROPIC_AUTH_TOKEN` | 替代认证方式 |
### MCP 配置
MCP 服务器配置在 `~/.claude.json`
```json
{
"mcpServers": {
"mcp-fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"]
}
}
}
```
## Codex 配置
### 配置目录
默认:`~/.codex/`
### 主要文件
```
~/.codex/
├── auth.json # 认证配置
├── config.toml # 主配置 + MCP
└── AGENTS.md # 系统提示词
```
### auth.json
```json
{
"OPENAI_API_KEY": "sk-xxx"
}
```
### config.toml
```toml
# 基础配置
base_url = "https://api.openai.com/v1"
model = "gpt-4"
# MCP 服务器
[mcp_servers.mcp-fetch]
command = "uvx"
args = ["mcp-server-fetch"]
```
## Gemini CLI 配置
### 配置目录
默认:`~/.gemini/`
### 主要文件
```
~/.gemini/
├── .env # 环境变量(API Key
├── settings.json # 主配置 + MCP
└── GEMINI.md # 系统提示词
```
### .env
```bash
GEMINI_API_KEY=xxx
GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
GEMINI_MODEL=gemini-pro
```
### settings.json
```json
{
"mcpServers": {
"mcp-fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"]
}
}
}
```
| 字段 | 说明 |
|------|------|
| `mcpServers` | MCP 服务器配置 |
## OpenCode 配置
### 配置目录
默认:`~/.opencode/`
### 主要文件
```
~/.opencode/
├── config.json # 主配置文件
├── AGENTS.md # 系统提示词
└── skills/ # 技能目录
└── ...
```
## 配置优先级
CC Switch 修改配置时的优先级:
1. **CC Switch 数据库** - 单一事实源 (SSOT)
2. **Live 配置文件** - 切换供应商时写入
3. **回填机制** - 编辑当前供应商时从 Live 文件读取
## 手动编辑配置
### 可以手动编辑
- CLI 工具的配置文件(会被 CC Switch 回填)
- CC Switch 的 `settings.json`
### 不建议手动编辑
- `cc-switch.db` 数据库文件
- 备份文件
### 编辑后同步
如果手动编辑了 CLI 工具的配置:
1. 打开 CC Switch
2. 编辑对应的供应商
3. 会看到手动修改的内容已回填
4. 保存以同步到数据库
## 配置迁移
### 从旧版本迁移
CC Switch v3.7.0 从 JSON 文件迁移到 SQLite
- 首次启动自动迁移
- 迁移成功后显示提示
- 旧配置文件保留作为备份
### 跨设备迁移
1. 在源设备导出配置
2. 在目标设备导入配置
3. 或使用云同步功能
## 配置备份建议
### 定期备份
建议定期导出配置:
1. 设置 → 高级 → 数据管理
2. 点击「导出」
3. 保存到安全位置
### 备份内容
导出文件包含:
- 所有供应商配置
- MCP 服务器配置
- Prompts 预设
- 应用设置
### 不包含的内容
- 用量日志(数据量大)
- 设备级设置(不适合跨设备)
-220
View File
@@ -1,220 +0,0 @@
# 5.2 常见问题 FAQ
## 安装问题
### macOS 提示「未知开发者」
**问题**:首次打开时提示「无法打开,因为它来自身份不明的开发者」
**解决方法一**:通过系统设置
1. 关闭警告弹窗
2. 打开「系统设置」→「隐私与安全性」
3. 找到 CC Switch 相关提示
4. 点击「仍要打开」
5. 再次打开应用
**解决方法二**:通过终端命令(推荐)
```bash
sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/
```
执行后即可正常打开应用。
### Windows 安装后无法启动
**可能原因**
- 缺少 WebView2 运行时
- 杀毒软件拦截
**解决方法**
1. 安装 [Microsoft Edge WebView2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)
2. 将 CC Switch 添加到杀毒软件白名单
### Linux 启动报错
**问题**AppImage 无法启动
**解决方法**
```bash
# 添加执行权限
chmod +x CC-Switch-*.AppImage
# 如果仍然失败,尝试
./CC-Switch-*.AppImage --no-sandbox
```
## 供应商问题
### 切换供应商后不生效
**原因**CLI 工具需要重新加载配置
**解决方法**
- Claude Code:关闭并重新打开终端,或重启 IDE
- Codex:关闭并重新打开终端
- Gemini:托盘切换可即时生效,无需重启
### API Key 无效
**检查步骤**
1. 确认 API Key 正确复制(无多余空格)
2. 确认 API Key 未过期
3. 确认端点地址正确
4. 使用速度测试验证连接
### 如何恢复官方登录
**操作步骤**
1. 选择「官方登录」预设(Claude/Codex)或「Google 官方」预设(Gemini
2. 点击「启用」
3. 重启对应的 CLI 工具
4. 按照 CLI 工具的登录流程操作
## 代理问题
### 代理服务启动失败
**可能原因**:端口被占用
**解决方法**
1. 检查端口占用:
```bash
# macOS/Linux
lsof -i :49152
# Windows
netstat -ano | findstr :49152
```
2. 关闭占用端口的程序
3. 或尝试修改配置恢复默认端口:
- 打开「设置 → 代理服务」
- 点击「恢复默认」按钮
### 代理模式下请求超时
**可能原因**
- 网络问题
- 供应商服务器问题
- 代理配置错误
**解决方法**
1. 检查网络连接
2. 尝试直接访问供应商 API(关闭代理)
3. 检查供应商配置是否正确
### 关闭代理后配置未恢复
**可能原因**:代理异常退出
**解决方法**
1. 编辑当前供应商
2. 检查端点地址是否正确
3. 保存以更新配置
## 故障转移问题
### 故障转移没有触发
**检查清单**
- [ ] 代理服务是否运行
- [ ] 应用接管是否开启
- [ ] 自动故障转移是否开启
- [ ] 队列中是否有备用供应商
### 频繁触发故障转移
**可能原因**
- 主供应商不稳定
- 熔断器阈值设置过低
**解决方法**
1. 检查主供应商状态
2. 调高失败阈值(如从 3 改为 5)
3. 考虑更换主供应商
### 所有供应商都熔断了
**解决方法**
1. 等待熔断时长到期(默认 60 秒)
2. 或重启代理服务重置状态
## 数据问题
### 配置丢失
**可能原因**
- 配置目录被删除
- 数据库损坏
**解决方法**
1. 检查 `~/.cc-switch/` 目录是否存在
2. 从备份恢复:`~/.cc-switch/backups/`
3. 或从之前导出的配置文件导入
### 导入配置失败
**可能原因**
- 文件格式错误
- 版本不兼容
**解决方法**
1. 确认文件是 CC Switch 导出的 JSON 文件
2. 检查文件内容是否完整
3. 尝试用文本编辑器打开检查格式
### 用量统计数据为空
**检查清单**
- [ ] 代理服务是否运行
- [ ] 应用接管是否开启
- [ ] 日志记录是否开启
- [ ] 是否有请求通过代理
## 其他问题
### 托盘图标不显示
**macOS**
- 检查系统设置中的菜单栏图标设置
**Windows**
- 检查任务栏设置,确保 CC Switch 图标未被隐藏
**Linux**
- 需要安装系统托盘支持(如 `libappindicator`
### 界面显示异常
**解决方法**
1. 尝试切换主题(浅色/深色)
2. 重启应用
3. 删除 `~/.cc-switch/settings.json` 重置设置
### 更新失败
**解决方法**
1. 检查网络连接
2. 手动下载最新版本安装
3. 如使用 Homebrew`brew upgrade --cask cc-switch`
## 获取帮助
### 提交 Issue
如果以上方法都无法解决问题:
1. 访问 [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
2. 搜索是否有类似问题
3. 如果没有,创建新 Issue
4. 提供以下信息:
- 操作系统和版本
- CC Switch 版本
- 问题描述和复现步骤
- 错误信息(如有)
### 日志文件
提交 Issue 时可附上日志文件:
- macOS/Linux`~/.cc-switch/logs/`
- Windows`%APPDATA%\cc-switch\logs\`
-256
View File
@@ -1,256 +0,0 @@
# 5.3 深度链接协议
## 功能说明
CC Switch 支持 `ccswitch://` 深度链接协议,可以通过链接一键导入配置。
**使用场景**
- 团队共享配置
- 教程中的一键配置
- 跨设备快速同步
## 在线生成工具
CC Switch 提供在线深度链接生成工具:
**访问地址**[https://farion1231.github.io/cc-switch/deplink.html](https://farion1231.github.io/cc-switch/deplink.html)
### 使用方法
1. 打开上述网页
2. 选择导入类型(供应商/MCP/Prompt
3. 填写配置信息
4. 点击「生成链接」
5. 复制生成的深度链接
6. 分享给他人或在其他设备使用
## 协议格式
### V1 协议
使用 URL 参数格式,易读易生成:
```
ccswitch://v1/import?resource={type}&app={app}&name={name}&...
```
**通用参数**
| 参数 | 必填 | 说明 |
|------|------|------|
| `resource` | 是 | 资源类型:`provider` / `mcp` / `prompt` / `skill` |
| `app` | 是 | 应用类型:`claude` / `codex` / `gemini` / `opencode` |
| `name` | 是 | 名称 |
**供应商参数**resource=provider):
| 参数 | 必填 | 说明 |
|------|------|------|
| `endpoint` | 否 | API 端点地址(支持逗号分隔多个 URL) |
| `apiKey` | 否 | API 密钥 |
| `homepage` | 否 | 供应商官网 |
| `model` | 否 | 默认模型 |
| `haikuModel` | 否 | Haiku 模型(仅 Claude |
| `sonnetModel` | 否 | Sonnet 模型(仅 Claude |
| `opusModel` | 否 | Opus 模型(仅 Claude |
| `notes` | 否 | 备注 |
| `icon` | 否 | 图标 |
| `config` | 否 | Base64 编码的配置内容 |
| `configFormat` | 否 | 配置格式:`json` / `toml` |
| `configUrl` | 否 | 远程配置 URL |
| `enabled` | 否 | 是否启用(布尔值) |
| `usageScript` | 否 | 用量查询脚本 |
| `usageEnabled` | 否 | 是否启用用量查询(默认 true) |
| `usageApiKey` | 否 | 用量查询专用 API Key |
| `usageBaseUrl` | 否 | 用量查询专用地址 |
| `usageAccessToken` | 否 | 用量查询访问令牌 |
| `usageUserId` | 否 | 用量查询用户 ID |
| `usageAutoInterval` | 否 | 自动查询间隔(分钟) |
**提示词参数**resource=prompt):
| 参数 | 必填 | 说明 |
|------|------|------|
| `content` | 是 | 提示词内容 |
| `description` | 否 | 描述 |
| `enabled` | 否 | 是否启用(布尔值) |
**MCP 参数**resource=mcp):
| 参数 | 必填 | 说明 |
|------|------|------|
| `apps` | 是 | 应用列表(逗号分隔,如 `claude,codex,gemini` |
| `config` | 是 | MCP 服务器配置(JSON 格式) |
| `enabled` | 否 | 是否启用(布尔值) |
**Skill 参数**resource=skill):
| 参数 | 必填 | 说明 |
|------|------|------|
| `repo` | 是 | 仓库(格式:`owner/name` |
| `directory` | 否 | 目录路径 |
| `branch` | 否 | Git 分支 |
**示例**
```
ccswitch://v1/import?resource=provider&app=claude&name=My%20Provider&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-xxx
```
## 导入类型示例
### 导入供应商
```
ccswitch://v1/import?resource=provider&app=claude&name=My%20Provider&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-xxx
```
### 导入 MCP 服务器
```
ccswitch://v1/import?resource=mcp&apps=claude,codex&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-server-fetch%22%5D%7D&name=mcp-fetch
```
### 导入 Prompt 预设
```
ccswitch://v1/import?resource=prompt&app=claude&name=%E4%BB%A3%E7%A0%81%E5%AE%A1%E6%9F%A5&content=%23%20%E8%A7%92%E8%89%B2%0A%E4%BD%A0%E6%98%AF%E4%B8%80%E4%B8%AA%E4%B8%93%E4%B8%9A%E7%9A%84%E4%BB%A3%E7%A0%81%E5%AE%A1%E6%9F%A5%E4%B8%93%E5%AE%B6
```
### 导入 Skill
```
ccswitch://v1/import?resource=skill&name=my-skill&repo=owner/repo&directory=skills/my-skill&branch=main
```
## 生成深度链接
### 手动生成
1. 准备参数
2. 按 V1 协议格式拼接 URL
3. URL 编码特殊字符
**示例**
```javascript
const params = new URLSearchParams({
resource: 'provider',
app: 'claude',
name: 'My Provider',
endpoint: 'https://api.example.com',
apiKey: 'sk-xxx'
});
const url = `ccswitch://v1/import?${params.toString()}`;
```
### 在线工具
使用 CC Switch 官方提供的在线深度链接生成工具更方便。
## 使用深度链接
### 点击链接
在浏览器或其他应用中点击深度链接:
1. 系统会询问是否打开 CC Switch
2. 确认后 CC Switch 打开
3. 显示导入确认对话框
4. 确认导入
### 导入确认
导入前会显示确认对话框,包含:
- 导入类型
- 配置预览
- 确认/取消按钮
**安全提示**:只导入来自可信来源的配置。
## 协议注册
### 自动注册
CC Switch 安装时会自动注册 `ccswitch://` 协议。
### 手动注册
如果协议未正确注册:
**macOS**
重新安装应用,或运行:
```bash
/usr/bin/open -a "CC Switch" --args --register-protocol
```
**Windows**
重新安装应用,或检查注册表:
```
HKEY_CLASSES_ROOT\ccswitch
```
**Linux**
检查 `.desktop` 文件中的 `MimeType` 配置。
## 安全考虑
### 敏感信息
深度链接中可能包含敏感信息(如 API Key):
- 不要在公开场合分享包含 API Key 的链接
- 分享前移除或替换敏感信息
- 使用安全渠道传输链接
### 验证来源
导入前 CC Switch 会:
1. 验证数据格式
2. 显示配置预览
3. 要求用户确认
### 恶意链接防护
CC Switch 会检查:
- 数据格式是否合法
- 必填字段是否完整
- 配置值是否在合理范围
## 示例链接
### 示例:导入 Claude 供应商
```
ccswitch://v1/import?resource=provider&app=claude&name=Test%20Provider&apiKey=sk-xxx&endpoint=https%3A%2F%2Fapi.example.com
```
### 示例:导入 MCP 服务器
```
ccswitch://v1/import?resource=mcp&name=mcp-fetch&apps=claude,codex,gemini&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-server-fetch%22%5D%7D
```
## 故障排除
### 链接无法打开
**检查**
1. CC Switch 是否已安装
2. 协议是否正确注册
3. 链接格式是否正确
### 导入失败
**可能原因**
- Base64 编码错误
- JSON 格式错误
- 缺少必填字段
**解决方法**
1. 检查原始 JSON 格式
2. 重新进行 Base64 编码
3. 确保所有必填字段都存在
-108
View File
@@ -1,108 +0,0 @@
# 5.4 环境变量冲突
## 功能说明
CC Switch 会自动检测系统环境变量与应用配置的冲突,避免配置被意外覆盖。
**检测的环境变量**
- `ANTHROPIC_API_KEY` - Claude API 密钥
- `ANTHROPIC_BASE_URL` - Claude API 端点
- `OPENAI_API_KEY` - OpenAI API 密钥
- `GEMINI_API_KEY` - Gemini API 密钥
- 其他相关环境变量
## 冲突警告
当检测到冲突时,界面顶部会显示黄色警告横幅:
```
⚠️ 检测到环境变量冲突
发现 X 个环境变量可能与 CC Switch 配置冲突
[展开] [关闭]
```
## 查看冲突详情
点击「展开」按钮查看详细信息:
| 字段 | 说明 |
|------|------|
| 变量名 | 环境变量名称 |
| 变量值 | 当前设置的值 |
| 来源 | 变量的来源位置 |
### 来源类型
| 来源 | 说明 |
|------|------|
| 用户注册表 | Windows 用户级环境变量 |
| 系统注册表 | Windows 系统级环境变量 |
| Shell 配置 | macOS/Linux 的 shell 配置文件 |
| 系统环境 | 系统级环境变量 |
## 处理冲突
### 选择要删除的变量
1. 勾选要删除的环境变量
2. 或点击「全选」选择所有冲突变量
### 删除变量
1. 点击「删除选中」按钮
2. 确认删除操作
3. CC Switch 会自动备份并删除选中的变量
### 自动备份
删除前会自动备份:
- 备份位置:`~/.cc-switch/env-backups/`
- 备份格式:JSON 文件
- 包含变量名、值、来源等信息
## 忽略警告
如果确认冲突不影响使用,可以:
1. 点击警告横幅右侧的「关闭」按钮
2. 警告会暂时隐藏
3. 下次启动时会重新检测
## 手动处理
如果不想通过 CC Switch 删除,可以手动处理:
### Windows
1. 打开「系统属性 → 高级 → 环境变量」
2. 在用户变量或系统变量中找到冲突变量
3. 删除或修改变量
### macOS / Linux
1. 编辑 shell 配置文件(如 `~/.zshrc``~/.bashrc`
2. 删除或注释掉相关的 `export` 语句
3. 重新加载配置:`source ~/.zshrc`
## 为什么会冲突
环境变量的优先级通常高于配置文件,可能导致:
- CC Switch 设置的供应商配置被覆盖
- API 请求发送到错误的端点
- 使用错误的 API 密钥
## 最佳实践
1. **使用 CC Switch 管理配置**:避免在系统环境变量中设置 API 密钥
2. **定期检查**:关注冲突警告,及时处理
3. **备份重要变量**:删除前确认已备份
## 恢复已删除的变量
如果误删了环境变量:
1. 找到备份文件:`~/.cc-switch/env-backups/`
2. 打开对应的 JSON 文件
3. 手动恢复变量到系统环境
-111
View File
@@ -1,111 +0,0 @@
# CC Switch 用户手册
> Claude Code / Codex / Gemini CLI / OpenCode 全方位辅助工具
## 目录结构
```
📚 CC Switch 用户手册
├── 1. 快速入门
│ ├── 1.1 软件介绍
│ ├── 1.2 安装指南
│ ├── 1.3 界面概览
│ ├── 1.4 快速上手
│ └── 1.5 个性化配置
├── 2. 供应商管理
│ ├── 2.1 添加供应商
│ ├── 2.2 切换供应商
│ ├── 2.3 编辑供应商
│ ├── 2.4 排序与复制
│ └── 2.5 用量查询
├── 3. 扩展功能
│ ├── 3.1 MCP 服务器管理
│ ├── 3.2 Prompts 提示词管理
│ └── 3.3 Skills 技能管理
├── 4. 代理与高可用
│ ├── 4.1 代理服务
│ ├── 4.2 应用接管
│ ├── 4.3 故障转移
│ ├── 4.4 用量统计
│ └── 4.5 模型检查
└── 5. 常见问题
├── 5.1 配置文件说明
├── 5.2 FAQ
├── 5.3 深度链接协议
└── 5.4 环境变量冲突
```
## 文件列表
### 1. 快速入门
| 文件 | 内容 |
|------|------|
| [1.1-introduction.md](./1-getting-started/1.1-introduction.md) | 软件介绍、核心功能、支持平台 |
| [1.2-installation.md](./1-getting-started/1.2-installation.md) | Windows/macOS/Linux 安装指南 |
| [1.3-interface.md](./1-getting-started/1.3-interface.md) | 界面布局、导航栏、供应商卡片说明 |
| [1.4-quickstart.md](./1-getting-started/1.4-quickstart.md) | 5 分钟快速上手教程 |
| [1.5-settings.md](./1-getting-started/1.5-settings.md) | 语言、主题、目录、云同步配置 |
### 2. 供应商管理
| 文件 | 内容 |
|------|------|
| [2.1-add.md](./2-providers/2.1-add.md) | 使用预设、自定义配置、统一供应商 |
| [2.2-switch.md](./2-providers/2.2-switch.md) | 主界面切换、托盘切换、生效方式 |
| [2.3-edit.md](./2-providers/2.3-edit.md) | 编辑配置、修改 API Key、回填机制 |
| [2.4-sort-duplicate.md](./2-providers/2.4-sort-duplicate.md) | 拖拽排序、复制供应商、删除 |
| [2.5-usage-query.md](./2-providers/2.5-usage-query.md) | 用量查询、剩余额度、多套餐显示 |
### 3. 扩展功能
| 文件 | 内容 |
|------|------|
| [3.1-mcp.md](./3-extensions/3.1-mcp.md) | MCP 协议、添加服务器、应用绑定 |
| [3.2-prompts.md](./3-extensions/3.2-prompts.md) | 创建预设、激活切换、智能回填 |
| [3.3-skills.md](./3-extensions/3.3-skills.md) | 发现技能、安装卸载、仓库管理 |
### 4. 代理与高可用
| 文件 | 内容 |
|------|------|
| [4.1-service.md](./4-proxy/4.1-service.md) | 启动代理、配置项、运行状态 |
| [4.2-takeover.md](./4-proxy/4.2-takeover.md) | 应用接管、配置修改、状态指示 |
| [4.3-failover.md](./4-proxy/4.3-failover.md) | 故障转移队列、熔断器、健康状态 |
| [4.4-usage.md](./4-proxy/4.4-usage.md) | 用量统计、趋势图表、定价配置 |
| [4.5-model-test.md](./4-proxy/4.5-model-test.md) | 模型检查、健康检测、延迟测试 |
### 5. 常见问题
| 文件 | 内容 |
|------|------|
| [5.1-config-files.md](./5-faq/5.1-config-files.md) | CC Switch 存储、CLI 配置文件格式 |
| [5.2-questions.md](./5-faq/5.2-questions.md) | 常见问题解答 |
| [5.3-deeplink.md](./5-faq/5.3-deeplink.md) | 深度链接协议、生成和使用方法 |
| [5.4-env-conflict.md](./5-faq/5.4-env-conflict.md) | 环境变量冲突检测与处理 |
## 快速链接
- **新用户**:从 [1.1 软件介绍](./1-getting-started/1.1-introduction.md) 开始
- **安装问题**:查看 [1.2 安装指南](./1-getting-started/1.2-installation.md)
- **配置供应商**:查看 [2.1 添加供应商](./2-providers/2.1-add.md)
- **使用代理**:查看 [4.1 代理服务](./4-proxy/4.1-service.md)
- **遇到问题**:查看 [5.2 FAQ](./5-faq/5.2-questions.md)
## 版本信息
- 文档版本:v3.10.3
- 最后更新:2026-02-09
- 适用于 CC Switch v3.10.0+
## 贡献
欢迎提交 Issue 或 PR 改进文档:
- [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
- [GitHub Repository](https://github.com/farion1231/cc-switch)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

-2
View File
@@ -54,11 +54,9 @@
"@lobehub/icons-static-svg": "^1.73.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
-42
View File
@@ -50,9 +50,6 @@ importers:
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-collapsible':
specifier: ^1.1.12
version: 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -62,9 +59,6 @@ importers:
'@radix-ui/react-label':
specifier: ^2.1.7
version: 2.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-popover':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-scroll-area':
specifier: ^1.2.10
version: 1.2.10(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -1054,19 +1048,6 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-popover@1.1.15':
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
@@ -3909,29 +3890,6 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1)
aria-hidden: 1.2.6
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-remove-scroll: 2.7.1(@types/react@18.3.23)(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
-63
View File
@@ -714,7 +714,6 @@ dependencies = [
"futures",
"hyper",
"indexmap 2.11.4",
"json5",
"log",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
@@ -728,7 +727,6 @@ dependencies = [
"serde_json",
"serde_yaml",
"serial_test",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
@@ -748,7 +746,6 @@ dependencies = [
"tower-http 0.5.2",
"url",
"uuid",
"webkit2gtk",
"winreg 0.52.0",
"zip 2.4.2",
]
@@ -2522,17 +2519,6 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "json5"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1"
dependencies = [
"pest",
"pest_derive",
"serde",
]
[[package]]
name = "jsonptr"
version = "0.6.3"
@@ -3417,49 +3403,6 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pest"
version = "2.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7"
dependencies = [
"memchr",
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed"
dependencies = [
"pest",
"pest_generator",
]
[[package]]
name = "pest_generator"
version = "2.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "pest_meta"
version = "2.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365"
dependencies = [
"pest",
"sha2",
]
[[package]]
name = "phf"
version = "0.8.0"
@@ -5943,12 +5886,6 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "ucd-trie"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "uds_windows"
version = "1.1.0"
+1 -6
View File
@@ -57,19 +57,14 @@ 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"
[target.'cfg(target_os = "linux")'.dependencies]
webkit2gtk = { version = "2.0.1", features = ["v2_16"] }
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.52"
+60 -48
View File
@@ -25,7 +25,6 @@ impl McpApps {
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
AppType::OpenClaw => false, // OpenClaw doesn't support MCP
}
}
@@ -36,7 +35,6 @@ impl McpApps {
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore
}
}
@@ -85,7 +83,6 @@ impl SkillApps {
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
AppType::OpenClaw => false, // OpenClaw doesn't support Skills
}
}
@@ -96,7 +93,6 @@ impl SkillApps {
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore
}
}
@@ -129,20 +125,6 @@ impl SkillApps {
apps.set_enabled_for(app, true);
apps
}
/// 从来源标签列表构建启用状态
///
/// 标签与 AppType::as_str() 一致时启用对应应用,
/// 其他标签(如 "agents", "cc-switch")忽略。
pub fn from_labels(labels: &[String]) -> Self {
let mut apps = Self::default();
for label in labels {
if let Ok(app) = label.parse::<AppType>() {
apps.set_enabled_for(&app, true);
}
}
apps
}
}
/// 已安装的 Skillv3.10.0+ 统一结构)
@@ -189,8 +171,6 @@ pub struct UnmanagedSkill {
pub description: Option<String>,
/// 在哪些应用目录中发现(如 ["claude", "codex"]
pub found_in: Vec<String>,
/// 发现路径(首个匹配的完整路径)
pub path: String,
}
/// MCP 服务器定义(v3.7.0 统一结构)
@@ -242,9 +222,6 @@ pub struct McpRoot {
/// OpenCode MCP 配置(v4.0.0+,实际使用 opencode.json
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub opencode: McpConfig,
/// OpenClaw MCP 配置(v4.1.0+,实际使用 openclaw.json
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub openclaw: McpConfig,
}
impl Default for McpRoot {
@@ -257,7 +234,6 @@ impl Default for McpRoot {
codex: McpConfig::default(),
gemini: McpConfig::default(),
opencode: McpConfig::default(),
openclaw: McpConfig::default(),
}
}
}
@@ -280,8 +256,6 @@ pub struct PromptRoot {
pub gemini: PromptConfig,
#[serde(default)]
pub opencode: PromptConfig,
#[serde(default)]
pub openclaw: PromptConfig,
}
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
@@ -297,7 +271,6 @@ pub enum AppType {
Codex,
Gemini,
OpenCode,
OpenClaw,
}
impl AppType {
@@ -307,16 +280,15 @@ impl AppType {
AppType::Codex => "codex",
AppType::Gemini => "gemini",
AppType::OpenCode => "opencode",
AppType::OpenClaw => "openclaw",
}
}
/// Check if this app uses additive mode
///
/// - Switch mode (false): Only the current provider is written to live config (Claude, Codex, Gemini)
/// - Additive mode (true): All providers are written to live config (OpenCode, OpenClaw)
/// - Additive mode (true): All providers are written to live config (OpenCode)
pub fn is_additive_mode(&self) -> bool {
matches!(self, AppType::OpenCode | AppType::OpenClaw)
matches!(self, AppType::OpenCode)
}
/// Return an iterator over all app types
@@ -326,7 +298,6 @@ impl AppType {
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
AppType::OpenClaw,
]
.into_iter()
}
@@ -342,16 +313,53 @@ 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 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 +377,12 @@ pub struct MultiAppConfig {
/// Claude Skills 配置
#[serde(default)]
pub skills: SkillStore,
/// 通用配置片段(按应用分治)
#[serde(default)]
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 +396,6 @@ impl Default for MultiAppConfig {
apps.insert("codex".to_string(), ProviderManager::default());
apps.insert("gemini".to_string(), ProviderManager::default());
apps.insert("opencode".to_string(), ProviderManager::default());
apps.insert("openclaw".to_string(), ProviderManager::default());
Self {
version: 2,
@@ -390,6 +403,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 +498,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 +555,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 +565,6 @@ impl MultiAppConfig {
AppType::Codex => &mut self.mcp.codex,
AppType::Gemini => &mut self.mcp.gemini,
AppType::OpenCode => &mut self.mcp.opencode,
AppType::OpenClaw => &mut self.mcp.openclaw,
}
}
@@ -557,7 +579,6 @@ impl MultiAppConfig {
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?;
Ok(config)
}
@@ -578,7 +599,6 @@ impl MultiAppConfig {
|| !self.prompts.codex.prompts.is_empty()
|| !self.prompts.gemini.prompts.is_empty()
|| !self.prompts.opencode.prompts.is_empty()
|| !self.prompts.openclaw.prompts.is_empty()
{
return Ok(false);
}
@@ -591,7 +611,6 @@ impl MultiAppConfig {
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
AppType::OpenClaw,
] {
// 复用已有的单应用导入逻辑
if Self::auto_import_prompt_if_exists(self, app)? {
@@ -662,7 +681,6 @@ impl MultiAppConfig {
AppType::Codex => &mut config.prompts.codex.prompts,
AppType::Gemini => &mut config.prompts.gemini.prompts,
AppType::OpenCode => &mut config.prompts.opencode.prompts,
AppType::OpenClaw => &mut config.prompts.openclaw.prompts,
};
prompts.insert(id, prompt);
@@ -691,18 +709,12 @@ impl MultiAppConfig {
let mut conflicts = Vec::new();
// 收集所有应用的 MCP
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
let old_servers = match app {
AppType::Claude => &self.mcp.claude.servers,
AppType::Codex => &self.mcp.codex.servers,
AppType::Gemini => &self.mcp.gemini.servers,
AppType::OpenCode => &self.mcp.opencode.servers,
AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip
};
for (id, entry) in old_servers {
+79 -40
View File
@@ -9,6 +9,7 @@ use crate::codex_config;
use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::settings;
/// 获取 Claude Code 配置状态
#[tauri::command]
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
Ok(config::get_claude_config_status())
@@ -59,23 +60,16 @@ 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 })
}
}
}
/// 获取 Claude Code 配置文件路径
#[tauri::command]
pub async fn get_claude_code_config_path() -> Result<String, String> {
Ok(get_claude_settings_path().to_string_lossy().to_string())
}
/// 获取当前生效的配置目录
#[tauri::command]
pub async fn get_config_dir(app: String) -> Result<String, String> {
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
@@ -83,12 +77,12 @@ 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())
}
/// 打开配置文件夹
#[tauri::command]
pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool, String> {
let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
@@ -96,7 +90,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() {
@@ -111,6 +104,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
Ok(true)
}
/// 弹出系统目录选择器并返回用户选择的路径
#[tauri::command]
pub async fn pick_directory(
app: AppHandle,
@@ -142,12 +136,14 @@ pub async fn pick_directory(
}
}
/// 获取应用配置文件路径
#[tauri::command]
pub async fn get_app_config_path() -> Result<String, String> {
let config_path = config::get_app_config_path();
Ok(config_path.to_string_lossy().to_string())
}
/// 打开应用配置文件夹
#[tauri::command]
pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
let config_dir = config::get_app_config_dir();
@@ -164,6 +160,42 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
Ok(true)
}
/// 获取 Claude 通用配置片段(已废弃,使用 get_common_config_snippet
#[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())
}
/// 设置 Claude 通用配置片段(已废弃,使用 set_common_config_snippet
#[tauri::command]
pub async fn set_claude_common_config_snippet(
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
// 验证是否为有效的 JSON(如果不为空)
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,
@@ -175,19 +207,25 @@ pub async fn get_common_config_snippet(
.map_err(|e| e.to_string())
}
/// 设置通用配置片段(统一接口)
#[tauri::command]
pub async fn set_common_config_snippet(
app_type: String,
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
// 验证格式(根据应用类型)
if !snippet.trim().is_empty() {
match app_type.as_str() {
"claude" | "gemini" | "omo" | "omo-slim" => {
"claude" | "gemini" => {
// 验证 JSON 格式
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(invalid_json_format_error)?;
}
"codex" => {}
"codex" => {
// TOML 格式暂不验证(或可使用 toml crate)
// 注意:TOML 验证较为复杂,暂时跳过
}
_ => {}
}
}
@@ -202,32 +240,33 @@ pub async fn set_common_config_snippet(
.db
.set_config_snippet(&app_type, value)
.map_err(|e| e.to_string())?;
if app_type == "omo"
&& state
.db
.get_current_omo_provider("opencode", "omo")
.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())?;
}
Ok(())
}
/// 提取通用配置片段
///
/// 优先从 `settingsConfig`(编辑器当前内容)提取;若未提供,则从当前激活供应商提取。
///
/// 提取时会自动排除差异化字段(API Key、模型配置、端点等),返回可复用的通用配置片段。
#[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())
}
+17 -44
View File
@@ -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())
}
+76 -422
View File
@@ -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,87 @@ 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
];
#[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 +970,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
View File
@@ -8,8 +8,6 @@ mod global_proxy;
mod import_export;
mod mcp;
mod misc;
mod omo;
mod openclaw;
mod plugin;
mod prompt;
mod provider;
@@ -18,10 +16,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::*;
@@ -31,8 +26,6 @@ pub use global_proxy::*;
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 +35,3 @@ pub use settings::*;
pub use skill::*;
pub use stream_check::*;
pub use usage::*;
pub use webdav_sync::*;
pub use workspace::*;
-99
View File
@@ -1,99 +0,0 @@
use tauri::State;
use crate::services::omo::{OmoLocalFileData, SLIM, STANDARD};
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())
}
#[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")
.map_err(|e| e.to_string())?;
Ok(provider.map(|p| p.id).unwrap_or_default())
}
#[tauri::command]
pub async fn disable_current_omo(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") {
state
.db
.clear_omo_provider_current("opencode", id, "omo")
.map_err(|e| e.to_string())?;
}
}
OmoService::delete_config_file(&STANDARD).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn get_omo_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"))
.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)
}
-108
View File
@@ -1,108 +0,0 @@
use std::collections::HashMap;
use tauri::State;
use crate::openclaw_config;
use crate::store::AppState;
// ============================================================================
// OpenClaw Provider Commands (migrated from provider.rs)
// ============================================================================
/// Import providers from OpenClaw live config to database.
///
/// OpenClaw uses additive mode — users may already have providers
/// configured in openclaw.json.
#[tauri::command]
pub fn import_openclaw_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
crate::services::provider::import_openclaw_providers_from_live(state.inner())
.map_err(|e| e.to_string())
}
/// Get provider IDs in the OpenClaw live config.
#[tauri::command]
pub fn get_openclaw_live_provider_ids() -> Result<Vec<String>, String> {
openclaw_config::get_providers()
.map(|providers| providers.keys().cloned().collect())
.map_err(|e| e.to_string())
}
// ============================================================================
// Agents Configuration Commands
// ============================================================================
/// Get OpenClaw default model config (agents.defaults.model)
#[tauri::command]
pub fn get_openclaw_default_model() -> Result<Option<openclaw_config::OpenClawDefaultModel>, String>
{
openclaw_config::get_default_model().map_err(|e| e.to_string())
}
/// Set OpenClaw default model config (agents.defaults.model)
#[tauri::command]
pub fn set_openclaw_default_model(
model: openclaw_config::OpenClawDefaultModel,
) -> Result<(), String> {
openclaw_config::set_default_model(&model).map_err(|e| e.to_string())
}
/// Get OpenClaw model catalog/allowlist (agents.defaults.models)
#[tauri::command]
pub fn get_openclaw_model_catalog(
) -> Result<Option<HashMap<String, openclaw_config::OpenClawModelCatalogEntry>>, String> {
openclaw_config::get_model_catalog().map_err(|e| e.to_string())
}
/// Set OpenClaw model catalog/allowlist (agents.defaults.models)
#[tauri::command]
pub fn set_openclaw_model_catalog(
catalog: HashMap<String, openclaw_config::OpenClawModelCatalogEntry>,
) -> Result<(), String> {
openclaw_config::set_model_catalog(&catalog).map_err(|e| e.to_string())
}
/// Get full agents.defaults config (all fields)
#[tauri::command]
pub fn get_openclaw_agents_defaults(
) -> Result<Option<openclaw_config::OpenClawAgentsDefaults>, String> {
openclaw_config::get_agents_defaults().map_err(|e| e.to_string())
}
/// Set full agents.defaults config (all fields)
#[tauri::command]
pub fn set_openclaw_agents_defaults(
defaults: openclaw_config::OpenClawAgentsDefaults,
) -> Result<(), String> {
openclaw_config::set_agents_defaults(&defaults).map_err(|e| e.to_string())
}
// ============================================================================
// Env Configuration Commands
// ============================================================================
/// Get OpenClaw env config (env section of openclaw.json)
#[tauri::command]
pub fn get_openclaw_env() -> Result<openclaw_config::OpenClawEnvConfig, String> {
openclaw_config::get_env_config().map_err(|e| e.to_string())
}
/// Set OpenClaw env config (env section of openclaw.json)
#[tauri::command]
pub fn set_openclaw_env(env: openclaw_config::OpenClawEnvConfig) -> Result<(), String> {
openclaw_config::set_env_config(&env).map_err(|e| e.to_string())
}
// ============================================================================
// Tools Configuration Commands
// ============================================================================
/// Get OpenClaw tools config (tools section of openclaw.json)
#[tauri::command]
pub fn get_openclaw_tools() -> Result<openclaw_config::OpenClawToolsConfig, String> {
openclaw_config::get_tools_config().map_err(|e| e.to_string())
}
/// Set OpenClaw tools config (tools section of openclaw.json)
#[tauri::command]
pub fn set_openclaw_tools(tools: openclaw_config::OpenClawToolsConfig) -> Result<(), String> {
openclaw_config::set_tools_config(&tools).map_err(|e| e.to_string())
}
+55 -30
View File
@@ -4,12 +4,11 @@ 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;
/// 获取所有供应商
#[tauri::command]
pub fn get_providers(
state: State<'_, AppState>,
@@ -19,12 +18,14 @@ pub fn get_providers(
ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string())
}
/// 获取当前供应商ID
#[tauri::command]
pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result<String, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::current(state.inner(), app_type).map_err(|e| e.to_string())
}
/// 添加供应商
#[tauri::command]
pub fn add_provider(
state: State<'_, AppState>,
@@ -35,6 +36,7 @@ pub fn add_provider(
ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string())
}
/// 更新供应商
#[tauri::command]
pub fn update_provider(
state: State<'_, AppState>,
@@ -45,6 +47,7 @@ pub fn update_provider(
ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string())
}
/// 删除供应商
#[tauri::command]
pub fn delete_provider(
state: State<'_, AppState>,
@@ -57,23 +60,18 @@ pub fn delete_provider(
.map_err(|e| e.to_string())
}
/// Remove provider from live config only (for additive mode apps like OpenCode)
/// Does NOT delete from database - provider remains in the list
#[tauri::command]
pub fn remove_provider_from_live_config(
state: tauri::State<'_, AppState>,
app: String,
id: String,
) -> Result<bool, String> {
pub fn remove_provider_from_live_config(app: String, id: String) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::remove_from_live_config(state.inner(), app_type, &id)
ProviderService::remove_from_live_config(app_type, &id)
.map(|_| true)
.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 +80,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 +89,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))]
@@ -110,12 +108,14 @@ pub fn import_default_config_test_hook(
import_default_config_internal(state, app_type)
}
/// 导入当前配置为默认供应商
#[tauri::command]
pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
import_default_config_internal(&state, app_type).map_err(Into::into)
}
/// 查询供应商用量
#[allow(non_snake_case)]
#[tauri::command]
pub async fn queryProviderUsage(
@@ -129,6 +129,7 @@ pub async fn queryProviderUsage(
.map_err(|e| e.to_string())
}
/// 测试用量脚本(使用当前编辑器中的脚本,不保存)
#[allow(non_snake_case)]
#[allow(clippy::too_many_arguments)]
#[tauri::command]
@@ -161,18 +162,14 @@ pub async fn testUsageScript(
.map_err(|e| e.to_string())
}
/// 读取当前生效的配置内容
#[tauri::command]
pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
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>,
@@ -183,6 +180,7 @@ pub async fn test_api_endpoints(
.map_err(|e| e.to_string())
}
/// 获取自定义端点列表
#[tauri::command]
pub fn get_custom_endpoints(
state: State<'_, AppState>,
@@ -194,6 +192,7 @@ pub fn get_custom_endpoints(
.map_err(|e| e.to_string())
}
/// 添加自定义端点
#[tauri::command]
pub fn add_custom_endpoint(
state: State<'_, AppState>,
@@ -206,6 +205,7 @@ pub fn add_custom_endpoint(
.map_err(|e| e.to_string())
}
/// 删除自定义端点
#[tauri::command]
pub fn remove_custom_endpoint(
state: State<'_, AppState>,
@@ -218,6 +218,7 @@ pub fn remove_custom_endpoint(
.map_err(|e| e.to_string())
}
/// 更新端点最后使用时间
#[tauri::command]
pub fn update_endpoint_last_used(
state: State<'_, AppState>,
@@ -230,6 +231,7 @@ pub fn update_endpoint_last_used(
.map_err(|e| e.to_string())
}
/// 更新多个供应商的排序
#[tauri::command]
pub fn update_providers_sort_order(
state: State<'_, AppState>,
@@ -240,16 +242,24 @@ pub fn update_providers_sort_order(
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
}
// ============================================================================
// 统一供应商(Universal Provider)命令
// ============================================================================
use crate::provider::UniversalProvider;
use std::collections::HashMap;
use tauri::{AppHandle, Emitter};
/// 统一供应商同步完成事件的 payload
#[derive(Clone, serde::Serialize)]
pub struct UniversalProviderSyncedEvent {
/// 操作类型: "upsert" | "delete" | "sync"
pub action: String,
/// 统一供应商 ID
pub id: String,
}
/// 发送统一供应商同步事件,通知前端刷新供应商列表
fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
let _ = app.emit(
"universal-provider-synced",
@@ -260,6 +270,7 @@ fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
);
}
/// 获取所有统一供应商
#[tauri::command]
pub fn get_universal_providers(
state: State<'_, AppState>,
@@ -267,6 +278,7 @@ pub fn get_universal_providers(
ProviderService::list_universal(state.inner()).map_err(|e| e.to_string())
}
/// 获取单个统一供应商
#[tauri::command]
pub fn get_universal_provider(
state: State<'_, AppState>,
@@ -275,6 +287,7 @@ pub fn get_universal_provider(
ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string())
}
/// 添加或更新统一供应商
#[tauri::command]
pub fn upsert_universal_provider(
app: AppHandle,
@@ -285,11 +298,13 @@ pub fn upsert_universal_provider(
let result =
ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?;
// 发送事件通知前端刷新
emit_universal_provider_synced(&app, "upsert", &id);
Ok(result)
}
/// 删除统一供应商
#[tauri::command]
pub fn delete_universal_provider(
app: AppHandle,
@@ -299,11 +314,13 @@ pub fn delete_universal_provider(
let result =
ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?;
// 发送事件通知前端刷新
emit_universal_provider_synced(&app, "delete", &id);
Ok(result)
}
/// 同步统一供应商到各应用(手动触发)
#[tauri::command]
pub fn sync_universal_provider(
app: AppHandle,
@@ -313,24 +330,32 @@ pub fn sync_universal_provider(
let result =
ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?;
// 发送事件通知前端刷新
emit_universal_provider_synced(&app, "sync", &id);
Ok(result)
}
// ============================================================================
// OpenCode 专属命令
// ============================================================================
/// 从 OpenCode live 配置导入供应商到数据库
///
/// 这是 OpenCode 特有的功能,因为 OpenCode 使用累加模式,
/// 用户可能已经在 opencode.json 中配置了供应商。
#[tauri::command]
pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
crate::services::provider::import_opencode_providers_from_live(state.inner())
.map_err(|e| e.to_string())
}
/// 获取 OpenCode live 配置中的供应商 ID 列表
///
/// 用于前端判断供应商是否已添加到 opencode.json
#[tauri::command]
pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
crate::opencode_config::get_providers()
.map(|providers| providers.keys().cloned().collect())
.map_err(|e| e.to_string())
}
// ============================================================================
// OpenClaw 专属命令 → 已迁移至 commands/openclaw.rs
// ============================================================================
+2 -66
View File
@@ -2,28 +2,16 @@
use tauri::AppHandle;
fn merge_settings_for_save(
mut incoming: crate::settings::AppSettings,
existing: &crate::settings::AppSettings,
) -> crate::settings::AppSettings {
if incoming.webdav_sync.is_none() {
incoming.webdav_sync = existing.webdav_sync.clone();
}
incoming
}
/// 获取设置
#[tauri::command]
pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
Ok(crate::settings::get_settings_for_frontend())
Ok(crate::settings::get_settings())
}
/// 保存设置
#[tauri::command]
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
let existing = crate::settings::get_settings();
let merged = merge_settings_for_save(settings, &existing);
crate::settings::update_settings(merged).map_err(|e| e.to_string())?;
crate::settings::update_settings(settings).map_err(|e| e.to_string())?;
Ok(true)
}
@@ -66,58 +54,6 @@ pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
Ok(true)
}
#[cfg(test)]
mod tests {
use super::merge_settings_for_save;
use crate::settings::{AppSettings, WebDavSyncSettings};
#[test]
fn save_settings_should_preserve_existing_webdav_when_payload_omits_it() {
let mut existing = AppSettings::default();
existing.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "secret".to_string(),
..WebDavSyncSettings::default()
});
let incoming = AppSettings::default();
let merged = merge_settings_for_save(incoming, &existing);
assert!(merged.webdav_sync.is_some());
assert_eq!(
merged.webdav_sync.as_ref().map(|v| v.base_url.as_str()),
Some("https://dav.example.com")
);
}
#[test]
fn save_settings_should_keep_incoming_webdav_when_present() {
let mut existing = AppSettings::default();
existing.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.old.example.com".to_string(),
username: "old".to_string(),
password: "old-pass".to_string(),
..WebDavSyncSettings::default()
});
let mut incoming = AppSettings::default();
incoming.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.new.example.com".to_string(),
username: "new".to_string(),
password: "new-pass".to_string(),
..WebDavSyncSettings::default()
});
let merged = merge_settings_for_save(incoming, &existing);
assert_eq!(
merged.webdav_sync.as_ref().map(|v| v.base_url.as_str()),
Some("https://dav.new.example.com")
);
}
}
/// 获取开机自启状态
#[tauri::command]
pub async fn get_auto_launch_status() -> Result<bool, String> {
-97
View File
@@ -1,97 +0,0 @@
use serde_json::{json, Value};
use std::sync::Arc;
use crate::database::Database;
use crate::error::AppError;
use crate::services::provider::ProviderService;
use crate::settings;
use crate::store::AppState;
pub(crate) fn run_post_import_sync(db: Arc<Database>) -> Result<(), AppError> {
let app_state = AppState::new(db);
ProviderService::sync_current_to_live(&app_state)?;
settings::reload_settings()?;
Ok(())
}
fn post_sync_warning<E: std::fmt::Display>(err: E) -> String {
AppError::localized(
"sync.post_operation_sync_failed",
format!("后置同步状态失败: {err}"),
format!("Post-operation synchronization failed: {err}"),
)
.to_string()
}
pub(crate) fn post_sync_warning_from_result(
result: Result<Result<(), AppError>, String>,
) -> Option<String> {
match result {
Ok(Ok(())) => None,
Ok(Err(err)) => Some(post_sync_warning(err)),
Err(err) => Some(post_sync_warning(err)),
}
}
pub(crate) fn attach_warning(mut value: Value, warning: Option<String>) -> Value {
if let Some(message) = warning {
if let Some(obj) = value.as_object_mut() {
obj.insert("warning".to_string(), Value::String(message));
}
}
value
}
pub(crate) fn success_payload_with_warning(backup_id: String, warning: Option<String>) -> Value {
attach_warning(
json!({
"success": true,
"message": "SQL imported successfully",
"backupId": backup_id
}),
warning,
)
}
#[cfg(test)]
mod tests {
use super::{attach_warning, post_sync_warning_from_result};
use serde_json::json;
#[test]
fn post_sync_warning_from_result_returns_none_on_success() {
let warning = post_sync_warning_from_result(Ok(Ok(())));
assert!(warning.is_none());
}
#[test]
fn post_sync_warning_from_result_returns_some_on_sync_error() {
let warning =
post_sync_warning_from_result(Ok(Err(crate::error::AppError::Config("boom".into()))));
assert!(warning.is_some());
}
#[tokio::test]
async fn post_sync_warning_from_result_returns_some_on_join_error() {
let handle = tokio::spawn(async move {
panic!("forced join error");
});
let join_err = handle.await.expect_err("task should panic");
let warning = post_sync_warning_from_result(Err(join_err.to_string()));
assert!(warning.is_some());
}
#[test]
fn attach_warning_adds_warning_without_dropping_existing_fields() {
let payload = json!({ "status": "downloaded" });
let updated = attach_warning(payload, Some("post sync warning".to_string()));
assert_eq!(
updated.get("status").and_then(|v| v.as_str()),
Some("downloaded")
);
assert_eq!(
updated.get("warning").and_then(|v| v.as_str()),
Some("post sync warning")
);
}
}
-357
View File
@@ -1,357 +0,0 @@
#![allow(non_snake_case)]
use serde_json::{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/");
}
}
-362
View File
@@ -1,362 +0,0 @@
use regex::Regex;
use std::sync::LazyLock;
use tauri::AppHandle;
use tauri_plugin_opener::OpenerExt;
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}"))
}
/// Find the largest index `<= i` that is a valid UTF-8 char boundary.
/// Equivalent to the unstable `str::floor_char_boundary` (stabilized in 1.91).
fn floor_char_boundary(s: &str, mut i: usize) -> usize {
if i >= s.len() {
return s.len();
}
while !s.is_char_boundary(i) {
i -= 1;
}
i
}
/// Find the smallest index `>= i` that is a valid UTF-8 char boundary.
/// Equivalent to the unstable `str::ceil_char_boundary` (stabilized in 1.91).
fn ceil_char_boundary(s: &str, mut i: usize) -> usize {
if i >= s.len() {
return s.len();
}
while !s.is_char_boundary(i) {
i += 1;
}
i
}
/// Search result for daily memory full-text search.
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DailyMemorySearchResult {
pub filename: String,
pub date: String,
pub size_bytes: u64,
pub modified_at: u64,
pub snippet: String,
pub match_count: usize,
}
/// Full-text search across all daily memory files.
///
/// Performs case-insensitive search on both the date field and file content.
/// Returns results sorted by filename descending (newest first), each with a
/// snippet showing ~120 characters of context around the first match.
#[tauri::command]
pub async fn search_daily_memory_files(
query: String,
) -> Result<Vec<DailyMemorySearchResult>, String> {
let memory_dir = get_openclaw_dir().join("workspace").join("memory");
if !memory_dir.exists() || query.is_empty() {
return Ok(Vec::new());
}
let query_lower = query.to_lowercase();
let mut results: Vec<DailyMemorySearchResult> = 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) if m.is_file() => m,
_ => continue,
};
let date = name.trim_end_matches(".md").to_string();
let content = std::fs::read_to_string(entry.path()).unwrap_or_default();
let content_lower = content.to_lowercase();
// Count matches in content
let content_matches: Vec<usize> = content_lower
.match_indices(&query_lower)
.map(|(i, _)| i)
.collect();
// Also check date field
let date_matches = date.to_lowercase().contains(&query_lower);
if content_matches.is_empty() && !date_matches {
continue;
}
// Build snippet around first content match (~120 chars of context)
let snippet = if let Some(&first_pos) = content_matches.first() {
let start = if first_pos > 50 {
floor_char_boundary(&content, first_pos - 50)
} else {
0
};
let end = ceil_char_boundary(&content, (first_pos + 70).min(content.len()));
let mut s = String::new();
if start > 0 {
s.push_str("...");
}
s.push_str(&content[start..end]);
if end < content.len() {
s.push_str("...");
}
s
} else {
// Date-only match — use beginning of file as preview
let end = ceil_char_boundary(&content, 120.min(content.len()));
let mut s = content[..end].to_string();
if end < content.len() {
s.push_str("...");
}
s
};
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);
results.push(DailyMemorySearchResult {
filename: name,
date,
size_bytes,
modified_at,
snippet,
match_count: content_matches.len(),
});
}
// Sort by filename descending (newest date first)
results.sort_by(|a, b| b.filename.cmp(&a.filename));
Ok(results)
}
/// 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}"))
}
/// Open the workspace or memory directory in the system file manager.
/// `subdir`: "workspace" opens `~/.openclaw/workspace/`,
/// "memory" opens `~/.openclaw/workspace/memory/`.
#[tauri::command]
pub async fn open_workspace_directory(handle: AppHandle, subdir: String) -> Result<bool, String> {
let dir = match subdir.as_str() {
"memory" => get_openclaw_dir().join("workspace").join("memory"),
_ => get_openclaw_dir().join("workspace"),
};
if !dir.exists() {
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create directory: {e}"))?;
}
handle
.opener()
.open_path(dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("Failed to open directory: {e}"))?;
Ok(true)
}
+6 -215
View File
@@ -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)
}
}
-2
View File
@@ -4,7 +4,6 @@
pub mod failover;
pub mod mcp;
pub mod omo;
pub mod prompts;
pub mod providers;
pub mod proxy;
@@ -16,4 +15,3 @@ pub mod universal_providers;
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
// 导出 FailoverQueueItem 供外部使用
pub use failover::FailoverQueueItem;
pub use omo::OmoGlobalConfig;
-77
View File
@@ -1,77 +0,0 @@
use crate::database::Database;
use crate::error::AppError;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OmoGlobalConfig {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sisyphus_agent: Option<serde_json::Value>,
#[serde(default)]
pub disabled_agents: Vec<String>,
#[serde(default)]
pub disabled_mcps: Vec<String>,
#[serde(default)]
pub disabled_hooks: Vec<String>,
#[serde(default)]
pub disabled_skills: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lsp: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experimental: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub background_task: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub browser_automation_engine: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub claude_code: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub other_fields: Option<serde_json::Value>,
pub updated_at: String,
}
impl Default for OmoGlobalConfig {
fn default() -> Self {
Self {
id: "global".to_string(),
schema_url: None,
sisyphus_agent: None,
disabled_agents: vec![],
disabled_mcps: vec![],
disabled_hooks: vec![],
disabled_skills: vec![],
lsp: None,
experimental: None,
background_task: None,
browser_automation_engine: None,
claude_code: None,
other_fields: None,
updated_at: chrono::Utc::now().to_rfc3339(),
}
}
}
impl Database {
pub fn get_omo_global_config(&self, key: &str) -> Result<OmoGlobalConfig, AppError> {
let json_str = self.get_setting(key)?;
match json_str {
Some(s) => serde_json::from_str::<OmoGlobalConfig>(&s)
.map_err(|e| AppError::Config(format!("Failed to parse {key}: {e}"))),
None => Ok(OmoGlobalConfig::default()),
}
}
pub fn save_omo_global_config(
&self,
key: &str,
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)?;
Ok(())
}
}
+24 -140
View File
@@ -1,3 +1,7 @@
//! 供应商数据访问对象
//!
//! 提供供应商(Provider)的 CRUD 操作。
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::provider::{Provider, ProviderMeta};
@@ -5,18 +9,8 @@ use indexmap::IndexMap;
use rusqlite::params;
use std::collections::HashMap;
type OmoProviderRow = (
String,
String,
String,
Option<String>,
Option<i64>,
Option<usize>,
Option<String>,
String,
);
impl Database {
/// 获取指定应用类型的所有供应商
pub fn get_all_providers(
&self,
app_type: &str,
@@ -72,6 +66,7 @@ impl Database {
let (id, mut provider) = provider_res.map_err(|e| AppError::Database(e.to_string()))?;
provider.id = id.clone();
// 加载 endpoints
let mut stmt_endpoints = conn.prepare(
"SELECT url, added_at FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 ORDER BY added_at ASC, url ASC"
).map_err(|e| AppError::Database(e.to_string()))?;
@@ -108,6 +103,7 @@ impl Database {
Ok(providers)
}
/// 获取当前激活的供应商 ID
pub fn get_current_provider(&self, app_type: &str) -> Result<Option<String>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
@@ -127,6 +123,7 @@ impl Database {
}
}
/// 根据 ID 获取单个供应商
pub fn get_provider_by_id(
&self,
id: &str,
@@ -177,15 +174,21 @@ impl Database {
}
}
/// 保存供应商(新增或更新)
///
/// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理
/// add_custom_endpoint / remove_custom_endpoint),避免覆盖用户的修改。
pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
// 处理 meta:取出 endpoints 以便单独处理
let mut meta_clone = provider.meta.clone().unwrap_or_default();
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
// 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue
let existing: Option<(bool, bool)> = tx
.query_row(
"SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
@@ -199,6 +202,7 @@ impl Database {
existing.unwrap_or((false, provider.in_failover_queue));
if is_update {
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
tx.execute(
"UPDATE providers SET
name = ?1,
@@ -237,6 +241,7 @@ impl Database {
)
.map_err(|e| AppError::Database(e.to_string()))?;
} else {
// 新增模式:使用 INSERT
tx.execute(
"INSERT INTO providers (
id, app_type, name, settings_config, website_url, category,
@@ -263,6 +268,7 @@ impl Database {
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 只有新增时才同步 endpoints
for (url, endpoint) in endpoints {
tx.execute(
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)
@@ -277,6 +283,7 @@ impl Database {
Ok(())
}
/// 删除供应商
pub fn delete_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
@@ -287,18 +294,21 @@ impl Database {
Ok(())
}
/// 设置当前供应商
pub fn set_current_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
// 重置所有为 0
tx.execute(
"UPDATE providers SET is_current = 0 WHERE app_type = ?1",
params![app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 设置新的当前供应商
tx.execute(
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2",
params![id, app_type],
@@ -309,6 +319,7 @@ impl Database {
Ok(())
}
/// 更新供应商的 settings_config(仅更新配置,不改变其他字段)
pub fn update_provider_settings_config(
&self,
app_type: &str,
@@ -330,6 +341,7 @@ impl Database {
Ok(())
}
/// 添加自定义端点
pub fn add_custom_endpoint(
&self,
app_type: &str,
@@ -345,6 +357,7 @@ impl Database {
Ok(())
}
/// 移除自定义端点
pub fn remove_custom_endpoint(
&self,
app_type: &str,
@@ -359,133 +372,4 @@ impl Database {
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
pub fn set_omo_provider_current(
&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],
)
.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],
)
.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}'"
)));
}
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
pub fn is_omo_provider_current(
&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],
|row| row.get(0),
) {
Ok(is_current) => Ok(is_current),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
Err(e) => Err(AppError::Database(e.to_string())),
}
}
pub fn clear_omo_provider_current(
&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],
)
.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> {
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
LIMIT 1",
params![app_type, category],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
row.get(6)?,
row.get(7)?,
))
},
);
let (id, name, settings_config_str, _row_category, created_at, sort_index, notes, meta_str) =
match row_data {
Ok(v) => v,
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
Err(e) => return Err(AppError::Database(e.to_string())),
};
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}"
))
})?;
let meta: crate::provider::ProviderMeta = if meta_str.trim().is_empty() {
crate::provider::ProviderMeta::default()
} else {
serde_json::from_str(&meta_str).map_err(|e| {
AppError::Database(format!(
"Failed to parse {category} provider meta (provider_id={id}): {e}"
))
})?
};
Ok(Some(Provider {
id,
name,
settings_config,
website_url: None,
category: Some(category.to_string()),
created_at,
sort_index,
notes,
meta: Some(meta),
icon: None,
icon_color: None,
in_failover_queue: false,
}))
}
}
+1 -1
View File
@@ -168,7 +168,7 @@ impl Database {
/// 获取整流器配置
///
/// 返回整流器配置,如果不存在则返回默认值(全部启)
/// 返回整流器配置,如果不存在则返回默认值(全部启
pub fn get_rectifier_config(&self) -> Result<crate::proxy::types::RectifierConfig, AppError> {
match self.get_setting("rectifier_config")? {
Some(json) => serde_json::from_str(&json)
+33
View File
@@ -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(())
}
}
+5 -32
View File
@@ -23,7 +23,7 @@
//! └── settings.rs
//! ```
pub(crate) mod backup;
mod backup;
mod dao;
mod migration;
mod schema;
@@ -33,16 +33,18 @@ mod tests;
// DAO 类型导出供外部使用
pub use dao::FailoverQueueItem;
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 +75,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 +92,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 +110,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),
+10 -47
View File
@@ -919,16 +919,7 @@ impl Database {
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
let pricing_data = [
// Claude 4.6 系列
(
"claude-opus-4-6-20260206",
"Claude Opus 4.6",
"5",
"25",
"0.50",
"6.25",
),
// Claude 4.5 系列
// Claude 4.5 系列 (Latest Models)
(
"claude-opus-4-5-20251101",
"Claude Opus 4.5",
@@ -1034,40 +1025,6 @@ impl Database {
"0.175",
"0",
),
// GPT-5.3 Codex 系列
("gpt-5.3-codex", "GPT-5.3 Codex", "1.75", "14", "0.175", "0"),
(
"gpt-5.3-codex-low",
"GPT-5.3 Codex",
"1.75",
"14",
"0.175",
"0",
),
(
"gpt-5.3-codex-medium",
"GPT-5.3 Codex",
"1.75",
"14",
"0.175",
"0",
),
(
"gpt-5.3-codex-high",
"GPT-5.3 Codex",
"1.75",
"14",
"0.175",
"0",
),
(
"gpt-5.3-codex-xhigh",
"GPT-5.3 Codex",
"1.75",
"14",
"0.175",
"0",
),
// GPT-5.1 系列
("gpt-5.1", "GPT-5.1", "1.25", "10", "0.125", "0"),
("gpt-5.1-low", "GPT-5.1", "1.25", "10", "0.125", "0"),
@@ -1255,7 +1212,7 @@ impl Database {
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
conn.execute(
"INSERT OR IGNORE INTO model_pricing (
"INSERT OR REPLACE INTO model_pricing (
model_id, display_name, input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
@@ -1282,8 +1239,14 @@ impl Database {
}
fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> {
// 每次启动都执行 INSERT OR IGNORE,增量追加新模型,已有数据不覆盖
Self::seed_model_pricing(conn)
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))
.map_err(|e| AppError::Database(format!("统计模型定价数据失败: {e}")))?;
if count == 0 {
Self::seed_model_pricing(conn)?;
}
Ok(())
}
// --- 辅助方法 ---
+4 -9
View File
@@ -297,15 +297,6 @@ fn schema_migration_v4_adds_pricing_model_columns() {
r#"
CREATE TABLE proxy_config (app_type TEXT PRIMARY KEY);
CREATE TABLE proxy_request_logs (request_id TEXT PRIMARY KEY, model TEXT NOT NULL);
CREATE TABLE mcp_servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
server_config TEXT NOT NULL,
enabled_claude INTEGER NOT NULL DEFAULT 0,
enabled_codex INTEGER NOT NULL DEFAULT 0,
enabled_gemini INTEGER NOT NULL DEFAULT 0,
enabled_opencode INTEGER NOT NULL DEFAULT 0
);
"#,
)
.expect("seed v4 schema");
@@ -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
-4
View File
@@ -175,10 +175,6 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
"codex" => apps.codex = true,
"gemini" => apps.gemini = true,
"opencode" => apps.opencode = true,
"openclaw" => {
// OpenClaw doesn't support MCP, ignore silently
log::debug!("OpenClaw doesn't support MCP, ignoring in apps parameter");
}
other => {
return Err(AppError::InvalidInput(format!(
"Invalid app in 'apps': {other}"
-75
View File
@@ -146,7 +146,6 @@ pub(crate) fn build_provider_from_request(
AppType::Codex => build_codex_settings(request),
AppType::Gemini => build_gemini_settings(request),
AppType::OpenCode => build_opencode_settings(request),
AppType::OpenClaw => build_openclaw_settings(request),
};
// Build usage script configuration if provided
@@ -392,35 +391,6 @@ fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value
})
}
fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
let endpoint = get_primary_endpoint(request);
// Build OpenClaw provider config
// Format: { baseUrl, apiKey, api, models }
let mut config = serde_json::Map::new();
if !endpoint.is_empty() {
config.insert("baseUrl".to_string(), json!(endpoint));
}
if let Some(api_key) = &request.api_key {
config.insert("apiKey".to_string(), json!(api_key));
}
// Default to OpenAI-compatible API
config.insert("api".to_string(), json!("openai-completions"));
// Build models array
if let Some(model) = &request.model {
config.insert(
"models".to_string(),
json!([{ "id": model, "name": model }]),
);
}
json!(config)
}
// =============================================================================
// Config Merge Logic
// =============================================================================
@@ -482,10 +452,6 @@ pub fn parse_and_merge_config(
"claude" => merge_claude_config(&mut merged, &config_value)?,
"codex" => merge_codex_config(&mut merged, &config_value)?,
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
// Additive mode apps use JSON config directly; pass through as-is
"openclaw" | "opencode" => {
merge_additive_config(&mut merged, &config_value)?;
}
"" => {
// No app specified, skip merging
return Ok(merged);
@@ -657,47 +623,6 @@ fn merge_gemini_config(
Ok(())
}
/// Merge configuration for additive mode apps (OpenClaw, OpenCode)
///
/// These apps use JSON config directly, so we only extract common fields
/// (api_key, endpoint, model) from the config if not already set in URL params.
fn merge_additive_config(
request: &mut DeepLinkImportRequest,
config: &serde_json::Value,
) -> Result<(), AppError> {
// Extract api_key from config if not provided in URL
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(api_key) = config
.get("apiKey")
.or_else(|| config.get("api_key"))
.and_then(|v| v.as_str())
{
request.api_key = Some(api_key.to_string());
}
}
// Extract endpoint from config if not provided in URL
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(base_url) = config
.get("baseUrl")
.or_else(|| config.get("base_url"))
.or_else(|| config.get("options").and_then(|o| o.get("baseURL")))
.and_then(|v| v.as_str())
{
request.endpoint = Some(base_url.to_string());
}
}
// Auto-fill homepage from endpoint
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
request.homepage = infer_homepage_from_endpoint(endpoint);
}
}
Ok(())
}
/// Extract base_url from Codex TOML config
fn extract_codex_base_url(toml_value: &toml::Value) -> Option<String> {
// Try to find base_url in model_providers section
-2
View File
@@ -52,8 +52,6 @@ pub enum AppError {
},
#[error("数据库错误: {0}")]
Database(String),
#[error("OMO 配置文件不存在")]
OmoConfigNotFound,
#[error("所有供应商已熔断,无可用渠道")]
AllProvidersCircuitOpen,
#[error("未配置供应商")]
+50 -145
View File
@@ -13,7 +13,6 @@ mod gemini_config;
mod gemini_mcp;
mod init_status;
mod mcp;
mod openclaw_config;
mod opencode_config;
mod panic_hook;
mod prompt;
@@ -448,7 +447,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,69 +500,7 @@ pub fn run() {
log::info!("✓ Imported {count} OpenCode provider(s) from live config");
}
Ok(_) => log::debug!("○ No OpenCode providers found to import"),
Err(e) => log::warn!("○ Failed to import OpenCode providers: {e}"),
}
// 2.2 OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入)
{
let has_omo = app_state
.db
.get_all_providers("opencode")
.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) {
Ok(provider) => {
log::info!("✓ Imported OMO config from local as provider '{}'", provider.name);
}
Err(AppError::OmoConfigNotFound) => {
log::debug!("○ No OMO config to import");
}
Err(e) => {
log::warn!("✗ Failed to import OMO config from local: {e}");
}
}
}
}
// 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}"),
Err(e) => log::debug!("○ Failed to import OpenCode providers: {e}"),
}
// 3. 导入 MCP 服务器配置(表空时触发)
@@ -566,8 +548,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 +666,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,42 +744,8 @@ 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 初始化失败导致白屏
#[cfg(target_os = "linux")]
{
if let Some(window) = app.get_webview_window("main") {
let _ = window.with_webview(|webview| {
use webkit2gtk::{WebViewExt, SettingsExt, HardwareAccelerationPolicy};
let wk_webview = webview.inner();
if let Some(settings) = WebViewExt::settings(&wk_webview) {
SettingsExt::set_hardware_acceleration_policy(&settings, HardwareAccelerationPolicy::Never);
log::info!("已禁用 WebKitGTK 硬件加速");
}
});
}
}
// 静默启动:根据设置决定是否显示主窗口
let settings = crate::settings::get_settings();
if let Some(window) = app.get_webview_window("main") {
@@ -845,10 +787,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 +849,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 +950,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,
@@ -1035,24 +958,6 @@ pub fn run() {
commands::scan_local_proxies,
// Window theme control
commands::set_window_theme,
commands::read_omo_local_file,
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,
commands::search_daily_memory_files,
commands::open_workspace_directory,
]);
let app = builder
-546
View File
@@ -1,546 +0,0 @@
//! OpenClaw 配置文件读写模块
//!
//! 处理 `~/.openclaw/openclaw.json` 配置文件的读写操作(JSON5 格式)。
//! OpenClaw 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。
//!
//! ## 配置文件格式
//!
//! ```json5
//! {
//! // 模型供应商配置(映射为 CC Switch 的"供应商"
//! models: {
//! mode: "merge",
//! providers: {
//! "custom-provider": {
//! baseUrl: "https://api.example.com/v1",
//! apiKey: "${API_KEY}",
//! api: "openai-completions",
//! models: [{ id: "model-id", name: "Model Name" }]
//! }
//! }
//! },
//! // 环境变量配置
//! env: {
//! ANTHROPIC_API_KEY: "sk-...",
//! vars: { ... }
//! },
//! // Agent 默认模型配置
//! agents: {
//! defaults: {
//! model: {
//! primary: "provider/model",
//! fallbacks: ["provider2/model2"]
//! }
//! }
//! }
//! }
//! ```
use crate::config::write_json_file;
use crate::error::AppError;
use crate::settings::get_openclaw_override_dir;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::collections::HashMap;
use std::path::PathBuf;
// ============================================================================
// Path Functions
// ============================================================================
/// 获取 OpenClaw 配置目录
///
/// 默认路径: `~/.openclaw/`
/// 可通过 settings.openclaw_config_dir 覆盖
pub fn get_openclaw_dir() -> PathBuf {
if let Some(override_dir) = get_openclaw_override_dir() {
return override_dir;
}
// 所有平台统一使用 ~/.openclaw
dirs::home_dir()
.map(|h| h.join(".openclaw"))
.unwrap_or_else(|| PathBuf::from(".openclaw"))
}
/// 获取 OpenClaw 配置文件路径
///
/// 返回 `~/.openclaw/openclaw.json`
pub fn get_openclaw_config_path() -> PathBuf {
get_openclaw_dir().join("openclaw.json")
}
// ============================================================================
// Type Definitions
// ============================================================================
/// OpenClaw 供应商配置(对应 models.providers 中的条目)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenClawProviderConfig {
/// API 基础 URL
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
/// API Key(支持环境变量引用 ${VAR_NAME}
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
/// API 类型(如 "openai-completions", "anthropic" 等)
#[serde(skip_serializing_if = "Option::is_none")]
pub api: Option<String>,
/// 支持的模型列表
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<OpenClawModelEntry>,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
/// OpenClaw 模型条目
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenClawModelEntry {
/// 模型 ID
pub id: String,
/// 模型显示名称
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// 模型别名(用于快捷引用)
#[serde(skip_serializing_if = "Option::is_none")]
pub alias: Option<String>,
/// 模型成本(输入/输出价格)
#[serde(skip_serializing_if = "Option::is_none")]
pub cost: Option<OpenClawModelCost>,
/// 上下文窗口大小
#[serde(skip_serializing_if = "Option::is_none")]
pub context_window: Option<u32>,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
/// OpenClaw 模型成本配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawModelCost {
/// 输入价格(每百万 token)
pub input: f64,
/// 输出价格(每百万 token)
pub output: f64,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
/// OpenClaw 默认模型配置(agents.defaults.model
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawDefaultModel {
/// 主模型 ID(格式:provider/model
pub primary: String,
/// 回退模型列表
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fallbacks: Vec<String>,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
/// OpenClaw 模型目录条目(agents.defaults.models 中的值)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawModelCatalogEntry {
/// 模型别名(用于 UI 显示)
#[serde(skip_serializing_if = "Option::is_none")]
pub alias: Option<String>,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
/// OpenClaw agents.defaults 配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawAgentsDefaults {
/// 默认模型配置
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<OpenClawDefaultModel>,
/// 模型目录/允许列表(键为 provider/model 格式)
#[serde(skip_serializing_if = "Option::is_none")]
pub models: Option<HashMap<String, OpenClawModelCatalogEntry>>,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
/// OpenClaw agents 顶层配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct OpenClawAgents {
/// 默认配置
#[serde(skip_serializing_if = "Option::is_none")]
pub defaults: Option<OpenClawAgentsDefaults>,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
// ============================================================================
// Core Read/Write Functions
// ============================================================================
/// 读取 OpenClaw 配置文件
///
/// 支持 JSON5 格式,返回完整的配置 JSON 对象
pub fn read_openclaw_config() -> Result<Value, AppError> {
let path = get_openclaw_config_path();
if !path.exists() {
// Return empty config structure
return Ok(json!({
"models": {
"mode": "merge",
"providers": {}
}
}));
}
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
// 尝试 JSON5 解析(支持注释和尾随逗号)
json5::from_str(&content)
.map_err(|e| AppError::Config(format!("Failed to parse OpenClaw config as JSON5: {}", e)))
}
/// 写入 OpenClaw 配置文件(原子写入)
///
/// 使用标准 JSON 格式写入(JSON5 是 JSON 的超集)
pub fn write_openclaw_config(config: &Value) -> Result<(), AppError> {
let path = get_openclaw_config_path();
// 确保目录存在
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
// 复用统一的原子写入逻辑
write_json_file(&path, config)?;
log::debug!("OpenClaw config written to {path:?}");
Ok(())
}
// ============================================================================
// Provider Functions (Untyped - for raw JSON operations)
// ============================================================================
/// 获取所有供应商配置(原始 JSON)
///
/// 从 `models.providers` 读取
pub fn get_providers() -> Result<Map<String, Value>, AppError> {
let config = read_openclaw_config()?;
Ok(config
.get("models")
.and_then(|m| m.get("providers"))
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default())
}
/// 设置供应商配置(原始 JSON)
///
/// 写入到 `models.providers`
pub fn set_provider(id: &str, provider_config: Value) -> Result<(), AppError> {
let mut full_config = read_openclaw_config()?;
// 确保 models 结构存在
if full_config.get("models").is_none() {
full_config["models"] = json!({
"mode": "merge",
"providers": {}
});
}
// 确保 providers 对象存在
if full_config["models"].get("providers").is_none() {
full_config["models"]["providers"] = json!({});
}
// 设置供应商
if let Some(providers) = full_config["models"]
.get_mut("providers")
.and_then(|v| v.as_object_mut())
{
providers.insert(id.to_string(), provider_config);
}
write_openclaw_config(&full_config)
}
/// 删除供应商配置
pub fn remove_provider(id: &str) -> Result<(), AppError> {
let mut config = read_openclaw_config()?;
if let Some(providers) = config
.get_mut("models")
.and_then(|m| m.get_mut("providers"))
.and_then(|v| v.as_object_mut())
{
providers.remove(id);
}
write_openclaw_config(&config)
}
// ============================================================================
// Provider Functions (Typed)
// ============================================================================
/// 获取所有供应商配置(类型化)
pub fn get_typed_providers() -> Result<IndexMap<String, OpenClawProviderConfig>, AppError> {
let providers = get_providers()?;
let mut result = IndexMap::new();
for (id, value) in providers {
match serde_json::from_value::<OpenClawProviderConfig>(value.clone()) {
Ok(config) => {
result.insert(id, config);
}
Err(e) => {
log::warn!("Failed to parse OpenClaw provider '{id}': {e}");
// Skip invalid providers but continue
}
}
}
Ok(result)
}
/// 设置供应商配置(类型化)
pub fn set_typed_provider(id: &str, config: &OpenClawProviderConfig) -> Result<(), AppError> {
let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?;
set_provider(id, value)
}
// ============================================================================
// Agents Configuration Functions
// ============================================================================
/// 读取默认模型配置(agents.defaults.model
pub fn get_default_model() -> Result<Option<OpenClawDefaultModel>, AppError> {
let config = read_openclaw_config()?;
let Some(model_value) = config
.get("agents")
.and_then(|a| a.get("defaults"))
.and_then(|d| d.get("model"))
else {
return Ok(None);
};
let model = serde_json::from_value(model_value.clone())
.map_err(|e| AppError::Config(format!("Failed to parse agents.defaults.model: {e}")))?;
Ok(Some(model))
}
/// 设置默认模型配置(agents.defaults.model
pub fn set_default_model(model: &OpenClawDefaultModel) -> Result<(), AppError> {
let mut config = read_openclaw_config()?;
// Ensure agents.defaults path exists, preserving unknown fields
ensure_agents_defaults_path(&mut config);
let model_value =
serde_json::to_value(model).map_err(|e| AppError::JsonSerialize { source: e })?;
config["agents"]["defaults"]["model"] = model_value;
write_openclaw_config(&config)
}
/// 读取模型目录/允许列表(agents.defaults.models
pub fn get_model_catalog() -> Result<Option<HashMap<String, OpenClawModelCatalogEntry>>, AppError> {
let config = read_openclaw_config()?;
let Some(models_value) = config
.get("agents")
.and_then(|a| a.get("defaults"))
.and_then(|d| d.get("models"))
else {
return Ok(None);
};
let catalog = serde_json::from_value(models_value.clone())
.map_err(|e| AppError::Config(format!("Failed to parse agents.defaults.models: {e}")))?;
Ok(Some(catalog))
}
/// 设置模型目录/允许列表(agents.defaults.models
pub fn set_model_catalog(
catalog: &HashMap<String, OpenClawModelCatalogEntry>,
) -> Result<(), AppError> {
let mut config = read_openclaw_config()?;
// Ensure agents.defaults path exists, preserving unknown fields
ensure_agents_defaults_path(&mut config);
let catalog_value =
serde_json::to_value(catalog).map_err(|e| AppError::JsonSerialize { source: e })?;
config["agents"]["defaults"]["models"] = catalog_value;
write_openclaw_config(&config)
}
/// Ensure the `agents.defaults` path exists in the config,
/// preserving any existing unknown fields.
fn ensure_agents_defaults_path(config: &mut Value) {
if config.get("agents").is_none() {
config["agents"] = json!({});
}
if config["agents"].get("defaults").is_none() {
config["agents"]["defaults"] = json!({});
}
}
// ============================================================================
// Full Agents Defaults Functions
// ============================================================================
/// Read the full agents.defaults config
pub fn get_agents_defaults() -> Result<Option<OpenClawAgentsDefaults>, AppError> {
let config = read_openclaw_config()?;
let Some(defaults_value) = config.get("agents").and_then(|a| a.get("defaults")) else {
return Ok(None);
};
let defaults = serde_json::from_value(defaults_value.clone())
.map_err(|e| AppError::Config(format!("Failed to parse agents.defaults: {e}")))?;
Ok(Some(defaults))
}
/// Write the full agents.defaults config
pub fn set_agents_defaults(defaults: &OpenClawAgentsDefaults) -> Result<(), AppError> {
let mut config = read_openclaw_config()?;
if config.get("agents").is_none() {
config["agents"] = json!({});
}
let value =
serde_json::to_value(defaults).map_err(|e| AppError::JsonSerialize { source: e })?;
config["agents"]["defaults"] = value;
write_openclaw_config(&config)
}
// ============================================================================
// Env Configuration
// ============================================================================
/// OpenClaw env configuration (env section of openclaw.json)
///
/// Stores environment variables like API keys and custom vars.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawEnvConfig {
/// All environment variable key-value pairs
#[serde(flatten)]
pub vars: HashMap<String, Value>,
}
/// Read the env config section
pub fn get_env_config() -> Result<OpenClawEnvConfig, AppError> {
let config = read_openclaw_config()?;
let Some(env_value) = config.get("env") else {
return Ok(OpenClawEnvConfig {
vars: HashMap::new(),
});
};
serde_json::from_value(env_value.clone())
.map_err(|e| AppError::Config(format!("Failed to parse env config: {e}")))
}
/// Write the env config section
pub fn set_env_config(env: &OpenClawEnvConfig) -> Result<(), AppError> {
let mut config = read_openclaw_config()?;
let value = serde_json::to_value(env).map_err(|e| AppError::JsonSerialize { source: e })?;
config["env"] = value;
write_openclaw_config(&config)
}
// ============================================================================
// Tools Configuration
// ============================================================================
/// OpenClaw tools configuration (tools section of openclaw.json)
///
/// Controls tool permissions with profile-based allow/deny lists.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawToolsConfig {
/// Active permission profile (e.g. "default", "strict", "permissive")
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
/// Allowed tool patterns
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allow: Vec<String>,
/// Denied tool patterns
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deny: Vec<String>,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
/// Read the tools config section
pub fn get_tools_config() -> Result<OpenClawToolsConfig, AppError> {
let config = read_openclaw_config()?;
let Some(tools_value) = config.get("tools") else {
return Ok(OpenClawToolsConfig {
profile: None,
allow: Vec::new(),
deny: Vec::new(),
extra: HashMap::new(),
});
};
serde_json::from_value(tools_value.clone())
.map_err(|e| AppError::Config(format!("Failed to parse tools config: {e}")))
}
/// Write the tools config section
pub fn set_tools_config(tools: &OpenClawToolsConfig) -> Result<(), AppError> {
let mut config = read_openclaw_config()?;
let value = serde_json::to_value(tools).map_err(|e| AppError::JsonSerialize { source: e })?;
config["tools"] = value;
write_openclaw_config(&config)
}
+71 -65
View File
@@ -1,3 +1,26 @@
//! OpenCode 配置文件读写模块
//!
//! 处理 `~/.config/opencode/opencode.json` 配置文件的读写操作。
//! OpenCode 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。
//!
//! ## 配置文件格式
//!
//! ```json
//! {
//! "$schema": "https://opencode.ai/config.json",
//! "provider": {
//! "my-provider": {
//! "npm": "@ai-sdk/openai-compatible",
//! "options": { "baseURL": "...", "apiKey": "{env:API_KEY}" },
//! "models": { "gpt-4o": { "name": "GPT-4o" } }
//! }
//! },
//! "mcp": {
//! "my-server": { "type": "local", "command": ["..."] }
//! }
//! }
//! ```
use crate::config::write_json_file;
use crate::error::AppError;
use crate::provider::OpenCodeProviderConfig;
@@ -6,29 +29,52 @@ use indexmap::IndexMap;
use serde_json::{json, Map, Value};
use std::path::PathBuf;
// ============================================================================
// Path Functions
// ============================================================================
/// 获取 OpenCode 配置目录
///
/// 默认路径: `~/.config/opencode/`
/// 可通过 settings.opencode_config_dir 覆盖
pub fn get_opencode_dir() -> PathBuf {
if let Some(override_dir) = get_opencode_override_dir() {
return override_dir;
}
// 所有平台统一使用 ~/.config/opencode
dirs::home_dir()
.map(|h| h.join(".config").join("opencode"))
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
}
/// 获取 OpenCode 配置文件路径
///
/// 返回 `~/.config/opencode/opencode.json`
pub fn get_opencode_config_path() -> PathBuf {
get_opencode_dir().join("opencode.json")
}
/// 获取 OpenCode 环境变量文件路径(如果存在)
///
/// 返回 `~/.config/opencode/.env`
#[allow(dead_code)]
pub fn get_opencode_env_path() -> PathBuf {
get_opencode_dir().join(".env")
}
// ============================================================================
// Core Read/Write Functions
// ============================================================================
/// 读取 OpenCode 配置文件
///
/// 返回完整的配置 JSON 对象
pub fn read_opencode_config() -> Result<Value, AppError> {
let path = get_opencode_config_path();
if !path.exists() {
// Return empty config with schema
return Ok(json!({
"$schema": "https://opencode.ai/config.json"
}));
@@ -38,14 +84,23 @@ pub fn read_opencode_config() -> Result<Value, AppError> {
serde_json::from_str(&content).map_err(|e| AppError::json(&path, e))
}
/// 写入 OpenCode 配置文件(原子写入)
///
/// 使用临时文件 + 重命名确保原子性
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
let path = get_opencode_config_path();
// 复用统一的原子写入逻辑(兼容 Windows 上目标文件已存在的情况)
write_json_file(&path, config)?;
log::debug!("OpenCode config written to {path:?}");
Ok(())
}
// ============================================================================
// Provider Functions (Untyped - for raw JSON operations)
// ============================================================================
/// 获取所有供应商配置(原始 JSON)
pub fn get_providers() -> Result<Map<String, Value>, AppError> {
let config = read_opencode_config()?;
Ok(config
@@ -55,6 +110,7 @@ pub fn get_providers() -> Result<Map<String, Value>, AppError> {
.unwrap_or_default())
}
/// 设置供应商配置(原始 JSON)
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
let mut full_config = read_opencode_config()?;
@@ -72,6 +128,7 @@ pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
write_opencode_config(&full_config)
}
/// 删除供应商配置
pub fn remove_provider(id: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
@@ -82,6 +139,11 @@ pub fn remove_provider(id: &str) -> Result<(), AppError> {
write_opencode_config(&config)
}
// ============================================================================
// Provider Functions (Typed - using OpenCodeProviderConfig)
// ============================================================================
/// 获取所有供应商配置(类型化)
pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>, AppError> {
let providers = get_providers()?;
let mut result = IndexMap::new();
@@ -93,6 +155,7 @@ pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>,
}
Err(e) => {
log::warn!("Failed to parse provider '{id}': {e}");
// Skip invalid providers but continue
}
}
}
@@ -100,11 +163,17 @@ pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>,
Ok(result)
}
/// 设置供应商配置(类型化)
pub fn set_typed_provider(id: &str, config: &OpenCodeProviderConfig) -> Result<(), AppError> {
let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?;
set_provider(id, value)
}
// ============================================================================
// MCP Functions
// ============================================================================
/// 获取所有 MCP 服务器配置
pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
let config = read_opencode_config()?;
Ok(config
@@ -114,6 +183,7 @@ pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
.unwrap_or_default())
}
/// 设置 MCP 服务器配置
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
let mut full_config = read_opencode_config()?;
@@ -128,6 +198,7 @@ pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
write_opencode_config(&full_config)
}
/// 删除 MCP 服务器配置
pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
@@ -137,68 +208,3 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
write_opencode_config(&config)
}
pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
let plugins = config.get_mut("plugin").and_then(|v| v.as_array_mut());
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));
if !already_exists {
arr.push(Value::String(plugin_name.to_string()));
}
}
None => {
config["plugin"] = json!([plugin_name]);
}
}
write_opencode_config(&config)
}
pub fn remove_plugin_by_prefix(prefix: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) {
arr.retain(|v| {
v.as_str()
.map(|s| {
if !s.starts_with(prefix) {
return true; // Keep: doesn't match prefix at all
}
let rest = &s[prefix.len()..];
rest.starts_with('-')
})
.unwrap_or(true)
});
if arr.is_empty() {
config.as_object_mut().map(|obj| obj.remove("plugin"));
}
}
write_opencode_config(&config)
}
-3
View File
@@ -5,7 +5,6 @@ use crate::codex_config::get_codex_auth_path;
use crate::config::get_claude_settings_path;
use crate::error::AppError;
use crate::gemini_config::get_gemini_dir;
use crate::openclaw_config::get_openclaw_dir;
use crate::opencode_config::get_opencode_dir;
/// 返回指定应用所使用的提示词文件路径。
@@ -15,7 +14,6 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
AppType::Gemini => get_gemini_dir(),
AppType::OpenCode => get_opencode_dir(),
AppType::OpenClaw => get_openclaw_dir(),
};
let filename = match app {
@@ -23,7 +21,6 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
AppType::Codex => "AGENTS.md",
AppType::Gemini => "GEMINI.md",
AppType::OpenCode => "AGENTS.md",
AppType::OpenClaw => "AGENTS.md", // OpenClaw uses AGENTS.md for agent instructions
};
Ok(base_dir.join(filename))
-5
View File
@@ -235,11 +235,6 @@ pub struct ProviderMeta {
/// - "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 {
+22 -213
View File
@@ -8,10 +8,7 @@ use super::{
failover_switch::FailoverSwitchManager,
provider_router::ProviderRouter,
providers::{get_adapter, ProviderAdapter, ProviderType},
thinking_budget_rectifier::{rectify_thinking_budget, should_rectify_thinking_budget},
thinking_rectifier::{
normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,
},
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
types::{ProxyStatus, RectifierConfig},
ProxyError,
};
@@ -160,7 +157,6 @@ impl RequestForwarder {
// 整流器重试标记:确保整流最多触发一次
let mut rectifier_retried = false;
let mut budget_rectifier_retried = false;
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
let bypass_circuit_breaker = providers.len() == 1;
@@ -262,7 +258,6 @@ impl RequestForwarder {
provider_type,
ProviderType::Claude | ProviderType::ClaudeAuth
);
let mut signature_rectifier_non_retryable_client_error = false;
if is_anthropic_provider {
let error_message = extract_error_message(&e);
@@ -298,185 +293,12 @@ impl RequestForwarder {
// 首次触发:整流请求体
let rectified = rectify_anthropic_request(&mut body);
// 整流未生效:继续尝试 budget 整流路径,避免误判后短路
// 整流未生效:直接返回错误(不可重试客户端错误)
if !rectified.applied {
log::warn!(
"[{app_type_str}] [RECT-006] thinking 签名整流器触发但无可整流内容,继续检查 budget;若 budget 也未命中则按客户端错误返回"
);
signature_rectifier_non_retryable_client_error = true;
} else {
log::info!(
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
app_type_str,
rectified.removed_thinking_blocks,
rectified.removed_redacted_thinking_blocks,
rectified.removed_signature_fields
);
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
let _ = std::mem::replace(&mut rectifier_retried, true);
// 使用同一供应商重试(不计入熔断器)
match self
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
.await
{
Ok(response) => {
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
// 记录成功
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
true,
None,
)
.await;
// 更新当前应用类型使用的 provider
{
let mut current_providers =
self.current_providers.write().await;
current_providers.insert(
app_type_str.to_string(),
(provider.id.clone(), provider.name.clone()),
);
}
// 更新成功统计
{
let mut status = self.status.write().await;
status.success_requests += 1;
status.last_error = None;
let should_switch =
self.current_provider_id_at_start.as_str()
!= provider.id.as_str();
if should_switch {
status.failover_count += 1;
// 异步触发供应商切换,更新 UI/托盘
let fm = self.failover_manager.clone();
let ah = self.app_handle.clone();
let pid = provider.id.clone();
let pname = provider.name.clone();
let at = app_type_str.to_string();
tokio::spawn(async move {
let _ = fm
.try_switch(ah.as_ref(), &at, &pid, &pname)
.await;
});
}
if status.total_requests > 0 {
status.success_rate = (status.success_requests
as f32
/ status.total_requests as f32)
* 100.0;
}
}
return Ok(ForwardResult {
response,
provider: provider.clone(),
});
}
Err(retry_err) => {
// 整流重试仍失败:区分错误类型决定是否记录熔断器
log::warn!(
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
);
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
let is_provider_error = match &retry_err {
ProxyError::Timeout(_)
| ProxyError::ForwardFailed(_) => true,
ProxyError::UpstreamError { status, .. } => {
*status >= 500
}
_ => false,
};
if is_provider_error {
// Provider 问题:记录失败到熔断器
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
false,
Some(retry_err.to_string()),
)
.await;
} else {
// 客户端问题:仅释放 permit,不记录熔断器
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
}
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(retry_err.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: retry_err,
provider: Some(provider.clone()),
});
}
}
}
}
}
// 检测是否需要触发 budget 整流器(仅 Claude/ClaudeAuth 供应商)
if is_anthropic_provider {
let error_message = extract_error_message(&e);
if should_rectify_thinking_budget(
error_message.as_deref(),
&self.rectifier_config,
) {
// 已经重试过:直接返回错误(不可重试客户端错误)
if budget_rectifier_retried {
log::warn!(
"[{app_type_str}] [RECT-013] budget 整流器已触发过,不再重试"
);
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(e.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
});
}
let budget_rectified = rectify_thinking_budget(&mut body);
if !budget_rectified.applied {
log::warn!(
"[{app_type_str}] [RECT-014] budget 整流器触发但无可整流内容,不做无意义重试"
"[{app_type_str}] [RECT-006] 整流器触发但无可整流内容,不做无意义重试"
);
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
self.router
.release_permit_neutral(
&provider.id,
@@ -499,13 +321,15 @@ impl RequestForwarder {
}
log::info!(
"[{}] [RECT-010] thinking budget 整流器触发, before={:?}, after={:?}",
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
app_type_str,
budget_rectified.before,
budget_rectified.after
rectified.removed_thinking_blocks,
rectified.removed_redacted_thinking_blocks,
rectified.removed_signature_fields
);
let _ = std::mem::replace(&mut budget_rectifier_retried, true);
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
let _ = std::mem::replace(&mut rectifier_retried, true);
// 使用同一供应商重试(不计入熔断器)
match self
@@ -513,7 +337,8 @@ impl RequestForwarder {
.await
{
Ok(response) => {
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
// 记录成功
let _ = self
.router
.record_result(
@@ -525,6 +350,7 @@ impl RequestForwarder {
)
.await;
// 更新当前应用类型使用的 provider
{
let mut current_providers =
self.current_providers.write().await;
@@ -534,6 +360,7 @@ impl RequestForwarder {
);
}
// 更新成功统计
{
let mut status = self.status.write().await;
status.success_requests += 1;
@@ -543,11 +370,14 @@ impl RequestForwarder {
!= provider.id.as_str();
if should_switch {
status.failover_count += 1;
// 异步触发供应商切换,更新 UI/托盘
let fm = self.failover_manager.clone();
let ah = self.app_handle.clone();
let pid = provider.id.clone();
let pname = provider.name.clone();
let at = app_type_str.to_string();
tokio::spawn(async move {
let _ = fm
.try_switch(ah.as_ref(), &at, &pid, &pname)
@@ -567,10 +397,12 @@ impl RequestForwarder {
});
}
Err(retry_err) => {
// 整流重试仍失败:区分错误类型决定是否记录熔断器
log::warn!(
"[{app_type_str}] [RECT-012] budget 整流重试仍失败: {retry_err}"
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
);
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
let is_provider_error = match &retry_err {
ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => {
true
@@ -580,6 +412,7 @@ impl RequestForwarder {
};
if is_provider_error {
// Provider 问题:记录失败到熔断器
let _ = self
.router
.record_result(
@@ -591,6 +424,7 @@ impl RequestForwarder {
)
.await;
} else {
// 客户端问题:仅释放 permit,不记录熔断器
self.router
.release_permit_neutral(
&provider.id,
@@ -617,28 +451,6 @@ impl RequestForwarder {
}
}
if signature_rectifier_non_retryable_client_error {
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(e.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
});
}
// 失败:记录失败并更新熔断器
let _ = self
.router
@@ -763,9 +575,6 @@ impl RequestForwarder {
let (mapped_body, _original_model, _mapped_model) =
super::model_mapper::apply_model_mapping(body.clone(), provider);
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
let mapped_body = normalize_thinking_type(mapped_body);
// 转换请求体(如果需要)
let request_body = if needs_transform {
adapter.transform_request(mapped_body, provider)?
-1
View File
@@ -21,7 +21,6 @@ pub mod response_handler;
pub mod response_processor;
pub(crate) mod server;
pub mod session;
pub mod thinking_budget_rectifier;
pub mod thinking_rectifier;
pub(crate) mod types;
pub mod usage;
+2 -36
View File
@@ -97,21 +97,11 @@ impl ModelMapping {
/// 检测请求是否启用了 thinking 模式
pub fn has_thinking_enabled(body: &Value) -> bool {
match body
.get("thinking")
body.get("thinking")
.and_then(|v| v.as_object())
.and_then(|o| o.get("type"))
.and_then(|t| t.as_str())
{
Some("enabled") | Some("adaptive") => true,
Some("disabled") | None => false,
Some(other) => {
log::warn!(
"[ModelMapper] 未知 thinking.type='{other}',按 disabled 处理以避免误路由 reasoning 模型"
);
false
}
}
== Some("enabled")
}
/// 对请求体应用模型映射
@@ -310,30 +300,6 @@ mod tests {
assert!(mapped.is_none());
}
#[test]
fn test_thinking_adaptive() {
let provider = create_provider_with_mapping();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "adaptive"}
});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "reasoning-model");
assert_eq!(mapped, Some("reasoning-model".to_string()));
}
#[test]
fn test_thinking_unknown_type() {
let provider = create_provider_with_mapping();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "some_future_type"}
});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "sonnet-mapped");
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
}
#[test]
fn test_case_insensitive() {
let provider = create_provider_with_mapping();
+3 -15
View File
@@ -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();
-8
View File
@@ -136,10 +136,6 @@ impl ProviderType {
// OpenCode doesn't support proxy, but return a default type for completeness
ProviderType::Codex // Fallback to Codex-like type
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy, but return a default type for completeness
ProviderType::Codex // Fallback to Codex-like type
}
}
}
@@ -188,10 +184,6 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
// OpenCode doesn't support proxy, fallback to Codex adapter
Box::new(CodexAdapter::new())
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy, fallback to Codex adapter
Box::new(CodexAdapter::new())
}
}
}
@@ -1,359 +0,0 @@
//! Thinking Budget 整流器
//!
//! 用于自动修复 Anthropic API 中因 thinking budget 约束导致的请求错误。
//! 当上游 API 返回 budget_tokens 相关错误时,系统会自动调整 budget 参数并重试。
use super::types::RectifierConfig;
use serde_json::Value;
/// 最大 thinking budget tokens
const MAX_THINKING_BUDGET: u64 = 32000;
/// 最大 max_tokens 值
const MAX_TOKENS_VALUE: u64 = 64000;
/// max_tokens 必须大于 budget_tokens
const MIN_MAX_TOKENS_FOR_BUDGET: u64 = MAX_THINKING_BUDGET + 1;
/// Budget 整流结果
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BudgetRectifySnapshot {
/// max_tokens
pub max_tokens: Option<u64>,
/// thinking.type
pub thinking_type: Option<String>,
/// thinking.budget_tokens
pub thinking_budget_tokens: Option<u64>,
}
/// Budget 整流结果
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BudgetRectifyResult {
/// 是否应用了整流
pub applied: bool,
/// 整流前快照
pub before: BudgetRectifySnapshot,
/// 整流后快照
pub after: BudgetRectifySnapshot,
}
/// 检测是否需要触发 thinking budget 整流器
///
/// 检测条件:error message 同时包含 `budget_tokens` + `thinking` 相关约束
pub fn should_rectify_thinking_budget(
error_message: Option<&str>,
config: &RectifierConfig,
) -> bool {
// 检查总开关
if !config.enabled {
return false;
}
// 检查子开关
if !config.request_thinking_budget {
return false;
}
let Some(msg) = error_message else {
return false;
};
let lower = msg.to_lowercase();
// 与 CCH 对齐:仅在包含 budget_tokens + thinking + 1024 约束时触发
let has_budget_tokens_reference =
lower.contains("budget_tokens") || lower.contains("budget tokens");
let has_thinking_reference = lower.contains("thinking");
let has_1024_constraint = lower.contains("greater than or equal to 1024")
|| lower.contains(">= 1024")
|| (lower.contains("1024") && lower.contains("input should be"));
if has_budget_tokens_reference && has_thinking_reference && has_1024_constraint {
return true;
}
false
}
/// 对请求体执行 budget 整流
///
/// 整流动作:
/// - `thinking.type = "enabled"`
/// - `thinking.budget_tokens = 32000`
/// - 如果 `max_tokens < 32001`,设为 `64000`
pub fn rectify_thinking_budget(body: &mut Value) -> BudgetRectifyResult {
let before = snapshot_budget(body);
// 与 CCH 对齐:adaptive 请求不改写
if before.thinking_type.as_deref() == Some("adaptive") {
return BudgetRectifyResult {
applied: false,
before: before.clone(),
after: before,
};
}
// 与 CCH 对齐:缺少/非法 thinking 时自动创建后再整流
if !body.get("thinking").is_some_and(Value::is_object) {
body["thinking"] = Value::Object(serde_json::Map::new());
}
let Some(thinking) = body.get_mut("thinking").and_then(|t| t.as_object_mut()) else {
return BudgetRectifyResult {
applied: false,
before: before.clone(),
after: before,
};
};
thinking.insert("type".to_string(), Value::String("enabled".to_string()));
thinking.insert(
"budget_tokens".to_string(),
Value::Number(MAX_THINKING_BUDGET.into()),
);
if before.max_tokens.is_none() || before.max_tokens < Some(MIN_MAX_TOKENS_FOR_BUDGET) {
body["max_tokens"] = Value::Number(MAX_TOKENS_VALUE.into());
}
let after = snapshot_budget(body);
BudgetRectifyResult {
applied: before != after,
before,
after,
}
}
fn snapshot_budget(body: &Value) -> BudgetRectifySnapshot {
let max_tokens = body.get("max_tokens").and_then(|v| v.as_u64());
let thinking = body.get("thinking").and_then(|t| t.as_object());
let thinking_type = thinking
.and_then(|t| t.get("type"))
.and_then(|v| v.as_str())
.map(ToString::to_string);
let thinking_budget_tokens = thinking
.and_then(|t| t.get("budget_tokens"))
.and_then(|v| v.as_u64());
BudgetRectifySnapshot {
max_tokens,
thinking_type,
thinking_budget_tokens,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn enabled_config() -> RectifierConfig {
RectifierConfig {
enabled: true,
request_thinking_signature: true,
request_thinking_budget: true,
}
}
fn budget_disabled_config() -> RectifierConfig {
RectifierConfig {
enabled: true,
request_thinking_signature: true,
request_thinking_budget: false,
}
}
fn master_disabled_config() -> RectifierConfig {
RectifierConfig {
enabled: false,
request_thinking_signature: true,
request_thinking_budget: true,
}
}
// ==================== should_rectify_thinking_budget 测试 ====================
#[test]
fn test_detect_budget_tokens_thinking_error() {
assert!(should_rectify_thinking_budget(
Some("thinking.budget_tokens: Input should be greater than or equal to 1024"),
&enabled_config()
));
}
#[test]
fn test_detect_budget_tokens_max_tokens_error() {
assert!(!should_rectify_thinking_budget(
Some("budget_tokens must be less than max_tokens"),
&enabled_config()
));
}
#[test]
fn test_detect_budget_tokens_1024_error() {
assert!(!should_rectify_thinking_budget(
Some("budget_tokens: value must be at least 1024"),
&enabled_config()
));
}
#[test]
fn test_detect_budget_tokens_with_thinking_and_1024_error() {
assert!(should_rectify_thinking_budget(
Some("thinking budget_tokens must be >= 1024"),
&enabled_config()
));
}
#[test]
fn test_no_trigger_for_unrelated_error() {
assert!(!should_rectify_thinking_budget(
Some("Request timeout"),
&enabled_config()
));
assert!(!should_rectify_thinking_budget(None, &enabled_config()));
}
#[test]
fn test_disabled_budget_config() {
assert!(!should_rectify_thinking_budget(
Some("thinking.budget_tokens: Input should be greater than or equal to 1024"),
&budget_disabled_config()
));
}
#[test]
fn test_master_disabled() {
assert!(!should_rectify_thinking_budget(
Some("thinking.budget_tokens: Input should be greater than or equal to 1024"),
&master_disabled_config()
));
}
// ==================== rectify_thinking_budget 测试 ====================
#[test]
fn test_rectify_budget_basic() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 512 },
"max_tokens": 1024
});
let result = rectify_thinking_budget(&mut body);
assert!(result.applied);
assert_eq!(result.before.thinking_type.as_deref(), Some("enabled"));
assert_eq!(result.after.thinking_type.as_deref(), Some("enabled"));
assert_eq!(result.before.thinking_budget_tokens, Some(512));
assert_eq!(
result.after.thinking_budget_tokens,
Some(MAX_THINKING_BUDGET)
);
assert_eq!(result.before.max_tokens, Some(1024));
assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET);
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
}
#[test]
fn test_rectify_budget_skips_adaptive() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive", "budget_tokens": 512 },
"max_tokens": 1024
});
let result = rectify_thinking_budget(&mut body);
assert!(!result.applied);
assert_eq!(result.before, result.after);
assert_eq!(body["thinking"]["type"], "adaptive");
assert_eq!(body["thinking"]["budget_tokens"], 512);
assert_eq!(body["max_tokens"], 1024);
}
#[test]
fn test_rectify_budget_preserves_large_max_tokens() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 512 },
"max_tokens": 100000
});
let result = rectify_thinking_budget(&mut body);
assert!(result.applied);
assert_eq!(result.before.max_tokens, Some(100000));
assert_eq!(result.after.max_tokens, Some(100000));
assert_eq!(body["max_tokens"], 100000);
}
#[test]
fn test_rectify_budget_creates_thinking_object_when_missing() {
let mut body = json!({
"model": "claude-test",
"max_tokens": 1024
});
let result = rectify_thinking_budget(&mut body);
assert!(result.applied);
assert_eq!(result.before.thinking_type, None);
assert_eq!(result.after.thinking_type.as_deref(), Some("enabled"));
assert_eq!(
result.after.thinking_budget_tokens,
Some(MAX_THINKING_BUDGET)
);
assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET);
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
}
#[test]
fn test_rectify_budget_no_max_tokens() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 512 }
});
let result = rectify_thinking_budget(&mut body);
assert!(result.applied);
assert_eq!(result.before.max_tokens, None);
assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
}
#[test]
fn test_rectify_budget_normalizes_non_enabled_type() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "disabled", "budget_tokens": 512 },
"max_tokens": 1024
});
let result = rectify_thinking_budget(&mut body);
assert!(result.applied);
assert_eq!(result.before.thinking_type.as_deref(), Some("disabled"));
assert_eq!(result.after.thinking_type.as_deref(), Some("enabled"));
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET);
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
}
#[test]
fn test_rectify_budget_no_change_when_already_valid() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 32000 },
"max_tokens": 64001
});
let result = rectify_thinking_budget(&mut body);
assert!(!result.applied);
assert_eq!(result.before, result.after);
assert_eq!(body["thinking"]["budget_tokens"], 32000);
assert_eq!(body["max_tokens"], 64001);
}
}
+3 -271
View File
@@ -59,12 +59,10 @@ pub fn should_rectify_thinking_signature(
}
// 场景3: expected thinking or redacted_thinking, found tool_use
// 与 CCH 对齐:要求明确包含 tool_use,避免过宽匹配。
// 错误示例: "Expected `thinking` or `redacted_thinking`, but found `tool_use`"
if lower.contains("expected")
&& (lower.contains("thinking") || lower.contains("redacted_thinking"))
&& lower.contains("found")
&& lower.contains("tool_use")
{
return true;
}
@@ -75,28 +73,6 @@ pub fn should_rectify_thinking_signature(
return true;
}
// 场景5: signature 字段不被接受(第三方渠道)
// 错误示例: "xxx.signature: Extra inputs are not permitted"
if lower.contains("signature") && lower.contains("extra inputs are not permitted") {
return true;
}
// 场景6: thinking/redacted_thinking 块被修改
// 错误示例: "thinking or redacted_thinking blocks ... cannot be modified"
if (lower.contains("thinking") || lower.contains("redacted_thinking"))
&& lower.contains("cannot be modified")
{
return true;
}
// 场景7: 非法请求(与 CCH 对齐,按 invalid request 统一兜底)
if lower.contains("非法请求")
|| lower.contains("illegal request")
|| lower.contains("invalid request")
{
return true;
}
false
}
@@ -183,13 +159,11 @@ pub fn rectify_anthropic_request(body: &mut Value) -> RectifyResult {
/// 判断是否需要删除顶层 thinking 字段
fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
// 检查 thinking 是否启用
let thinking_type = body
let thinking_enabled = body
.get("thinking")
.and_then(|t| t.get("type"))
.and_then(|t| t.as_str());
// 与 CCH 对齐:仅 type=enabled 视为开启
let thinking_enabled = thinking_type == Some("enabled");
.and_then(|t| t.as_str())
== Some("enabled");
if !thinking_enabled {
return false;
@@ -228,11 +202,6 @@ fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
}
/// 与 CCH 对齐:请求前不做 thinking type 主动改写。
pub fn normalize_thinking_type(body: Value) -> Value {
body
}
#[cfg(test)]
mod tests {
use super::*;
@@ -242,7 +211,6 @@ mod tests {
RectifierConfig {
enabled: true,
request_thinking_signature: true,
request_thinking_budget: true,
}
}
@@ -250,7 +218,6 @@ mod tests {
RectifierConfig {
enabled: true,
request_thinking_signature: false,
request_thinking_budget: false,
}
}
@@ -258,7 +225,6 @@ mod tests {
RectifierConfig {
enabled: false,
request_thinking_signature: true,
request_thinking_budget: true,
}
}
@@ -298,14 +264,6 @@ mod tests {
));
}
#[test]
fn test_no_detect_thinking_expected_without_tool_use() {
assert!(!should_rectify_thinking_signature(
Some("messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `text`."),
&enabled_config()
));
}
#[test]
fn test_detect_must_start_with_thinking() {
assert!(should_rectify_thinking_signature(
@@ -460,230 +418,4 @@ mod tests {
// 此时会触发删除顶层 thinking 的逻辑
// 这是预期行为:整流后如果仍然不符合要求,就删除顶层 thinking
}
// ==================== 新增错误场景检测测试 ====================
#[test]
fn test_detect_signature_extra_inputs() {
// 场景5: signature 字段不被接受
assert!(should_rectify_thinking_signature(
Some("xxx.signature: Extra inputs are not permitted"),
&enabled_config()
));
}
#[test]
fn test_detect_thinking_cannot_be_modified() {
// 场景6: thinking blocks cannot be modified
assert!(should_rectify_thinking_signature(
Some("thinking or redacted_thinking blocks in the response cannot be modified"),
&enabled_config()
));
}
#[test]
fn test_detect_invalid_request() {
// 场景7: 非法请求(与 CCH 对齐,统一触发)
assert!(should_rectify_thinking_signature(
Some("非法请求:thinking signature 不合法"),
&enabled_config()
));
assert!(should_rectify_thinking_signature(
Some("illegal request: tool_use block mismatch"),
&enabled_config()
));
assert!(should_rectify_thinking_signature(
Some("invalid request: malformed JSON"),
&enabled_config()
));
}
#[test]
fn test_do_not_detect_thinking_type_tag_mismatch() {
// 与 CCH 对齐:adaptive tag mismatch 不触发签名整流器
assert!(!should_rectify_thinking_signature(
Some("Input tag 'adaptive' found using 'type' does not match expected tags"),
&enabled_config()
));
}
// ==================== adaptive thinking type 测试 ====================
#[test]
fn test_rectify_keeps_adaptive_when_no_legacy_blocks() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive" },
"messages": [{
"role": "user",
"content": [{ "type": "text", "text": "hello" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
assert_eq!(body["thinking"]["type"], "adaptive");
assert!(body["thinking"].get("budget_tokens").is_none());
}
#[test]
fn test_rectify_adaptive_preserves_existing_budget_tokens() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive", "budget_tokens": 5000 },
"messages": [{
"role": "user",
"content": [{ "type": "text", "text": "hello" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
assert_eq!(body["thinking"]["type"], "adaptive");
assert_eq!(body["thinking"]["budget_tokens"], 5000);
}
#[test]
fn test_rectify_does_not_change_enabled_type() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 1024 },
"messages": [{
"role": "user",
"content": [{ "type": "text", "text": "hello" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
assert_eq!(body["thinking"]["type"], "enabled");
}
#[test]
fn test_rectify_removes_top_level_thinking_adaptive() {
// 顶层 thinking 仅在 type=enabled 且 tool_use 场景才会删除,adaptive 不删除
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive" },
"messages": [{
"role": "assistant",
"content": [
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {} }
]
}, {
"role": "user",
"content": [{ "type": "tool_result", "tool_use_id": "toolu_1", "content": "ok" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
assert_eq!(body["thinking"]["type"], "adaptive");
}
#[test]
fn test_rectify_adaptive_still_cleans_legacy_signature_blocks() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive" },
"messages": [{
"role": "assistant",
"content": [
{ "type": "thinking", "thinking": "t", "signature": "sig_thinking" },
{ "type": "text", "text": "hello", "signature": "sig_text" }
]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(result.applied);
assert_eq!(result.removed_thinking_blocks, 1);
let content = body["messages"][0]["content"].as_array().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0]["type"], "text");
assert!(content[0].get("signature").is_none());
assert_eq!(body["thinking"]["type"], "adaptive");
}
// ==================== normalize_thinking_type 测试 ====================
#[test]
fn test_normalize_thinking_type_adaptive_unchanged() {
let body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive" }
});
let result = normalize_thinking_type(body);
assert_eq!(result["thinking"]["type"], "adaptive");
assert!(result["thinking"].get("budget_tokens").is_none());
}
#[test]
fn test_normalize_thinking_type_enabled_unchanged() {
let body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 2048 }
});
let result = normalize_thinking_type(body);
assert_eq!(result["thinking"]["type"], "enabled");
assert_eq!(result["thinking"]["budget_tokens"], 2048);
}
#[test]
fn test_normalize_thinking_type_disabled_unchanged() {
let body = json!({
"model": "claude-test",
"thinking": { "type": "disabled" }
});
let result = normalize_thinking_type(body);
assert_eq!(result["thinking"]["type"], "disabled");
}
#[test]
fn test_normalize_thinking_type_preserves_budget() {
let body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive", "budget_tokens": 5000 }
});
let result = normalize_thinking_type(body);
assert_eq!(result["thinking"]["type"], "adaptive");
assert_eq!(result["thinking"]["budget_tokens"], 5000);
}
#[test]
fn test_normalize_thinking_type_no_thinking() {
let body = json!({
"model": "claude-test"
});
let result = normalize_thinking_type(body);
assert!(result.get("thinking").is_none());
}
#[test]
fn test_normalize_thinking_type_unknown_unchanged() {
let body = json!({
"model": "claude-test",
"thinking": { "type": "unexpected", "budget_tokens": 100 }
});
let result = normalize_thinking_type(body);
assert_eq!(result["thinking"]["type"], "unexpected");
assert_eq!(result["thinking"]["budget_tokens"], 100);
}
}
+14 -48
View File
@@ -113,8 +113,6 @@ pub struct ProxyTakeoverStatus {
pub claude: bool,
pub codex: bool,
pub gemini: bool,
pub opencode: bool,
pub openclaw: bool,
}
/// API 格式类型(预留,当前不需要格式转换)
@@ -197,22 +195,17 @@ pub struct AppProxyConfig {
/// 整流器配置
///
/// 存储在 settings 表中
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct RectifierConfig {
/// 总开关:是否启用整流器(默认开启)
#[serde(default = "default_true")]
/// 总开关:是否启用整流器
#[serde(default)]
pub enabled: bool,
/// 请求整流:启用 thinking 签名整流器(默认开启)
/// 请求整流:启用 thinking 签名整流器
///
/// 处理错误:Invalid 'signature' in 'thinking' block
#[serde(default = "default_true")]
#[serde(default)]
pub request_thinking_signature: bool,
/// 请求整流:启用 thinking budget 整流器(默认开启)
///
/// 处理错误:budget_tokens + thinking 相关约束
#[serde(default = "default_true")]
pub request_thinking_budget: bool,
}
fn default_true() -> bool {
@@ -223,16 +216,6 @@ fn default_log_level() -> String {
"info".to_string()
}
impl Default for RectifierConfig {
fn default() -> Self {
Self {
enabled: true,
request_thinking_signature: true,
request_thinking_budget: true,
}
}
}
/// 日志配置
///
/// 存储在 settings 表的 log_config 字段中(JSON 格式)
@@ -278,49 +261,32 @@ mod tests {
use super::*;
#[test]
fn test_rectifier_config_default_enabled() {
// 验证 RectifierConfig::default() 返回全开启状态
fn test_rectifier_config_default_disabled() {
// 验证 RectifierConfig::default() 返回全禁用状态
let config = RectifierConfig::default();
assert!(config.enabled, "整流器总开关默认应为 true");
assert!(!config.enabled, "整流器总开关默认应为 false");
assert!(
config.request_thinking_signature,
"thinking 签名整流器默认应为 true"
);
assert!(
config.request_thinking_budget,
"thinking budget 整流器默认应为 true"
!config.request_thinking_signature,
"thinking 签名整流器默认应为 false"
);
}
#[test]
fn test_rectifier_config_serde_default() {
// 验证反序列化缺字段时使用默认值 true
// 验证反序列化缺字段时使用默认值 false
let json = "{}";
let config: RectifierConfig = serde_json::from_str(json).unwrap();
assert!(config.enabled);
assert!(config.request_thinking_signature);
assert!(config.request_thinking_budget);
assert!(!config.enabled);
assert!(!config.request_thinking_signature);
}
#[test]
fn test_rectifier_config_serde_explicit_true() {
// 验证显式设置 true 时正确反序列化
let json =
r#"{"enabled": true, "requestThinkingSignature": true, "requestThinkingBudget": true}"#;
let json = r#"{"enabled": true, "requestThinkingSignature": true}"#;
let config: RectifierConfig = serde_json::from_str(json).unwrap();
assert!(config.enabled);
assert!(config.request_thinking_signature);
assert!(config.request_thinking_budget);
}
#[test]
fn test_rectifier_config_serde_partial_fields() {
// 验证只设置部分字段时,缺失字段使用默认值 true
let json = r#"{"enabled": true, "requestThinkingSignature": false}"#;
let config: RectifierConfig = serde_json::from_str(json).unwrap();
assert!(config.enabled);
assert!(!config.request_thinking_signature);
assert!(config.request_thinking_budget);
}
#[test]
+146
View File
@@ -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(&current_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, &current_id, &provider)?,
AppType::Claude => Self::sync_claude_live(config, &current_id, &provider)?,
AppType::Gemini => Self::sync_gemini_live(config, &current_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(())
}
}
-9
View File
@@ -123,11 +123,6 @@ impl McpService {
&server.server,
)?;
}
AppType::OpenClaw => {
// OpenClaw MCP support is still in development (Issue #4834)
// Skip for now
log::debug!("OpenClaw MCP support is still in development, skipping sync");
}
}
Ok(())
}
@@ -153,10 +148,6 @@ impl McpService {
AppType::OpenCode => {
mcp::remove_server_from_opencode(id)?;
}
AppType::OpenClaw => {
// OpenClaw MCP support is still in development
log::debug!("OpenClaw MCP support is still in development, skipping remove");
}
}
Ok(())
}
+1 -6
View File
@@ -2,7 +2,6 @@ pub mod config;
pub mod env_checker;
pub mod env_manager;
pub mod mcp;
pub mod omo;
pub mod prompt;
pub mod provider;
pub mod proxy;
@@ -10,15 +9,11 @@ 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};
-623
View File
@@ -1,623 +0,0 @@
use crate::config::write_json_file;
use crate::database::OmoGlobalConfig;
use crate::error::AppError;
use crate::opencode_config::get_opencode_dir;
use crate::store::AppState;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OmoLocalFileData {
pub agents: Option<Value>,
pub categories: Option<Value>,
pub other_fields: Option<Value>,
pub global: OmoGlobalConfig,
pub file_path: String,
pub last_modified: Option<String>,
}
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 resolve_local_config_path(v: &OmoVariant) -> Result<PathBuf, AppError> {
let config_path = Self::config_path(v);
if config_path.exists() {
return Ok(config_path);
}
let json_path = config_path.with_extension("json");
if json_path.exists() {
return Ok(json_path);
}
Err(AppError::OmoConfigNotFound)
}
fn read_jsonc_object(path: &Path) -> Result<Map<String, Value>, AppError> {
let content = std::fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
let cleaned = Self::strip_jsonc_comments(&content);
let parsed: Value = serde_json::from_str(&cleaned)
.map_err(|e| AppError::Config(format!("Failed to parse oh-my-opencode config: {e}")))?;
parsed
.as_object()
.cloned()
.ok_or_else(|| AppError::Config("Expected JSON object".to_string()))
}
// ── Field extraction ───────────────────────────────────
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()) {
other.insert(k.clone(), v.clone());
}
}
other
}
fn extract_string_array(val: &Value) -> Vec<String> {
val.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
fn merge_global_from_obj(obj: &Map<String, Value>, global: &mut OmoGlobalConfig) {
if let Some(v) = obj.get("$schema") {
global.schema_url = v.as_str().map(|s| s.to_string());
}
for (key, target) in [
("disabled_agents", &mut global.disabled_agents),
("disabled_mcps", &mut global.disabled_mcps),
("disabled_hooks", &mut global.disabled_hooks),
("disabled_skills", &mut global.disabled_skills),
] {
if let Some(v) = obj.get(key) {
*target = Self::extract_string_array(v);
}
}
for (key, target) in [
("sisyphus_agent", &mut global.sisyphus_agent),
("lsp", &mut global.lsp),
("experimental", &mut global.experimental),
("background_task", &mut global.background_task),
(
"browser_automation_engine",
&mut global.browser_automation_engine,
),
("claude_code", &mut global.claude_code),
] {
if let Some(v) = obj.get(key) {
*target = Some(v.clone());
}
}
}
// ── 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());
}
}
fn insert_string_array(result: &mut Map<String, Value>, key: &str, values: &[String]) {
if !values.is_empty() {
result.insert(
key.to_string(),
serde_json::to_value(values).unwrap_or(Value::Array(vec![])),
);
}
}
fn insert_object_entries(result: &mut Map<String, Value>, value: Option<&Value>) {
if let Some(Value::Object(map)) = value {
for (k, v) in map {
result.insert(k.clone(), v.clone());
}
}
}
// ── Public API (variant-parameterized) ─────────────────
pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> {
let config_path = Self::config_path(v);
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);
}
crate::opencode_config::remove_plugin_by_prefix(v.plugin_prefix)?;
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)?;
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 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);
(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);
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
write_json_file(&config_path, &merged)?;
crate::opencode_config::add_plugin(v.plugin_name)?;
log::info!("{} config written to {config_path:?}", v.label);
Ok(())
}
fn merge_config(
v: &OmoVariant,
global: &OmoGlobalConfig,
profile_data: Option<&OmoProfileData>,
) -> Value {
let mut result = Map::new();
let use_common_config = profile_data.map(|(_, _, _, uc)| *uc).unwrap_or(true);
if use_common_config {
if let Some(url) = &global.schema_url {
result.insert("$schema".to_string(), Value::String(url.clone()));
}
Self::insert_opt_value(&mut result, "sisyphus_agent", &global.sisyphus_agent);
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_opt_value(&mut result, "lsp", &global.lsp);
Self::insert_opt_value(&mut result, "experimental", &global.experimental);
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_object_entries(&mut result, other_fields.as_ref());
}
Value::Object(result)
}
pub fn import_from_local(
state: &AppState,
v: &OmoVariant,
) -> Result<crate::provider::Provider, AppError> {
let actual_path = Self::resolve_local_config_path(v)?;
let obj = Self::read_jsonc_object(&actual_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());
}
}
settings.insert("useCommonConfig".to_string(), Value::Bool(true));
let other = Self::extract_other_fields_with_keys(&obj, v.known_keys);
if !other.is_empty() {
settings.insert("otherFields".to_string(), Value::Object(other));
}
let mut global = state.db.get_omo_global_config(v.config_key)?;
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)?;
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 settings_config =
serde_json::to_value(&settings).unwrap_or_else(|_| serde_json::json!({}));
let provider = crate::provider::Provider {
id: provider_id,
name,
settings_config,
website_url: None,
category: Some(v.category.to_string()),
created_at: Some(chrono::Utc::now().timestamp_millis()),
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
};
state.db.save_provider("opencode", &provider)?;
state
.db
.set_omo_provider_current("opencode", &provider.id, v.category)?;
Self::write_config_to_file(state, v)?;
Ok(provider)
}
pub fn read_local_file(v: &OmoVariant) -> Result<OmoLocalFileData, AppError> {
let actual_path = Self::resolve_local_config_path(v)?;
let metadata = std::fs::metadata(&actual_path).ok();
let last_modified = metadata
.and_then(|m| m.modified().ok())
.map(|t| chrono::DateTime::<chrono::Utc>::from(t).to_rfc3339());
let obj = Self::read_jsonc_object(&actual_path)?;
Ok(Self::build_local_file_data(
v,
&obj,
actual_path.to_string_lossy().to_string(),
last_modified,
))
}
fn build_local_file_data(
v: &OmoVariant,
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 other = Self::extract_other_fields_with_keys(obj, v.known_keys);
let other_fields = if other.is_empty() {
None
} else {
Some(Value::Object(other))
};
let mut global = OmoGlobalConfig::default();
Self::merge_global_from_obj(obj, &mut global);
global.other_fields = other_fields.clone();
OmoLocalFileData {
agents,
categories,
other_fields,
global,
file_path,
last_modified,
}
}
fn strip_jsonc_comments(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
let mut in_string = false;
let mut escape = false;
while let Some(&c) = chars.peek() {
if in_string {
result.push(c);
chars.next();
if escape {
escape = false;
} else if c == '\\' {
escape = true;
} else if c == '"' {
in_string = false;
}
} else if c == '"' {
in_string = true;
result.push(c);
chars.next();
} else if c == '/' {
chars.next();
match chars.peek() {
Some('/') => {
chars.next();
while let Some(&nc) = chars.peek() {
if nc == '\n' {
break;
}
chars.next();
}
}
Some('*') => {
chars.next();
while let Some(nc) = chars.next() {
if nc == '*' {
if let Some(&'/') = chars.peek() {
chars.next();
break;
}
}
}
}
_ => {
result.push('/');
}
}
} else {
result.push(c);
chars.next();
}
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_jsonc_comments() {
let input = r#"{
// This is a comment
"key": "value", // inline comment
/* multi
line */
"key2": "val//ue"
}"#;
let result = OmoService::strip_jsonc_comments(input);
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["key"], "value");
assert_eq!(parsed["key2"], "val//ue");
}
#[test]
fn test_merge_config_empty() {
let global = OmoGlobalConfig::default();
let merged = OmoService::merge_config(&STANDARD, &global, None);
assert!(merged.is_object());
}
#[test]
fn test_merge_config_with_profile() {
let global = OmoGlobalConfig {
schema_url: Some("https://example.com/schema.json".to_string()),
disabled_agents: vec!["explore".to_string()],
..Default::default()
};
let agents = Some(serde_json::json!({
"sisyphus": { "model": "claude-opus-4-5" }
}));
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 obj = merged.as_object().unwrap();
assert_eq!(obj["$schema"], "https://example.com/schema.json");
assert_eq!(obj["disabled_agents"], serde_json::json!(["explore"]));
assert!(obj.contains_key("agents"));
assert_eq!(obj["agents"]["sisyphus"]["model"], "claude-opus-4-5");
}
#[test]
fn test_merge_config_without_common_config() {
let global = OmoGlobalConfig {
schema_url: Some("https://example.com/schema.json".to_string()),
disabled_agents: vec!["explore".to_string()],
..Default::default()
};
let agents = Some(serde_json::json!({
"sisyphus": { "model": "claude-opus-4-5" }
}));
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 obj = merged.as_object().unwrap();
assert!(!obj.contains_key("$schema"));
assert!(!obj.contains_key("disabled_agents"));
assert!(obj.contains_key("agents"));
}
#[test]
fn test_build_local_file_data_keeps_unknown_top_level_fields_in_global() {
let obj = serde_json::json!({
"$schema": "https://example.com/schema.json",
"disabled_agents": ["oracle"],
"agents": {
"sisyphus": { "model": "claude-opus-4-6" }
},
"categories": {
"code": { "model": "gpt-5.3" }
},
"custom_top_level": {
"enabled": true
}
});
let obj_map = obj.as_object().unwrap().clone();
let data = OmoService::build_local_file_data(
&STANDARD,
&obj_map,
"/tmp/oh-my-opencode.jsonc".to_string(),
None,
);
assert_eq!(
data.global.schema_url.as_deref(),
Some("https://example.com/schema.json")
);
assert_eq!(data.global.disabled_agents, vec!["oracle".to_string()]);
assert_eq!(
data.other_fields,
Some(serde_json::json!({
"custom_top_level": { "enabled": true }
}))
);
assert_eq!(data.global.other_fields, data.other_fields);
}
#[test]
fn test_merge_config_ignores_non_object_other_fields() {
let global = OmoGlobalConfig {
other_fields: Some(serde_json::json!(["global_non_object"])),
..Default::default()
};
let agents = None;
let categories = None;
let other_fields = Some(serde_json::json!("profile_non_object"));
let profile_data = (agents, categories, other_fields, true);
let merged = OmoService::merge_config(&STANDARD, &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"));
}
}
+18 -584
View File
@@ -191,485 +191,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.
@@ -719,7 +244,7 @@ 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(&current_id) {
write_live_partial(&app_type, provider)?;
write_live_snapshot(&app_type, provider)?;
}
// Note: get_effective_current_provider already validates existence,
// so providers.get() should always succeed here
@@ -815,21 +340,6 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
let config = read_opencode_config()?;
Ok(config)
}
AppType::OpenClaw => {
use crate::openclaw_config::{get_openclaw_config_path, read_openclaw_config};
let config_path = get_openclaw_config_path();
if !config_path.exists() {
return Err(AppError::localized(
"openclaw.config.missing",
"OpenClaw 配置文件不存在",
"OpenClaw configuration file not found",
));
}
let config = read_openclaw_config()?;
Ok(config)
}
}
}
@@ -838,12 +348,6 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
/// Returns `Ok(true)` if a provider was actually imported,
/// `Ok(false)` if skipped (providers already exist for this app).
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
// Additive mode apps (OpenCode, OpenClaw) should use their dedicated
// import_xxx_providers_from_live functions, not this generic default config import
if app_type.is_additive_mode() {
return Ok(false);
}
{
let providers = state.db.get_all_providers(app_type.as_str())?;
if !providers.is_empty() {
@@ -911,9 +415,23 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
"config": config_obj
})
}
// OpenCode and OpenClaw use additive mode and are handled by early return above
AppType::OpenCode | AppType::OpenClaw => {
unreachable!("additive mode apps are handled by early return")
AppType::OpenCode => {
// OpenCode uses additive mode - import from live is not the same pattern
// For now, return an empty config structure
use crate::opencode_config::{get_opencode_config_path, read_opencode_config};
let config_path = get_opencode_config_path();
if !config_path.exists() {
return Err(AppError::localized(
"opencode.live.missing",
"OpenCode 配置文件不存在",
"OpenCode configuration file is missing",
));
}
// For OpenCode, we return the full config - but note that OpenCode
// uses additive mode, so importing defaults works differently
read_opencode_config()?
}
};
@@ -1091,87 +609,3 @@ pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, Ap
Ok(imported)
}
/// Import all providers from OpenClaw live config to database
///
/// This imports existing providers from ~/.openclaw/openclaw.json
/// into the CC Switch database. Each provider found will be added to the
/// database with is_current set to false.
pub fn import_openclaw_providers_from_live(state: &AppState) -> Result<usize, AppError> {
use crate::openclaw_config;
let providers = openclaw_config::get_typed_providers()?;
if providers.is_empty() {
return Ok(0);
}
let mut imported = 0;
let existing = state.db.get_all_providers("openclaw")?;
for (id, config) in providers {
// Validate: skip entries with empty id or no models
if id.trim().is_empty() {
log::warn!("Skipping OpenClaw provider with empty id");
continue;
}
if config.models.is_empty() {
log::warn!("Skipping OpenClaw provider '{id}': no models defined");
continue;
}
// Skip if already exists in database
if existing.contains_key(&id) {
log::debug!("OpenClaw provider '{id}' already exists in database, skipping");
continue;
}
// Convert to Value for settings_config
let settings_config = match serde_json::to_value(&config) {
Ok(v) => v,
Err(e) => {
log::warn!("Failed to serialize OpenClaw provider '{id}': {e}");
continue;
}
};
// Determine display name: use first model name if available, otherwise use id
let display_name = config
.models
.first()
.and_then(|m| m.name.clone())
.unwrap_or_else(|| id.clone());
// Create provider
let provider = Provider::with_id(id.clone(), display_name, settings_config, None);
// Save to database
if let Err(e) = state.db.save_provider("openclaw", &provider) {
log::warn!("Failed to import OpenClaw provider '{id}': {e}");
continue;
}
imported += 1;
log::info!("Imported OpenClaw provider '{id}' from live config");
}
Ok(imported)
}
/// Remove an OpenClaw provider from live config
///
/// This removes a specific provider from ~/.openclaw/openclaw.json
/// without affecting other providers in the file.
pub fn remove_openclaw_provider_from_live(provider_id: &str) -> Result<(), AppError> {
use crate::openclaw_config;
// Check if OpenClaw config directory exists
if !openclaw_config::get_openclaw_dir().exists() {
log::debug!("OpenClaw config directory doesn't exist, skipping removal of '{provider_id}'");
return Ok(());
}
openclaw_config::remove_provider(provider_id)?;
log::info!("OpenClaw provider '{provider_id}' removed from live config");
Ok(())
}
+275 -276
View File
@@ -21,31 +21,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;
// 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 +75,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 +142,10 @@ impl ProviderService {
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
///
/// 对于累加模式应用(OpenCode, OpenClaw),不存在"当前供应商"概念,直接返回空字符串。
/// 对于 OpenCode(累加模式),不存在"当前供应商"概念,直接返回空字符串。
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
// Additive mode apps have no "current" provider concept
if app_type.is_additive_mode() {
// OpenCode uses additive mode - no "current" provider concept
if matches!(app_type, AppType::OpenCode) {
return Ok(String::new());
}
crate::settings::get_effective_current_provider(&state.db, &app_type)
@@ -131,15 +162,8 @@ 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() {
// OMO providers use exclusive mode and write to dedicated config file.
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo")
{
// Do not auto-enable newly added OMO providers.
// Users must explicitly switch/apply an OMO provider to activate it.
return Ok(true);
}
// OpenCode uses additive mode - always write to live config
if matches!(app_type, AppType::OpenCode) {
write_live_snapshot(&app_type, &provider)?;
return Ok(true);
}
@@ -151,7 +175,7 @@ impl ProviderService {
state
.db
.set_current_provider(app_type.as_str(), &provider.id)?;
write_live_partial(&app_type, &provider)?;
write_live_snapshot(&app_type, &provider)?;
}
Ok(true)
@@ -171,38 +195,8 @@ 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")?;
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,
)?;
}
return Ok(true);
}
// OpenCode uses additive mode - always update in live config
if matches!(app_type, AppType::OpenCode) {
write_live_snapshot(&app_type, &provider)?;
return Ok(true);
}
@@ -232,7 +226,7 @@ impl ProviderService {
)
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
} else {
write_live_partial(&app_type, &provider)?;
write_live_snapshot(&app_type, &provider)?;
// Sync MCP
McpService::sync_all_enabled(state)?;
}
@@ -244,78 +238,14 @@ 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
.db
.get_provider_by_id(id, app_type.as_str())?
.and_then(|p| p.category);
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 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(());
}
}
// OpenCode uses additive mode - no current provider concept
if matches!(app_type, AppType::OpenCode) {
// 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,66 +262,17 @@ 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
/// but keep it available for future use.
pub fn remove_from_live_config(
state: &AppState,
app_type: AppType,
id: &str,
) -> Result<(), AppError> {
pub fn remove_from_live_config(app_type: AppType, id: &str) -> Result<(), AppError> {
match app_type {
AppType::OpenCode => {
let provider_category = state
.db
.get_provider_by_id(id, app_type.as_str())?
.and_then(|p| p.category);
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 still_has_current {
crate::services::OmoService::write_config_to_file(
state,
&crate::services::omo::STANDARD,
)?;
} 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,
)?;
}
} else {
remove_opencode_provider_from_live(id)?;
}
}
AppType::OpenClaw => {
remove_openclaw_provider_from_live(id)?;
remove_opencode_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,25 +295,13 @@ 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
.get(id)
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
// OMO providers are switched through their own exclusive path.
if matches!(app_type, AppType::OpenCode) && _provider.category.as_deref() == Some("omo") {
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 +355,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 +368,34 @@ 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());
}
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(&current_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(), &current_provider)
{
log::warn!("Backfill failed: {e}");
result
.warnings
.push(format!("backfill_failed:{current_id}"));
}
current_provider.settings_config = live_config;
// Ignore backfill failure, don't affect switch flow
let _ = state.db.save_provider(app_type.as_str(), &current_provider);
}
}
}
}
}
// Additive mode apps skip setting is_current (no such concept)
if !app_type.is_additive_mode() {
// OpenCode uses additive mode - skip setting is_current (no such concept)
if !matches!(app_type, AppType::OpenCode) {
// Update local settings (device-level, takes priority)
crate::settings::set_current_provider(&app_type, Some(id))?;
@@ -564,10 +403,13 @@ 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 (write_gemini_live handles security flag internally for Gemini)
write_live_snapshot(&app_type, provider)?;
Ok(result)
// Sync MCP
McpService::sync_all_enabled(state)?;
Ok(())
}
/// Sync current provider to live configuration (re-export)
@@ -575,6 +417,199 @@ 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(&current_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 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 (JSON format)
///
/// Extracts `.env` values while excluding provider-specific credentials:
/// - GOOGLE_GEMINI_BASE_URL
/// - GEMINI_API_KEY
fn extract_gemini_common_config(settings: &Value) -> Result<String, AppError> {
let env = settings.get("env").and_then(|v| v.as_object());
let mut snippet = serde_json::Map::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() {
snippet.insert(key.to_string(), Value::String(trimmed.to_string()));
}
}
}
if snippet.is_empty() {
return Ok("{}".to_string());
}
serde_json::to_string_pretty(&Value::Object(snippet))
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
}
/// 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 +622,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 +717,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 +787,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 +958,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))
}
}
}
}

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