Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d01b9cd79 |
@@ -56,8 +56,7 @@ jobs:
|
||||
libssl-dev \
|
||||
rpm \
|
||||
flatpak \
|
||||
flatpak-builder \
|
||||
elfutils
|
||||
flatpak-builder
|
||||
# GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libgtk-3-dev \
|
||||
@@ -157,7 +156,7 @@ jobs:
|
||||
|
||||
- name: Build Tauri App (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: pnpm tauri build --bundles appimage,deb,rpm
|
||||
run: pnpm tauri build -- --bundles appimage,deb,rpm
|
||||
|
||||
- name: Prepare macOS Assets
|
||||
if: runner.os == 'macOS'
|
||||
@@ -284,6 +283,19 @@ jobs:
|
||||
else
|
||||
echo "No .rpm found (optional)"
|
||||
fi
|
||||
# 额外上传 .flatpak(跨发行版;不参与 Updater)
|
||||
if [ -n "$DEB" ]; then
|
||||
echo "Building Flatpak bundle from .deb..."
|
||||
cp "$DEB" flatpak/cc-switch.deb
|
||||
flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install -y --user flathub org.gnome.Platform//46 org.gnome.Sdk//46
|
||||
flatpak-builder --force-clean --user --disable-cache --repo flatpak-repo flatpak-build flatpak/com.ccswitch.desktop.yml
|
||||
NEW_FLATPAK="CC-Switch-${VERSION}-Linux.flatpak"
|
||||
flatpak build-bundle --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo flatpak-repo "release-assets/$NEW_FLATPAK" com.ccswitch.desktop
|
||||
echo "Flatpak bundle created: $NEW_FLATPAK"
|
||||
else
|
||||
echo "Skip Flatpak build: no .deb found"
|
||||
fi
|
||||
|
||||
- name: List prepared assets
|
||||
shell: bash
|
||||
@@ -312,7 +324,7 @@ jobs:
|
||||
|
||||
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`(Homebrew)
|
||||
- **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
|
||||
- **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`(AppImage)或 `CC-Switch-${{ github.ref_name }}-Linux.deb`(Debian/Ubuntu)或 `CC-Switch-${{ github.ref_name }}-Linux.rpm`(Fedora/RHEL/openSUSE)
|
||||
- **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`(AppImage)或 `CC-Switch-${{ github.ref_name }}-Linux.deb`(Debian/Ubuntu)或 `CC-Switch-${{ github.ref_name }}-Linux.rpm`(Fedora/RHEL/openSUSE)或 `CC-Switch-${{ github.ref_name }}-Linux.flatpak`(Flatpak)
|
||||
|
||||
---
|
||||
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
|
||||
|
||||
@@ -5,91 +5,6 @@ All notable changes to CC Switch will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [3.9.1] - 2026-01-09
|
||||
|
||||
### Bug Fix Release
|
||||
|
||||
This release focuses on stability improvements and crash prevention.
|
||||
|
||||
### Added
|
||||
|
||||
- **Crash Logging** - Panic hook captures crash info to `~/.cc-switch/crash.log` with full stack traces (#562)
|
||||
- **Release Logging** - Enable logging for release builds with automatic rotation (keeps 2 most recent files)
|
||||
- **AIGoCode Icon** - Added colored icon for AIGoCode provider preset
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Proxy Panic Prevention** - Graceful degradation when HTTP client initialization fails due to invalid proxy settings; falls back to no_proxy mode (#560)
|
||||
- **UTF-8 Safety** - Fix potential panic when masking API keys or truncating logs containing multi-byte characters (Chinese, emoji, etc.) (#560)
|
||||
- **Default Proxy Port** - Change default port from 5000 to 15721 to avoid conflict with macOS AirPlay Receiver (#560)
|
||||
- **Windows Title** - Display "CC Switch" instead of default "Tauri app" in window title
|
||||
- **Windows/Linux Spacing** - Remove extra 28px blank space below native titlebar introduced in v3.9.0
|
||||
- **Flatpak Tray Icon** - Bundle libayatana-appindicator for tray icon support on Flatpak (#556)
|
||||
- **Provider Preset** - Correct casing from "AiGoCode" to "AIGoCode" to match official branding
|
||||
|
||||
---
|
||||
|
||||
## [3.9.0] - 2026-01-07
|
||||
|
||||
### Stable Release
|
||||
|
||||
This stable release includes all changes from `3.9.0-1`, `3.9.0-2`, and `3.9.0-3`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Local API Proxy** - High-performance local HTTP proxy for Claude Code, Codex, and Gemini CLI (Axum-based)
|
||||
- **Per-App Takeover** - Independently route each app through the proxy with automatic live-config backup/redirect
|
||||
- **Auto Failover** - Circuit breaker + smart failover with independent queues and health tracking per app
|
||||
- **Universal Provider** - Shared provider configurations that can sync to Claude/Codex/Gemini (ideal for API gateways like NewAPI)
|
||||
- **Provider Search Filter** - Quick filter to find providers by name (#435)
|
||||
- **Keyboard Shortcut** - Open settings with Command+comma / Ctrl+comma (#436)
|
||||
- **Deeplink Usage Config** - Import usage query config via deeplink (#400)
|
||||
- **Provider Icon Colors** - Customize provider icon colors (#385)
|
||||
- **Skills Multi-App Support** - Skills now support both Claude Code and Codex (#365)
|
||||
- **Closable Toasts** - Close button for switch toast and all success toasts (#350)
|
||||
- **Skip First-Run Confirmation** - Option to skip Claude Code first-run confirmation dialog
|
||||
- **MCP Import** - Import MCP servers from installed apps
|
||||
- **Common Config Snippet Extraction** - Extract reusable common config snippets from the current provider or editor content (Claude/Codex/Gemini)
|
||||
- **Usage Enhancements** - Model extraction, request logging improvements, cache hit/creation metrics, and auto-refresh (#455, #508)
|
||||
- **Error Request Logging** - Detailed logging for proxy requests (#401)
|
||||
- **Linux Packaging** - Added RPM and Flatpak packaging targets
|
||||
- **Provider Presets & Icons** - Added/updated partner presets and icons (e.g., MiMo, DMXAPI, Cubence)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Usage Terminology** - Rename "Cache Read/Write" to "Cache Hit/Creation" across all languages (#508)
|
||||
- **Model Pricing Data** - Refresh built-in model pricing table (Claude full version IDs, GPT-5 series, Gemini ID formats, and Chinese models) (#508)
|
||||
- **Proxy Header Forwarding** - Switch to a blacklist approach and improve header passthrough compatibility (#508)
|
||||
- **Failover Behavior** - Bypass timeout/retry configs when failover is disabled; update default failover timeout and circuit breaker values (#508, #521)
|
||||
- **Provider Presets** - Update default model versions and change the default Qwen base URL (#517)
|
||||
- **Skills Management** - Unify Skills management architecture with SSOT + React Query; improve caching for discoverable skills
|
||||
- **Settings UX** - Reorder items in the Advanced tab for better discoverability
|
||||
- **Proxy Active Theme** - Apply emerald theme when proxy takeover is active
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Security** - Security fixes for JavaScript executor and usage script (#151)
|
||||
- **Usage Timezone & Parsing** - Fix datetime picker timezone handling; improve token parsing/billing for Gemini and Codex formats (#508)
|
||||
- **Windows Compatibility** - Improve MCP export and version check behavior to avoid terminal popups
|
||||
- **Windows Startup** - Use system titlebar to prevent black screen on startup
|
||||
- **WebView Compatibility** - Add fallback for crypto.randomUUID() on older WebViews
|
||||
- **macOS Autostart** - Use `.app` bundle path to prevent terminal window popups
|
||||
- **Database** - Add missing schema migrations; show an error dialog on initialization failure with a retry option
|
||||
- **Import/Export** - Restrict SQL import to CC Switch exported backups only; refresh providers immediately after import
|
||||
- **Prompts** - Allow saving prompts with empty content
|
||||
- **MCP Sync** - Skip sync when the target CLI app is not installed
|
||||
- **Common Config (Codex)** - Preserve MCP server `base_url` during extraction and remove provider-specific `model_providers` blocks
|
||||
- **Proxy** - Improve takeover detection and stability; clean up model override env vars when switching providers in takeover mode (#508)
|
||||
- **Skills** - Skip hidden directories during discovery; fix wrong skill repo branch
|
||||
- **Settings Navigation** - Navigate to About tab when clicking update badge
|
||||
- **UI** - Fix dialogs not opening on first click and improve window dragging area in `FullScreenPanel`
|
||||
|
||||
---
|
||||
|
||||
## [3.9.0-3] - 2025-12-29
|
||||
|
||||
### Beta Release
|
||||
@@ -148,34 +63,6 @@ Third beta release with important bug fixes for Windows compatibility, UI improv
|
||||
|
||||
---
|
||||
|
||||
## [3.9.0-2] - 2025-12-20
|
||||
|
||||
### Beta Release
|
||||
|
||||
Second beta release focusing on proxy stability, import safety, and provider preset polish.
|
||||
|
||||
### Added
|
||||
|
||||
- **DMXAPI Partner** - Added DMXAPI as an official partner provider preset
|
||||
- **Provider Icons** - Added provider icons for OpenRouter, LongCat, ModelScope, and AiHubMix
|
||||
|
||||
### Changed
|
||||
|
||||
- **Proxy (OpenRouter)** - Switched OpenRouter to passthrough mode for native Claude API
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Import/Export** - Restrict SQL import to CC Switch exported backups only; refresh providers immediately after import
|
||||
- **Proxy** - Respect existing Claude token when syncing; add fallback recovery for orphaned takeover state; remove global auto-start flag
|
||||
- **Windows** - Add minimum window size to Windows platform config
|
||||
- **UI** - Improve About section UI (#419) and unify header toolbar styling
|
||||
|
||||
### Stats
|
||||
|
||||
- 13 commits since v3.9.0-1
|
||||
|
||||
---
|
||||
|
||||
## [3.9.0-1] - 2025-12-18
|
||||
|
||||
### Beta Release
|
||||
@@ -668,8 +555,8 @@ v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI
|
||||
|
||||
### ⚠ Breaking Changes
|
||||
|
||||
- Tauri commands only accept the `app` parameter (`claude`/`codex`); removed `app_type`/`appType` compatibility.
|
||||
- Frontend types are standardized to `AppId` (removed `AppType` export); variable naming is standardized to `appId`.
|
||||
- Tauri 命令仅接受参数 `app`(取值:`claude`/`codex`);移除对 `app_type`/`appType` 的兼容。
|
||||
- 前端类型命名统一为 `AppId`(移除 `AppType` 导出),变量命名统一为 `appId`。
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
@@ -912,3 +799,40 @@ For users upgrading from v2.x (Electron version):
|
||||
- Basic provider management
|
||||
- Claude Code integration
|
||||
- Configuration file handling
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### ⚠️ Breaking Changes
|
||||
|
||||
- **Runtime auto-migration from v1 to v2 config format has been removed**
|
||||
- `MultiAppConfig::load()` no longer automatically migrates v1 configs
|
||||
- When a v1 config is detected, the app now returns a clear error with migration instructions
|
||||
- **Migration path**: Install v3.2.x to perform one-time auto-migration, OR manually edit `~/.cc-switch/config.json` to v2 format
|
||||
- **Rationale**: Separates concerns (load() should be read-only), fail-fast principle, simplifies maintenance
|
||||
- Related: `app_config.rs` (v1 detection improved with structural analysis), `app_config_load.rs` (comprehensive test coverage added)
|
||||
|
||||
- **Legacy v1 copy file migration logic has been removed**
|
||||
- Removed entire `migration.rs` module (435 lines) that handled one-time migration from v3.1.0 to v3.2.0
|
||||
- No longer scans/merges legacy copy files (`settings-*.json`, `auth-*.json`, `config-*.toml`)
|
||||
- No longer archives copy files or performs automatic deduplication
|
||||
- **Migration path**: Users upgrading from v3.1.0 must first upgrade to v3.2.x to automatically migrate their configurations
|
||||
- **Benefits**: Improved startup performance (no file scanning), reduced code complexity, cleaner codebase
|
||||
|
||||
- **Tauri commands now only accept `app` parameter**
|
||||
- Removed legacy `app_type`/`appType` compatibility paths
|
||||
- Explicit error with available values when unknown `app` is provided
|
||||
|
||||
### 🔧 Improvements
|
||||
|
||||
- Unified `AppType` parsing: centralized to `FromStr` implementation, command layer no longer implements separate `parse_app()`, reducing code duplication and drift
|
||||
- Localized and user-friendly error messages: returns bilingual (Chinese/English) hints for unsupported `app` values with a list of available options
|
||||
- Simplified startup logic: Only ensures config structure exists, no migration overhead
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- Added unit tests covering `AppType::from_str`: case sensitivity, whitespace trimming, unknown value error messages
|
||||
- Added comprehensive config loading tests:
|
||||
- `load_v1_config_returns_error_and_does_not_write`
|
||||
- `load_v1_with_extra_version_still_treated_as_v1`
|
||||
- `load_invalid_json_returns_parse_error_and_does_not_write`
|
||||
- `load_valid_v2_config_succeeds`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
@@ -37,11 +37,6 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
|
||||
<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://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
|
||||
<td>Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more with flexible billing options including pay-as-you-go and monthly plans. Cubence provides special discounts for CC Switch users: register using <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">this link</a> and enter the "CCSWITCH" promo code during recharge to get 10% off every top-up!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## Screenshots
|
||||
@@ -52,7 +47,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
|
||||
|
||||
## Features
|
||||
|
||||
### Current Version: v3.9.1 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
|
||||
### Current Version: v3.8.3 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
|
||||
|
||||
**v3.8.0 Major Update (2025-11-28)**
|
||||
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
[English](README.md) | [中文](README_ZH.md) | 日本語 | [Changelog](CHANGELOG.md) | [v3.9.0 リリースノート](docs/release-note-v3.9.0-ja.md)
|
||||
[English](README.md) | [中文](README_ZH.md) | 日本語 | [Changelog](CHANGELOG.md) | [v3.8.0 リリースノート](docs/release-note-v3.8.0-en.md)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -37,11 +37,6 @@
|
||||
<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://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
|
||||
<td>Cubence のご支援に感謝します!Cubence は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームで、従量課金や月額プランなど柔軟な料金体系を提供しています。CC Switch ユーザー向けの特別割引:<a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">このリンク</a>で登録し、チャージ時に「CCSWITCH」クーポンを入力すると、毎回 10% オフになります!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## スクリーンショット
|
||||
@@ -52,7 +47,7 @@
|
||||
|
||||
## 特長
|
||||
|
||||
### 現在のバージョン:v3.9.1 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md)
|
||||
### 現在のバージョン:v3.8.3 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md)
|
||||
|
||||
**v3.8.0 メジャーアップデート (2025-11-28)**
|
||||
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
# Claude Code / Codex / Gemini CLI 全方位辅助工具
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
[English](README.md) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md) | [v3.9.0 发布说明](docs/release-note-v3.9.0-zh.md)
|
||||
[English](README.md) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md) | [v3.8.0 发布说明](docs/release-note-v3.8.0-zh.md)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<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>
|
||||
@@ -35,13 +35,8 @@
|
||||
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-zh.jpeg" alt="DMXAPI" width="150"></a></td>
|
||||
<td>感谢 DMXAPI(大模型API)赞助了本项目! DMXAPI,一个Key用全球大模型。
|
||||
为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://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
|
||||
<td>感谢 Cubence 赞助本项目!Cubence 是一家可靠高效的 API 中继服务提供商,提供对 Claude Code、Codex、Gemini 等模型的中继服务,并提供按量、包月等灵活的计费方式。Cubence 为 CC Switch 的用户提供了特别优惠:使用 <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">此链接</a> 注册,并在充值时输入 "CCSWITCH" 优惠码,每次充值均可享受九折优惠!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 界面预览
|
||||
@@ -52,7 +47,7 @@
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 当前版本:v3.9.1 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md)
|
||||
### 当前版本:v3.8.3 | [完整更新日志](CHANGELOG.md)
|
||||
|
||||
**v3.8.0 重大更新(2025-11-28)**
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 51 KiB |
@@ -1,485 +0,0 @@
|
||||
# OpenCode 第四应用支持实现计划
|
||||
|
||||
> **范围说明**:本计划暂不包含统一供应商(UniversalProvider)对 OpenCode 的支持,以降低初期实现复杂度。
|
||||
|
||||
## 概述
|
||||
|
||||
为 CC Switch 添加 OpenCode 支持,这是第四个受管理的 CLI 应用。OpenCode 的核心差异在于采用**累加式**供应商管理(多供应商共存,应用内热切换),而非现有三应用的**替换式**管理。
|
||||
|
||||
## 关键设计决策
|
||||
|
||||
| 特性 | Claude/Codex/Gemini | OpenCode |
|
||||
|------|---------------------|----------|
|
||||
| 供应商模式 | 替换式(单一活跃) | 累加式(多供应商共存) |
|
||||
| UI 按钮 | 启用/切换 | 添加/删除 |
|
||||
| is_current | 需要 | 不需要 |
|
||||
| 代理/故障转移 | 支持 | 不支持 |
|
||||
| API 格式字段 | 无 | 需要(npm 包名) |
|
||||
| 配置文件 | 各自独立 | `~/.config/opencode/opencode.json` |
|
||||
|
||||
## 配置文件格式
|
||||
|
||||
### 供应商配置
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"provider-id": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Provider Name",
|
||||
"options": {
|
||||
"baseURL": "https://api.example.com/v1",
|
||||
"apiKey": "{env:API_KEY}"
|
||||
},
|
||||
"models": {
|
||||
"model-id": { "name": "Model Name" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MCP 配置
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"remote-server": {
|
||||
"type": "remote",
|
||||
"url": "https://example.com/mcp",
|
||||
"enabled": true
|
||||
},
|
||||
"local-server": {
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "my-mcp-command"],
|
||||
"enabled": true,
|
||||
"environment": { "KEY": "value" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实现步骤
|
||||
|
||||
### Phase 1: 后端数据结构扩展
|
||||
|
||||
#### 1.1 AppType 枚举扩展
|
||||
**文件**: `src-tauri/src/app_config.rs`
|
||||
|
||||
```rust
|
||||
pub enum AppType {
|
||||
Claude,
|
||||
Codex,
|
||||
Gemini,
|
||||
OpenCode, // 新增
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 McpApps / SkillApps 扩展
|
||||
**文件**: `src-tauri/src/app_config.rs`
|
||||
|
||||
```rust
|
||||
pub struct McpApps {
|
||||
pub claude: bool,
|
||||
pub codex: bool,
|
||||
pub gemini: bool,
|
||||
pub opencode: bool, // 新增
|
||||
}
|
||||
|
||||
pub struct SkillApps {
|
||||
pub claude: bool,
|
||||
pub codex: bool,
|
||||
pub gemini: bool,
|
||||
pub opencode: bool, // 新增
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 数据库 Schema 迁移
|
||||
**文件**: `src-tauri/src/database/schema.rs`
|
||||
|
||||
- `SCHEMA_VERSION` 递增
|
||||
- 添加迁移:
|
||||
```sql
|
||||
ALTER TABLE mcp_servers ADD COLUMN enabled_opencode BOOLEAN NOT NULL DEFAULT 0;
|
||||
ALTER TABLE skills ADD COLUMN enabled_opencode BOOLEAN NOT NULL DEFAULT 0;
|
||||
```
|
||||
|
||||
### Phase 2: OpenCode 供应商数据结构
|
||||
|
||||
#### 2.1 OpenCode 专属配置结构
|
||||
**文件**: `src-tauri/src/provider.rs`(或新建 `opencode_provider.rs`)
|
||||
|
||||
```rust
|
||||
/// OpenCode 供应商的 settings_config 结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenCodeProviderConfig {
|
||||
/// AI SDK 包名,如 "@ai-sdk/openai-compatible"
|
||||
pub npm: String,
|
||||
/// 供应商选项
|
||||
pub options: OpenCodeProviderOptions,
|
||||
/// 模型定义
|
||||
pub models: HashMap<String, OpenCodeModel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenCodeProviderOptions {
|
||||
#[serde(rename = "baseURL", skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
#[serde(rename = "apiKey", skip_serializing_if = "Option::is_none")]
|
||||
pub api_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub headers: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenCodeModel {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<OpenCodeModelLimit>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenCodeModelLimit {
|
||||
pub context: Option<u64>,
|
||||
pub output: Option<u64>,
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: OpenCode Live 配置读写
|
||||
|
||||
#### 3.1 新建 OpenCode 配置模块
|
||||
**文件**: `src-tauri/src/opencode_config.rs`
|
||||
|
||||
核心功能:
|
||||
- `get_opencode_config_path()` → `~/.config/opencode/opencode.json`
|
||||
- `read_opencode_config()` → 读取整个配置文件
|
||||
- `write_opencode_config()` → 原子写入配置文件
|
||||
- `get_providers()` → 获取 `provider` 对象
|
||||
- `set_provider(id, config)` → 添加/更新供应商
|
||||
- `remove_provider(id)` → 删除供应商
|
||||
- `get_mcp_servers()` → 获取 `mcp` 对象
|
||||
- `set_mcp_server(id, config)` → 添加/更新 MCP 服务器
|
||||
- `remove_mcp_server(id)` → 删除 MCP 服务器
|
||||
|
||||
### Phase 4: MCP 同步模块
|
||||
|
||||
#### 4.1 新建 OpenCode MCP 同步
|
||||
**文件**: `src-tauri/src/mcp/opencode.rs`
|
||||
|
||||
```rust
|
||||
/// 同步所有 enabled_opencode=true 的服务器到 OpenCode 配置
|
||||
pub fn sync_enabled_to_opencode(config: &MultiAppConfig) -> Result<(), AppError>
|
||||
|
||||
/// 同步单个服务器
|
||||
pub fn sync_single_server_to_opencode(
|
||||
config: &MultiAppConfig,
|
||||
id: &str,
|
||||
server_spec: &Value
|
||||
) -> Result<(), AppError>
|
||||
|
||||
/// 从 OpenCode 配置移除服务器
|
||||
pub fn remove_server_from_opencode(id: &str) -> Result<(), AppError>
|
||||
|
||||
/// 从 OpenCode 配置导入服务器
|
||||
pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppError>
|
||||
```
|
||||
|
||||
**格式转换**:
|
||||
| CC Switch 统一格式 | OpenCode 格式 |
|
||||
|-------------------|---------------|
|
||||
| `type: "stdio"` | `type: "local"` |
|
||||
| `command` + `args` | `command: [cmd, ...args]` |
|
||||
| `env` | `environment` |
|
||||
| `type: "sse"/"http"` | `type: "remote"` |
|
||||
| `url` | `url` |
|
||||
|
||||
### Phase 5: 供应商服务层
|
||||
|
||||
#### 5.1 OpenCode 供应商服务
|
||||
**文件**: `src-tauri/src/services/provider/opencode.rs`
|
||||
|
||||
核心方法:
|
||||
```rust
|
||||
/// 获取所有 OpenCode 供应商
|
||||
pub fn list(state: &AppState) -> Result<IndexMap<String, Provider>, AppError>
|
||||
|
||||
/// 添加供应商(同时写入 live 配置)
|
||||
pub fn add(state: &AppState, provider: Provider) -> Result<bool, AppError>
|
||||
|
||||
/// 更新供应商
|
||||
pub fn update(state: &AppState, provider: Provider) -> Result<bool, AppError>
|
||||
|
||||
/// 删除供应商(同时从 live 配置移除)
|
||||
pub fn delete(state: &AppState, id: &str) -> Result<(), AppError>
|
||||
|
||||
/// 从 live 配置导入供应商到数据库
|
||||
pub fn import_from_live(state: &AppState) -> Result<usize, AppError>
|
||||
```
|
||||
|
||||
**关键差异**:
|
||||
- 不需要 `switch()` 方法
|
||||
- 不需要 `is_current` 管理
|
||||
- `add()` 自动写入 live
|
||||
- `delete()` 自动从 live 移除
|
||||
|
||||
### Phase 6: Tauri 命令扩展
|
||||
|
||||
#### 6.1 更新现有命令
|
||||
**文件**: `src-tauri/src/commands/providers.rs`
|
||||
|
||||
- 所有命令支持 `app_type = "opencode"`
|
||||
- OpenCode 特定逻辑分支
|
||||
|
||||
#### 6.2 新增 OpenCode 专属命令(如需要)
|
||||
```rust
|
||||
#[tauri::command]
|
||||
pub async fn opencode_sync_all_providers(state: State<'_, AppState>) -> Result<(), AppError>
|
||||
```
|
||||
|
||||
### Phase 7: 前端类型定义
|
||||
|
||||
#### 7.1 TypeScript 类型扩展
|
||||
**文件**: `src/types.ts`
|
||||
|
||||
```typescript
|
||||
// AppId 扩展
|
||||
type AppId = "claude" | "codex" | "gemini" | "opencode";
|
||||
|
||||
// OpenCode 专属配置
|
||||
interface OpenCodeProviderConfig {
|
||||
npm: string; // AI SDK 包名
|
||||
options: {
|
||||
baseURL?: string;
|
||||
apiKey?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
models: Record<string, OpenCodeModel>;
|
||||
}
|
||||
|
||||
interface OpenCodeModel {
|
||||
name: string;
|
||||
limit?: {
|
||||
context?: number;
|
||||
output?: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### 7.2 MCP 应用状态扩展
|
||||
**文件**: `src/types.ts`
|
||||
|
||||
```typescript
|
||||
interface McpApps {
|
||||
claude: boolean;
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
opencode: boolean; // 新增
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 8: 前端预设配置
|
||||
|
||||
#### 8.1 新建 OpenCode 供应商预设
|
||||
**文件**: `src/config/opencodeProviderPresets.ts`
|
||||
|
||||
```typescript
|
||||
export const opencodeProviderPresets: ProviderPreset[] = [
|
||||
{
|
||||
name: "OpenAI",
|
||||
npmPackage: "@ai-sdk/openai",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai",
|
||||
options: { apiKey: "{env:OPENAI_API_KEY}" },
|
||||
models: {
|
||||
"gpt-4o": { name: "GPT-4o" },
|
||||
"gpt-4o-mini": { name: "GPT-4o Mini" },
|
||||
},
|
||||
},
|
||||
theme: { icon: "openai", iconColor: "#00A67E" },
|
||||
},
|
||||
{
|
||||
name: "Anthropic",
|
||||
npmPackage: "@ai-sdk/anthropic",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
options: { apiKey: "{env:ANTHROPIC_API_KEY}" },
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": { name: "Claude Sonnet 4" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OpenAI Compatible",
|
||||
npmPackage: "@ai-sdk/openai-compatible",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
baseURL: "",
|
||||
apiKey: "{env:API_KEY}",
|
||||
},
|
||||
models: {},
|
||||
},
|
||||
isCustomTemplate: true,
|
||||
},
|
||||
// ... 更多预设
|
||||
];
|
||||
|
||||
// npm 包选项
|
||||
export const opencodeNpmPackages = [
|
||||
{ value: "@ai-sdk/openai", label: "OpenAI" },
|
||||
{ value: "@ai-sdk/anthropic", label: "Anthropic" },
|
||||
{ value: "@ai-sdk/openai-compatible", label: "OpenAI Compatible" },
|
||||
{ value: "@ai-sdk/google", label: "Google" },
|
||||
{ value: "@ai-sdk/azure", label: "Azure OpenAI" },
|
||||
{ value: "@ai-sdk/amazon-bedrock", label: "Amazon Bedrock" },
|
||||
// ... 更多选项
|
||||
];
|
||||
```
|
||||
|
||||
### Phase 9: 前端 UI 组件
|
||||
|
||||
#### 9.1 OpenCode 供应商表单
|
||||
**文件**: `src/components/providers/forms/OpenCodeFormFields.tsx`
|
||||
|
||||
新增字段:
|
||||
- npm 包选择器(下拉框 + 自定义输入)
|
||||
- options 编辑器(baseURL, apiKey, headers)
|
||||
- models 编辑器(动态添加/删除模型)
|
||||
|
||||
#### 9.2 供应商卡片按钮适配
|
||||
**文件**: `src/components/providers/ProviderActions.tsx`
|
||||
|
||||
```tsx
|
||||
// OpenCode 使用不同的主按钮
|
||||
if (appId === "opencode") {
|
||||
return (
|
||||
<Button onClick={onAdd}>
|
||||
{isInConfig ? t("provider.removeFromConfig") : t("provider.addToConfig")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 9.3 隐藏 OpenCode 不需要的功能
|
||||
|
||||
在以下组件中检查 `appId !== "opencode"`:
|
||||
- 代理设置面板
|
||||
- 故障转移队列
|
||||
- 供应商切换逻辑
|
||||
|
||||
### Phase 10: 国际化
|
||||
|
||||
#### 10.1 新增翻译 Key
|
||||
**文件**: `src/locales/zh/translation.json` & `en/translation.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"app.opencode": "OpenCode",
|
||||
"provider.addToConfig": "添加到配置",
|
||||
"provider.removeFromConfig": "从配置移除",
|
||||
"provider.inConfig": "已添加",
|
||||
"provider.npmPackage": "AI SDK 包",
|
||||
"provider.models": "模型配置",
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键文件清单
|
||||
|
||||
### 后端(Rust)
|
||||
| 操作 | 文件路径 |
|
||||
|------|---------|
|
||||
| 修改 | `src-tauri/src/app_config.rs` |
|
||||
| 修改 | `src-tauri/src/database/schema.rs` |
|
||||
| 修改 | `src-tauri/src/database/dao/mcp.rs` |
|
||||
| 修改 | `src-tauri/src/database/dao/providers.rs` |
|
||||
| 修改 | `src-tauri/src/services/provider/mod.rs` |
|
||||
| 修改 | `src-tauri/src/services/mcp.rs` |
|
||||
| 修改 | `src-tauri/src/commands/providers.rs` |
|
||||
| 修改 | `src-tauri/src/commands/mcp.rs` |
|
||||
| 修改 | `src-tauri/src/mcp/mod.rs` |
|
||||
| 新建 | `src-tauri/src/opencode_config.rs` |
|
||||
| 新建 | `src-tauri/src/mcp/opencode.rs` |
|
||||
| 新建 | `src-tauri/src/services/provider/opencode.rs` |
|
||||
|
||||
### 前端(TypeScript/React)
|
||||
| 操作 | 文件路径 |
|
||||
|------|---------|
|
||||
| 修改 | `src/types.ts` |
|
||||
| 修改 | `src/lib/api/types.ts` |
|
||||
| 修改 | `src/lib/api/providers.ts` |
|
||||
| 修改 | `src/components/providers/ProviderActions.tsx` |
|
||||
| 修改 | `src/components/providers/ProviderCard.tsx` |
|
||||
| 修改 | `src/components/providers/AddProviderDialog.tsx` |
|
||||
| 修改 | `src/components/providers/forms/ProviderForm.tsx` |
|
||||
| 修改 | `src/App.tsx` |
|
||||
| 新建 | `src/config/opencodeProviderPresets.ts` |
|
||||
| 新建 | `src/components/providers/forms/OpenCodeFormFields.tsx` |
|
||||
|
||||
### 国际化
|
||||
| 操作 | 文件路径 |
|
||||
|------|---------|
|
||||
| 修改 | `src/locales/zh/translation.json` |
|
||||
| 修改 | `src/locales/en/translation.json` |
|
||||
| 修改 | `src/locales/ja/translation.json` |
|
||||
|
||||
---
|
||||
|
||||
## 验证计划
|
||||
|
||||
### 单元测试
|
||||
1. OpenCode 配置读写测试
|
||||
2. MCP 格式转换测试(stdio ↔ local, sse ↔ remote)
|
||||
3. 供应商 CRUD 操作测试
|
||||
|
||||
### 集成测试
|
||||
1. 添加 OpenCode 供应商 → 验证写入 `~/.config/opencode/opencode.json`
|
||||
2. 删除供应商 → 验证从配置文件移除
|
||||
3. MCP 同步测试 → 验证格式正确转换
|
||||
4. 从 live 配置导入 → 验证正确解析
|
||||
|
||||
### 手动测试
|
||||
1. UI 流程:添加预设 → 编辑 → 删除
|
||||
2. 切换应用 Tab → OpenCode 显示正确的 UI(无代理/故障转移)
|
||||
3. 托盘菜单正确显示 OpenCode 供应商
|
||||
4. 深链接导入 OpenCode 供应商
|
||||
|
||||
---
|
||||
|
||||
## 风险评估
|
||||
|
||||
1. **数据库迁移**:需要在升级时自动执行 `ALTER TABLE` 语句
|
||||
2. **配置文件冲突**:OpenCode 可能有自己的配置,需要合并而非覆盖
|
||||
3. **MCP 格式差异**:`stdio` → `local` 转换需要处理边界情况
|
||||
4. **UI 一致性**:OpenCode 的"添加/删除"模式需要与其他应用的"启用/切换"清晰区分
|
||||
|
||||
---
|
||||
|
||||
## 补充说明
|
||||
|
||||
### 托盘菜单特殊处理
|
||||
|
||||
由于 OpenCode 采用累加式管理,托盘菜单行为需要调整:
|
||||
|
||||
- **现有三应用**:托盘菜单显示 `CheckMenuItem`(单选,切换当前供应商)
|
||||
- **OpenCode**:显示当前所有启用的供应商(普通 MenuItem,无勾选逻辑),点击打开主界面
|
||||
|
||||
**修改文件**:`src-tauri/src/tray.rs`(`TRAY_SECTIONS` 常量)
|
||||
|
||||
### 数据库约束更新
|
||||
|
||||
`proxy_config` 表的 CHECK 约束需要扩展:
|
||||
```sql
|
||||
CHECK (app_type IN ('claude','codex','gemini','opencode'))
|
||||
```
|
||||
|
||||
### Settings 结构体扩展
|
||||
|
||||
**文件**:`src-tauri/src/settings.rs`
|
||||
|
||||
需要添加:
|
||||
- `current_provider_opencode: Option<String>` - 对 OpenCode 可能无意义,但保持结构一致
|
||||
- `opencode_config_dir: Option<String>` - 自定义配置目录
|
||||
@@ -1,188 +0,0 @@
|
||||
# CC Switch v3.9.0
|
||||
|
||||
> Local API Proxy, Auto Failover, Universal Provider, and a more complete multi-app workflow
|
||||
|
||||
**[中文版 →](release-note-v3.9.0-zh.md) | [日本語版 →](release-note-v3.9.0-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
CC Switch v3.9.0 is the stable release of the v3.9 beta series (`3.9.0-1`, `3.9.0-2`, `3.9.0-3`).
|
||||
It introduces a local API proxy with per-app takeover, automatic failover, universal providers, and many stability and UX improvements across Claude Code, Codex, and Gemini CLI.
|
||||
|
||||
**Release Date**: 2026-01-07
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- Local API Proxy for Claude Code / Codex / Gemini CLI
|
||||
- Auto Failover with circuit breaker and per-app failover queues
|
||||
- Universal Provider: one shared config synced across apps (ideal for API gateways like NewAPI)
|
||||
- Skills improvements: multi-app support, unified management with SSOT + React Query
|
||||
- Common config snippets: extract reusable snippets from the editor or the current provider
|
||||
- MCP import: import MCP servers from installed apps
|
||||
- Usage improvements: auto-refresh, cache hit/creation metrics, and timezone fixes
|
||||
- Linux packaging: RPM and Flatpak artifacts
|
||||
|
||||
---
|
||||
|
||||
## Major Features
|
||||
|
||||
### Local API Proxy
|
||||
|
||||
- Runs a local high-performance HTTP proxy server (Axum-based)
|
||||
- Supports Claude Code, Codex, and Gemini CLI with a unified proxy
|
||||
- Per-app takeover: you can independently decide which app routes through the proxy
|
||||
- Live config takeover: backs up and redirects the CLI live config to the local proxy when takeover is enabled
|
||||
- Monitoring: request logging and usage statistics for easier debugging and cost tracking
|
||||
- Error request logging: keep detailed logs for failed proxy requests to simplify debugging (#401, thanks @yovinchen)
|
||||
|
||||
### Auto Failover (Circuit Breaker)
|
||||
|
||||
- Automatically detects provider failures and triggers protection (circuit breaker)
|
||||
- Automatically switches to a backup provider when the current one is unhealthy
|
||||
- Tracks provider health in real time, and keeps independent failover queues per app
|
||||
- When failover is disabled, timeout/retry related settings no longer affect normal request flow
|
||||
|
||||
### Skills Management
|
||||
|
||||
- Multi-app Skills support for Claude Code and Codex, with smoother migration from older skill layouts (#365, #378, thanks @yovinchen)
|
||||
- Unified Skills management architecture (SSOT + React Query) for more consistent state and refresh behavior
|
||||
- Better discovery UX and performance:
|
||||
- Skip hidden directories during discovery
|
||||
- Faster discovery with long-lived caching for discoverable skills
|
||||
- Clear loading indicators and more discoverable header actions (import/refresh)
|
||||
- Fix wrong skill repo branch (#505, thanks @kjasn)
|
||||
|
||||
### Universal Provider
|
||||
|
||||
- Add a shared provider configuration that can sync to Claude/Codex/Gemini (#348, thanks @Calcium-Ion)
|
||||
- Designed for API gateways that support multiple protocols (e.g., NewAPI)
|
||||
- Allows per-app default model mapping under a single provider
|
||||
|
||||
### Common Config Snippets (Claude/Codex/Gemini)
|
||||
|
||||
- Maintain a reusable "common config" snippet and merge/append it into providers that enable it
|
||||
- New extraction workflow:
|
||||
- Extract from the editor content (what you are currently editing)
|
||||
- Or extract from the current active provider when the editor content is not provided
|
||||
- Codex extraction is safer:
|
||||
- Removes provider-specific sections like `model_provider`, `model`, and the entire `model_providers` table
|
||||
- Preserves `base_url` under `[mcp_servers.*]` so MCP configs are not accidentally broken
|
||||
|
||||
### MCP Management
|
||||
|
||||
- Import MCP servers from installed apps
|
||||
- Improve robustness: skip sync when the target CLI app is not installed; handle invalid Codex `config.toml` gracefully (#461, thanks @majiayu000)
|
||||
- Windows compatibility: wrap npx/npm commands with `cmd /c` for MCP export
|
||||
|
||||
### Usage & Pricing
|
||||
|
||||
- Usage & pricing improvements: auto-refresh, cache hit/creation metrics, timezone handling fixes, and refreshed built-in pricing table (#508, thanks @yovinchen)
|
||||
- DeepLink support: import usage query configuration via deeplink (#400, thanks @qyinter)
|
||||
- Model extraction for usage statistics (#455, thanks @yovinchen)
|
||||
- Usage query credentials can fall back to provider config (#360, thanks @Sirhexs)
|
||||
|
||||
---
|
||||
|
||||
## UX Improvements
|
||||
|
||||
- Provider search filter: quickly find providers by name (#435, thanks @TinsFox)
|
||||
- Provider icon colors: customize provider icon colors for quicker visual identification (#385, thanks @yovinchen)
|
||||
- Keyboard shortcut: `Cmd/Ctrl + ,` opens Settings (#436, thanks @TinsFox)
|
||||
- Skip Claude Code first-run confirmation dialog (optional)
|
||||
- Closable toasts: close buttons for switch toast and all success toasts (#350, thanks @ForteScarlet)
|
||||
- Update badge navigation: clicking the update badge opens the About tab
|
||||
- Settings page tab style improvements (#342, thanks @wenyuanw)
|
||||
- Smoother transitions: fade transitions for app/view switching and exit animations for panels
|
||||
- Proxy takeover active theme: apply an emerald theme while takeover is active
|
||||
- Dark mode readability improvements for forms and labels
|
||||
- Better window dragging area for full-screen panels (#525, thanks @zerob13)
|
||||
|
||||
---
|
||||
|
||||
## Platform Notes
|
||||
|
||||
### Windows
|
||||
|
||||
- Prevent terminal windows from appearing during version checks
|
||||
- Improve window sizing defaults (minimum width/height)
|
||||
- Fix black screen on startup by using the system titlebar
|
||||
- Add a fallback for `crypto.randomUUID()` on older WebViews
|
||||
|
||||
### macOS
|
||||
|
||||
- Use `.app` bundle path for autostart to avoid terminal window popups (#462, thanks @majiayu000)
|
||||
- Improve tray/icon behavior and header alignment
|
||||
|
||||
---
|
||||
|
||||
## Packaging
|
||||
|
||||
- Linux: RPM and Flatpak packaging targets are now available for building release artifacts
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Security improvements for the JavaScript executor and usage script execution (#151, thanks @luojiyin1987).
|
||||
- SQL import is restricted to CC Switch exported backups to reduce the risk of importing unsafe or incompatible SQL dumps.
|
||||
- Proxy takeover modifies CLI live configs; CC Switch will back up the live config before redirecting it to the local proxy. If you want to revert, disable takeover/stop the proxy and restore from the backup when needed.
|
||||
|
||||
## Special Thanks
|
||||
|
||||
Special thanks to @xunyu @deijing @su-fen for their support and contributions. This release wouldn't be possible without you!
|
||||
|
||||
## Download & Installation
|
||||
|
||||
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
|
||||
|
||||
### System Requirements
|
||||
|
||||
| System | Minimum Version | Architecture |
|
||||
| ------- | ------------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 or later | x64 |
|
||||
| macOS | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | See table below | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| File | Description |
|
||||
| --------------------------------------- | -------------------------------------------------- |
|
||||
| `CC-Switch-v3.9.0-Windows.msi` | **Recommended** - MSI installer with auto-update support |
|
||||
| `CC-Switch-v3.9.0-Windows-Portable.zip` | Portable version, no installation required |
|
||||
|
||||
### macOS
|
||||
|
||||
| File | Description |
|
||||
| ------------------------------- | ----------------------------------------------------------------- |
|
||||
| `CC-Switch-v3.9.0-macOS.zip` | **Recommended** - Extract and drag to Applications, Universal Binary |
|
||||
| `CC-Switch-v3.9.0-macOS.tar.gz` | For Homebrew installation and auto-update |
|
||||
|
||||
> **Note**: Since the author does not have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Close the app, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and it will open normally afterwards.
|
||||
|
||||
### Homebrew (MacOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
Update:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| Distribution | Recommended Format | Installation |
|
||||
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | Make executable and run directly, or use AUR |
|
||||
| Other distros / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
| Sandboxed installation | `.flatpak` | `flatpak install CC-Switch-*.flatpak` |
|
||||
@@ -1,188 +0,0 @@
|
||||
# CC Switch v3.9.0
|
||||
|
||||
> ローカル API プロキシ、自動フェイルオーバー、Universal Provider、多アプリ対応の強化
|
||||
|
||||
**[English →](release-note-v3.9.0-en.md) | [中文版 →](release-note-v3.9.0-zh.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.9.0 は v3.9 ベータ(`3.9.0-1`、`3.9.0-2`、`3.9.0-3`)の安定版です。
|
||||
ローカル API プロキシ(アプリ別テイクオーバー対応)、自動フェイルオーバー、Universal Provider を追加し、Claude Code / Codex / Gemini CLI の安定性と操作性を大きく改善しました。
|
||||
|
||||
**リリース日**:2026-01-07
|
||||
|
||||
---
|
||||
|
||||
## ハイライト
|
||||
|
||||
- ローカル API プロキシ:Claude Code / Codex / Gemini CLI を統一的にプロキシ
|
||||
- 自動フェイルオーバー:サーキットブレーカーとアプリ別のフェイルオーバーキュー
|
||||
- Universal Provider:1つの設定を複数アプリへ同期(NewAPI などのゲートウェイ向け)
|
||||
- Skills の改善:マルチアプリ対応、SSOT + React Query による管理の統一
|
||||
- 共通設定スニペット:エディタ内容または現在のプロバイダから抽出
|
||||
- MCP インポート:インストール済みアプリから MCP servers を取り込み
|
||||
- 使用量の改善:自動更新、キャッシュ指標、タイムゾーン修正
|
||||
- Linux パッケージ:RPM / Flatpak の成果物を追加
|
||||
|
||||
---
|
||||
|
||||
## 主要機能
|
||||
|
||||
### ローカル API プロキシ(Local API Proxy)
|
||||
|
||||
- ローカルで高性能な HTTP プロキシサーバーを起動(Axum ベース)
|
||||
- Claude Code / Codex / Gemini CLI の API リクエストを統一的に扱う
|
||||
- アプリ別テイクオーバー:アプリごとにプロキシ経由にするかを個別に切り替え可能
|
||||
- Live 設定テイクオーバー:有効化時に CLI の live 設定をバックアップし、ローカルプロキシへリダイレクト
|
||||
- 監視:リクエストログと使用量統計でデバッグとコスト把握を支援
|
||||
- エラーリクエストのログ:失敗したプロキシリクエストも詳細に記録してデバッグを容易に(#401、@yovinchen に感謝)
|
||||
|
||||
### 自動フェイルオーバー(Auto Failover / サーキットブレーカー)
|
||||
|
||||
- 障害を検知して保護(サーキットブレーカー)を自動で発動
|
||||
- 現在のプロバイダが不調な場合、バックアッププロバイダへ自動切り替え
|
||||
- アプリごとに独立したフェイルオーバーキューとヘルス状態を管理
|
||||
- フェイルオーバーを無効化している場合、タイムアウト/リトライ関連の設定は通常フローに影響しません
|
||||
|
||||
### Skills 管理
|
||||
|
||||
- Claude Code と Codex の Skills をマルチアプリで利用可能にし、旧レイアウトからの移行もよりスムーズに(#365、#378、@yovinchen に感謝)
|
||||
- SSOT + React Query による Skills 管理の統一で、状態の一貫性と更新挙動を改善
|
||||
- Discovery の体験と性能を改善:
|
||||
- スキャン時に隠しディレクトリをスキップ
|
||||
- Discoverable skills に長寿命キャッシュを適用して高速化
|
||||
- ローディング表示の改善と、インポート/更新などの操作導線を整理
|
||||
- Skills リポジトリのブランチ設定を修正(#505、@kjasn に感謝)
|
||||
|
||||
### Universal Provider
|
||||
|
||||
- 複数アプリで共有できるプロバイダ設定を追加(Claude/Codex/Gemini へ同期)(#348、@Calcium-Ion に感謝)
|
||||
- NewAPI のような複数プロトコル対応の API ゲートウェイを想定
|
||||
- 1つのプロバイダ内でアプリ別にデフォルトモデルを割り当て可能
|
||||
|
||||
### 共通設定スニペット(Claude/Codex/Gemini)
|
||||
|
||||
- 「共通設定スニペット」を保持し、有効化したプロバイダへマージ/追記
|
||||
- 新しい抽出フロー:
|
||||
- エディタの現在内容から抽出(編集している内容)
|
||||
- エディタ内容がない場合は、現在アクティブなプロバイダから抽出
|
||||
- Codex の抽出はより安全:
|
||||
- `model_provider`、`model`、および `model_providers` テーブル全体など、プロバイダ固有の設定を除去
|
||||
- `[mcp_servers.*]` 配下の `base_url` は保持し、MCP 設定を壊しにくくしています
|
||||
|
||||
### MCP 管理
|
||||
|
||||
- インストール済みアプリから MCP servers をインポート
|
||||
- 安定性向上:対象 CLI が未インストールなら同期をスキップし、無効な Codex `config.toml` も適切に扱います(#461、@majiayu000 に感謝)
|
||||
- Windows 互換性:MCP エクスポート時の npx/npm 呼び出しを `cmd /c` でラップ
|
||||
|
||||
### 使用量と価格データ
|
||||
|
||||
- 使用量/価格の改善:自動更新、キャッシュ指標、タイムゾーン修正、内蔵価格テーブル更新(#508、@yovinchen に感謝)
|
||||
- DeepLink 対応:deeplink から使用量クエリ設定をインポート(#400、@qyinter に感謝)
|
||||
- 使用量統計からモデル情報を抽出(#455、@yovinchen に感謝)
|
||||
- 使用量クエリ資格情報はプロバイダ設定へフォールバック可能(#360、@Sirhexs に感謝)
|
||||
|
||||
---
|
||||
|
||||
## 使い勝手の改善
|
||||
|
||||
- プロバイダ検索フィルター(名前で素早く検索)(#435、@TinsFox に感謝)
|
||||
- プロバイダのアイコン色:アイコンに任意の色を設定して見分けやすく(#385、@yovinchen に感謝)
|
||||
- ショートカット:`Cmd/Ctrl + ,` で設定を開く(#436、@TinsFox に感謝)
|
||||
- Claude Code の初回確認ダイアログをスキップ可能(任意)
|
||||
- トースト通知のクローズボタン:切り替え通知と成功通知を閉じられるように(#350、@ForteScarlet に感謝)
|
||||
- 更新バッジをクリックすると About タブへ移動
|
||||
- 設定ページのタブスタイル改善(#342、@wenyuanw に感謝)
|
||||
- アプリ/ビュー切り替えのフェードとパネル終了アニメーション
|
||||
- プロキシテイクオーバー中はエメラルド系テーマを適用して状態を分かりやすく
|
||||
- ダークモードの視認性改善
|
||||
- FullScreenPanel のウィンドウドラッグ領域を改善(#525、@zerob13 に感謝)
|
||||
|
||||
---
|
||||
|
||||
## プラットフォーム別メモ
|
||||
|
||||
### Windows
|
||||
|
||||
- バージョンチェック時にターミナルが表示されないよう改善
|
||||
- ウィンドウ最小サイズのデフォルトを調整
|
||||
- 起動時の黒画面を避けるため、システムタイトルバー方式を採用
|
||||
- 古い WebView 向けに `crypto.randomUUID()` のフォールバックを追加
|
||||
|
||||
### macOS
|
||||
|
||||
- 自動起動で `.app` バンドルパスを使用し、ターミナル表示を回避(#462、@majiayu000 に感謝)
|
||||
- トレイとヘッダー周りの体験を改善
|
||||
|
||||
---
|
||||
|
||||
## パッケージ
|
||||
|
||||
- Linux:RPM と Flatpak のパッケージングを追加し、リリース成果物の生成に対応
|
||||
|
||||
---
|
||||
|
||||
## 注意事項
|
||||
|
||||
- セキュリティ強化:JavaScript 実行器と使用量スクリプト実行に関するセキュリティ問題を修正(#151、@luojiyin1987 に感謝)。
|
||||
- SQL インポートは CC Switch がエクスポートしたバックアップのみに制限されます(安全性のため)。
|
||||
- プロキシのテイクオーバーは CLI の live 設定を変更します。CC Switch はリダイレクト前に live 設定をバックアップします。元に戻す場合はテイクオーバー無効化/プロキシ停止を行い、必要に応じてバックアップから復元してください。
|
||||
|
||||
## 特別な謝辞
|
||||
|
||||
@xunyu @deijing @su-fen の皆様のサポートと貢献に特別な感謝を申し上げます。皆様なしではこのリリースは実現しませんでした!
|
||||
|
||||
## ダウンロード & インストール
|
||||
|
||||
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から該当するバージョンをダウンロードしてください。
|
||||
|
||||
### システム要件
|
||||
|
||||
| システム | 最低バージョン | アーキテクチャ |
|
||||
| -------- | ----------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 以降 | x64 |
|
||||
| macOS | macOS 10.15 (Catalina) 以降 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 下表参照 | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| ファイル | 説明 |
|
||||
| --------------------------------------- | -------------------------------------------- |
|
||||
| `CC-Switch-v3.9.0-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
|
||||
| `CC-Switch-v3.9.0-Windows-Portable.zip` | ポータブル版、インストール不要 |
|
||||
|
||||
### macOS
|
||||
|
||||
| ファイル | 説明 |
|
||||
| ------------------------------- | ----------------------------------------------------------------- |
|
||||
| `CC-Switch-v3.9.0-macOS.zip` | **推奨** - 解凍して Applications へドラッグ、Universal Binary |
|
||||
| `CC-Switch-v3.9.0-macOS.tar.gz` | Homebrew インストールおよび自動更新用 |
|
||||
|
||||
> **注意**: 作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元が未確認」という警告が表示される場合があります。アプリを閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、正常に開けるようになります。
|
||||
|
||||
### Homebrew (MacOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
アップデート:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||
| --------------------------------------- | ----------- | ------------------------------------------------------------------------------ |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 実行権限を付与して直接実行、または AUR を使用 |
|
||||
| その他 / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
| サンドボックスで実行したい場合 | `.flatpak` | `flatpak install CC-Switch-*.flatpak` |
|
||||
@@ -1,188 +0,0 @@
|
||||
# CC Switch v3.9.0
|
||||
|
||||
> 本地 API 代理、自动故障切换、统一供应商与多应用工作流增强
|
||||
|
||||
**[English →](release-note-v3.9.0-en.md) | [日本語版 →](release-note-v3.9.0-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.9.0 是 v3.9 测试版序列(`3.9.0-1`、`3.9.0-2`、`3.9.0-3`)的稳定版。
|
||||
本次更新带来本地 API 代理(支持按应用接管)、自动故障切换、统一供应商(Universal Provider),并对 Claude Code / Codex / Gemini CLI 的稳定性与使用体验做了大量改进。
|
||||
|
||||
**发布日期**:2026-01-07
|
||||
|
||||
---
|
||||
|
||||
## 重点内容
|
||||
|
||||
- 本地 API 代理:Claude Code / Codex / Gemini CLI 统一接入
|
||||
- 自动故障切换:熔断保护 + 每个应用独立的 failover 队列
|
||||
- 统一供应商:一份配置可同步到多个应用(适合 NewAPI 等网关)
|
||||
- Skills 相关增强:支持多应用、管理架构统一(SSOT + React Query)
|
||||
- 通用配置片段:支持从编辑器内容或当前供应商提取可复用片段
|
||||
- MCP 导入:支持从已安装应用导入 MCP servers
|
||||
- 用量增强:自动刷新、缓存命中/创建指标、时区修复
|
||||
- Linux 打包:新增 RPM 与 Flatpak 制品
|
||||
|
||||
---
|
||||
|
||||
## 主要功能
|
||||
|
||||
### 本地 API 代理(Local API Proxy)
|
||||
|
||||
- 运行一个本地高性能 HTTP 代理服务(基于 Axum)
|
||||
- 统一代理 Claude Code、Codex、Gemini CLI 的 API 请求
|
||||
- 按应用接管:你可以分别控制每个应用是否走本地代理
|
||||
- Live 配置接管:启用接管时,会备份并重定向 CLI 的 live 配置到本地代理
|
||||
- 监控能力:记录请求日志与用量统计,便于排错与成本分析
|
||||
- 错误请求日志:代理会记录失败请求的详细信息,便于定位问题(#401,感谢 @yovinchen)
|
||||
|
||||
### 自动故障切换(Auto Failover / 熔断)
|
||||
|
||||
- 自动检测供应商异常并触发熔断保护
|
||||
- 当前供应商不可用时自动切换到备用供应商
|
||||
- 每个应用维护独立的 failover 队列,并实时追踪健康状态
|
||||
- 当关闭故障切换时,超时/重试相关配置不会影响正常请求流程
|
||||
|
||||
### Skills 管理
|
||||
|
||||
- Skills 支持 Claude Code 与 Codex 多应用使用,并提供旧结构到新结构的平滑迁移(#365、#378,感谢 @yovinchen)
|
||||
- Skills 管理架构统一(SSOT + React Query),状态刷新与数据一致性更稳定
|
||||
- 发现(Discovery)体验与性能改进:
|
||||
- 扫描时跳过隐藏目录
|
||||
- Discoverable skills 使用长生命周期缓存提升性能
|
||||
- 增加加载状态提示,导入/刷新等操作入口更显眼
|
||||
- 修复 Skills 仓库分支配置错误(#505,感谢 @kjasn)
|
||||
|
||||
### 统一供应商(Universal Provider)
|
||||
|
||||
- 新增“跨应用共享”的供应商配置,可同步到 Claude/Codex/Gemini(#348,感谢 @Calcium-Ion)
|
||||
- 适配支持多协议的 API 网关(例如 NewAPI)
|
||||
- 同一个供应商下可按应用分别设置默认模型映射
|
||||
|
||||
### 通用配置片段(Claude/Codex/Gemini)
|
||||
|
||||
- 维护一段“通用配置片段”,并将其合并/追加到启用该功能的供应商配置中
|
||||
- 新增“提取通用配置片段”工作流:
|
||||
- 优先从编辑器当前内容提取(你正在编辑的内容)
|
||||
- 若未提供编辑器内容,则从当前激活的供应商提取
|
||||
- Codex 场景提取更安全:
|
||||
- 自动移除 `model_provider`、`model` 以及整个 `model_providers` 表等供应商相关内容
|
||||
- 会保留 `[mcp_servers.*]` 下的 `base_url`,避免误伤 MCP 配置
|
||||
|
||||
### MCP 管理
|
||||
|
||||
- 支持从已安装应用导入 MCP servers
|
||||
- 同步更稳健:目标 CLI 未安装则跳过;无效的 Codex `config.toml` 可更优雅处理(#461,感谢 @majiayu000)
|
||||
- Windows 兼容性:MCP 导出相关的 npx/npm 调用使用 `cmd /c` 包裹
|
||||
|
||||
### 用量与计费数据
|
||||
|
||||
- 用量与计费增强:自动刷新、缓存命中/创建指标、时区修复,以及内置价格表更新(#508,感谢 @yovinchen)
|
||||
- 深链支持:可通过 deeplink 导入用量查询配置(#400,感谢 @qyinter)
|
||||
- 用量统计支持提取模型信息(#455,感谢 @yovinchen)
|
||||
- 用量查询凭证支持从供应商配置回退(#360,感谢 @Sirhexs)
|
||||
|
||||
---
|
||||
|
||||
## 体验优化
|
||||
|
||||
- 供应商搜索过滤:按名称快速查找(#435,感谢 @TinsFox)
|
||||
- 供应商图标颜色:支持为供应商图标设置自定义颜色,便于快速区分(#385,感谢 @yovinchen)
|
||||
- 快捷键:`Cmd/Ctrl + ,` 打开设置(#436,感谢 @TinsFox)
|
||||
- 可跳过 Claude Code 首次确认弹窗(可选)
|
||||
- Toast 通知可关闭:切换提示与成功提示都支持关闭按钮(#350,感谢 @ForteScarlet)
|
||||
- 点击更新徽章会自动跳转到 About 标签页
|
||||
- 设置页 Tab 样式改进(#342,感谢 @wenyuanw)
|
||||
- 更顺滑的切换动效:应用/视图淡入淡出与面板退出动画
|
||||
- 代理接管激活时应用翡翠绿主题,便于一眼识别当前状态
|
||||
- 深色模式可读性增强(表单与标签对比度等)
|
||||
- FullScreenPanel 的窗口拖拽区域优化(#525,感谢 @zerob13)
|
||||
|
||||
---
|
||||
|
||||
## 平台说明
|
||||
|
||||
### Windows
|
||||
|
||||
- 版本检查不再弹出终端窗口
|
||||
- 改进窗口尺寸默认值(最小宽高)
|
||||
- 修复部分设备启动黑屏问题(使用系统标题栏方案)
|
||||
- 兼容旧 WebView:为 `crypto.randomUUID()` 增加降级方案
|
||||
|
||||
### macOS
|
||||
|
||||
- 自启动使用 `.app bundle` 路径,避免弹出终端窗口(#462,感谢 @majiayu000)
|
||||
- 托盘与标题栏相关体验优化
|
||||
|
||||
---
|
||||
|
||||
## 打包
|
||||
|
||||
- Linux:新增 RPM 与 Flatpak 打包目标,用于生成发布制品
|
||||
|
||||
---
|
||||
|
||||
## 说明与注意事项
|
||||
|
||||
- 安全增强:修复 JavaScript 执行器与用量脚本相关的安全问题(#151,感谢 @luojiyin1987)。
|
||||
- 为降低导入风险,SQL 导入被限制为仅允许导入 CC Switch 自己导出的备份。
|
||||
- Proxy 接管会修改 CLI 的 live 配置;CC Switch 会在重定向前自动备份 live 配置。如需回退,可关闭接管/停止代理,并在必要时从备份恢复。
|
||||
|
||||
## 特别感谢
|
||||
|
||||
特别感谢 @xunyu @deijing @su-fen 做出的支持和贡献,没有你们就没有这个版本!
|
||||
|
||||
## 下载与安装
|
||||
|
||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
|
||||
|
||||
### 系统要求
|
||||
|
||||
| 系统 | 最低版本 | 架构 |
|
||||
| ------- | ----------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 及以上 | x64 |
|
||||
| macOS | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 见下表 | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| 文件 | 说明 |
|
||||
| --------------------------------------- | ----------------------------------- |
|
||||
| `CC-Switch-v3.9.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.9.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
|
||||
### macOS
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ------------------------------- | --------------------------------------------------------- |
|
||||
| `CC-Switch-v3.9.0-macOS.zip` | **推荐** - 解压后拖入 Applications 即可,Universal Binary |
|
||||
| `CC-Switch-v3.9.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||
|
||||
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
||||
|
||||
### Homebrew(MacOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| 发行版 | 推荐格式 | 安装方式 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
|
||||
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
| 沙箱隔离需求 | `.flatpak` | `flatpak install CC-Switch-*.flatpak` |
|
||||
@@ -0,0 +1,63 @@
|
||||
# 1.1 软件介绍
|
||||
|
||||
## 什么是 CC Switch
|
||||
|
||||
CC Switch 是一款跨平台桌面应用,专为使用 AI 编程工具的开发者设计。它帮助你统一管理 **Claude Code**、**Codex**、**Gemini CLI** 三大 AI 编程工具的配置。
|
||||
|
||||
## 解决什么问题
|
||||
|
||||
在日常开发中,你可能会遇到这些痛点:
|
||||
|
||||
- **多供应商切换麻烦**:使用不同的 API 供应商(官方、中转服务商),需要手动修改配置文件
|
||||
- **配置分散难管理**:Claude、Codex、Gemini 各有独立的配置文件,格式不同
|
||||
- **无法监控用量**:不知道 API 调用了多少次,花了多少钱
|
||||
- **服务不稳定**:单一供应商出问题时,整个工作流中断
|
||||
|
||||
CC Switch 通过统一的界面解决这些问题。
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 供应商管理
|
||||
- 一键切换多个 API 供应商配置
|
||||
- 支持预设模板,快速添加常用供应商
|
||||
- 统一供应商功能,跨应用共享配置
|
||||
- 用量查询与余额显示
|
||||
- 端点速度测试
|
||||
|
||||
### 扩展功能
|
||||
- **MCP 服务器**:管理 Model Context Protocol 服务器,扩展 AI 能力
|
||||
- **Prompts**:管理系统提示词预设,快速切换不同场景
|
||||
- **Skills**:安装和管理技能扩展(仅 Claude/Codex)
|
||||
|
||||
### 代理与高可用
|
||||
- 本地代理服务,记录请求日志和用量统计
|
||||
- 自动故障转移,主供应商失败时自动切换备用
|
||||
- 熔断器机制,防止频繁重试失败的供应商
|
||||
- 详细的 Token 用量追踪与成本估算
|
||||
|
||||
## 支持的应用
|
||||
|
||||
| 应用 | 说明 |
|
||||
|------|------|
|
||||
| **Claude Code** | Anthropic 官方的 AI 编程助手 |
|
||||
| **Codex** | OpenAI 的代码生成工具 |
|
||||
| **Gemini CLI** | Google 的 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(设备设置)
|
||||
|
||||
这种架构确保了:
|
||||
- 跨平台一致的体验
|
||||
- 原生级别的性能
|
||||
- 安全的本地数据存储
|
||||
@@ -0,0 +1,243 @@
|
||||
# 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
|
||||
|
||||
**方式一:Homebrew(macOS 推荐)**
|
||||
|
||||
```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
|
||||
|
||||
**方式一:Homebrew(macOS 推荐)**
|
||||
|
||||
```bash
|
||||
brew install codex
|
||||
```
|
||||
|
||||
**方式二:npm**
|
||||
|
||||
```bash
|
||||
npm install -g @openai/codex
|
||||
|
||||
# 国内用户如下载慢,使用镜像源
|
||||
npm install -g @openai/codex --registry=https://registry.npmmirror.com
|
||||
```
|
||||
|
||||
#### Gemini CLI
|
||||
|
||||
**方式一:Homebrew(macOS 推荐)**
|
||||
|
||||
```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
|
||||
```
|
||||
@@ -0,0 +1,168 @@
|
||||
# 1.3 界面概览
|
||||
|
||||
## 主界面布局
|
||||
|
||||

|
||||
|
||||
## 顶部导航栏
|
||||
|
||||
| 序号 | 元素 | 功能说明 |
|
||||
|------|------|----------|
|
||||
| ① | Logo | 点击访问 GitHub 项目页 |
|
||||
| ② | 设置按钮 | 打开设置页面(快捷键 `Cmd/Ctrl + ,`) |
|
||||
| ③ | 代理开关 | 启动/停止本地代理服务 |
|
||||
| ④ | 应用切换器 | 切换 Claude / Codex / Gemini |
|
||||
| ⑤ | 功能区 | Skills / Prompts / MCP 入口 |
|
||||
| ⑥ | 添加按钮 | 添加新供应商 |
|
||||
|
||||
### 应用切换器
|
||||
|
||||
点击下拉菜单切换当前管理的应用:
|
||||
|
||||
- **Claude** - 管理 Claude Code 配置
|
||||
- **Codex** - 管理 Codex 配置
|
||||
- **Gemini** - 管理 Gemini CLI 配置
|
||||
|
||||
切换后,供应商列表会显示对应应用的配置。
|
||||
|
||||
### 功能区按钮
|
||||
|
||||
| 按钮 | 功能 | 可见条件 |
|
||||
|------|------|----------|
|
||||
| Skills | 技能扩展管理 | 始终可见 |
|
||||
| Prompts | 系统提示词管理 | 始终可见 |
|
||||
| MCP | MCP 服务器管理 | 始终可见 |
|
||||
|
||||
## 供应商卡片
|
||||
|
||||
每个供应商以卡片形式展示,从左到右依次包含以下元素:
|
||||
|
||||
### 卡片元素(从左到右)
|
||||
|
||||
| 序号 | 元素 | 图标 | 功能说明 |
|
||||
|------|------|------|----------|
|
||||
| ① | 拖拽手柄 | ≡ | 按住上下拖动调整供应商顺序 |
|
||||
| ② | 供应商图标 | 🔷 | 显示供应商品牌图标,可自定义颜色 |
|
||||
| ③ | 供应商信息 | - | 名称、备注/端点地址(可点击打开官网) |
|
||||
| ④ | 用量信息 | - | 显示剩余额度,多套餐时显示套餐数量 |
|
||||
| ⑤ | 启用按钮 | ▶ | 切换为当前使用的供应商 |
|
||||
| ⑥ | 编辑按钮 | ✏️ | 编辑供应商配置 |
|
||||
| ⑦ | 复制按钮 | 📋 | 复制供应商(创建副本) |
|
||||
| ⑧ | 测速按钮 | 🧪 | 测试模型可用性和响应速度 |
|
||||
| ⑨ | 用量查询 | 📊 | 配置用量查询脚本 |
|
||||
| ⑩ | 删除按钮 | 🗑️ | 删除供应商(当前启用时禁用) |
|
||||
|
||||
> 💡 **提示**:操作按钮区域(⑤-⑩)在鼠标悬停时显示,平时隐藏以保持界面简洁。
|
||||
|
||||
### 按钮详细说明
|
||||
|
||||
| 按钮 | 状态变化 | 说明 |
|
||||
|------|----------|------|
|
||||
| **启用** | 已启用时显示 ✓ 并禁用 | 故障转移模式下变为「加入/已加入」 |
|
||||
| **编辑** | 始终可用 | 打开编辑面板修改配置 |
|
||||
| **复制** | 始终可用 | 创建供应商副本,名称后缀 `copy` |
|
||||
| **测速** | 测试中显示加载动画 | 仅代理服务运行时可用 |
|
||||
| **用量查询** | 始终可用 | 配置自定义用量查询脚本 |
|
||||
| **删除** | 当前启用时半透明禁用 | 需先切换到其他供应商才能删除 |
|
||||
|
||||
### 卡片状态
|
||||
|
||||
| 状态 | 边框颜色 | 说明 |
|
||||
|------|----------|------|
|
||||
| **当前启用** | 🔵 蓝色边框 | 普通模式下当前使用的供应商 |
|
||||
| **代理活跃** | 🟢 绿色边框 | 代理接管模式下实际使用的供应商 |
|
||||
| **普通状态** | 默认边框 | 未启用的供应商 |
|
||||
| **故障转移中** | 显示优先级徽章 | 如 P1、P2 表示故障转移优先级 |
|
||||
|
||||
### 健康状态徽章
|
||||
|
||||
在代理模式下,加入故障转移队列的供应商会显示健康状态:
|
||||
|
||||
| 徽章 | 颜色 | 说明 |
|
||||
|------|------|------|
|
||||
| 健康 | 🟢 绿色 | 连续失败 0 次 |
|
||||
| 警告 | 🟡 黄色 | 连续失败 1-2 次 |
|
||||
| 不健康 | 🔴 红色 | 连续失败 ≥3 次,可能触发熔断 |
|
||||
|
||||
|
||||
## 系统托盘
|
||||
|
||||
CC Switch 在系统托盘显示图标,提供快速操作入口。
|
||||
|
||||
### 托盘菜单结构
|
||||
|
||||

|
||||
|
||||
### 菜单功能
|
||||
|
||||
| 菜单项 | 功能 |
|
||||
|--------|------|
|
||||
| 打开主界面 | 显示主窗口并聚焦 |
|
||||
| 应用分组 | 按 Claude/Codex/Gemini 分组显示供应商 |
|
||||
| 供应商列表 | 点击切换,当前启用的显示勾选标记 |
|
||||
| 退出 | 完全退出应用 |
|
||||
|
||||
### 多语言支持
|
||||
|
||||
托盘菜单支持三种语言,根据设置自动切换:
|
||||
|
||||
| 语言 | 打开主界面 | 退出 |
|
||||
|------|-----------|------|
|
||||
| 中文 | 打开主界面 | 退出 |
|
||||
| English | Open main window | Quit |
|
||||
| 日本語 | メインウィンドウを開く | 終了 |
|
||||
|
||||
### 使用场景
|
||||
|
||||
托盘切换供应商无需打开主界面,适合:
|
||||
|
||||
- 频繁切换供应商
|
||||
- 主窗口最小化时快速操作
|
||||
- 后台运行时管理配置
|
||||
|
||||
## 设置页面
|
||||
|
||||
设置页面分为多个 Tab:
|
||||
|
||||
### 通用 Tab
|
||||
|
||||
- 语言设置(中文/English/日本語)
|
||||
- 主题设置(跟随系统/浅色/深色)
|
||||
- 窗口行为(开机自启、关闭行为)
|
||||
|
||||
### 高级 Tab
|
||||
|
||||
- 配置目录设置
|
||||
- 代理服务配置
|
||||
- 故障转移设置
|
||||
- 定价配置
|
||||
- 数据导入导出
|
||||
|
||||
### 用量 Tab
|
||||
|
||||
- 请求统计概览
|
||||
- 趋势图表
|
||||
- 请求日志
|
||||
- 供应商/模型统计
|
||||
|
||||
### 关于 Tab
|
||||
|
||||
- 版本信息
|
||||
- 更新检查
|
||||
- 开源协议
|
||||
|
||||
## 快捷键
|
||||
|
||||
| 快捷键 | 功能 |
|
||||
|--------|------|
|
||||
| `Cmd/Ctrl + ,` | 打开设置 |
|
||||
| `Cmd/Ctrl + F` | 搜索供应商 |
|
||||
| `Esc` | 关闭弹窗/搜索 |
|
||||
|
||||
## 搜索功能
|
||||
|
||||
按 `Cmd/Ctrl + F` 打开搜索框:
|
||||
|
||||
- 支持按名称、备注、URL 搜索
|
||||
- 实时过滤供应商列表
|
||||
- 按 `Esc` 关闭搜索
|
||||
@@ -0,0 +1,92 @@
|
||||
# 1.4 快速上手
|
||||
|
||||
本节帮助你在 5 分钟内完成首次配置。
|
||||
|
||||
## 第一步:添加供应商
|
||||
|
||||
1. 点击主界面右上角的 **+** 按钮
|
||||
2. 在「预设」下拉框中选择你的供应商
|
||||
- 常用预设:智谱 GLM、MiniMax、DeepSeek、Kimi、PackyCode
|
||||
- 或选择「自定义」手动配置
|
||||
3. 填写 **API Key**
|
||||
4. 点击「添加」
|
||||
|
||||

|
||||
|
||||
> 💡 **提示**:预设会自动填充端点地址,你只需要填写 API Key。
|
||||
|
||||
## 第二步:切换供应商
|
||||
|
||||
添加完成后,供应商会出现在列表中。
|
||||
|
||||
**方式一:主界面切换**
|
||||
- 点击供应商卡片的「启用」按钮
|
||||
|
||||
**方式二:托盘快速切换**
|
||||
- 右键系统托盘图标
|
||||
- 直接点击供应商名称
|
||||
|
||||
## 第三步:生效方式
|
||||
|
||||
切换供应商后,各 CLI 工具的生效方式不同:
|
||||
|
||||
| 应用 | 生效方式 |
|
||||
|------|----------|
|
||||
| Claude Code | ✅ 即时生效(支持热重载) |
|
||||
| Codex | 需关闭并重新打开终端 |
|
||||
| Gemini | ✅ 即时生效(每次请求重新读取配置) |
|
||||
|
||||
### Claude Code 首次安装提示
|
||||
|
||||
如果 Claude Code 首次启动时提示需要**登录**或显示初始化引导,请在 CC Switch 中开启「跳过 Claude Code 初次安装确认」选项:
|
||||
|
||||
1. 打开 CC Switch「设置 → 通用」
|
||||
2. 开启「跳过 Claude Code 初次安装确认」开关
|
||||
3. 重新启动 Claude Code
|
||||
|
||||

|
||||
|
||||
> ⚠️ **注意**:此选项会写入 `~/.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),重启客户端后按登录流程操作。
|
||||
@@ -0,0 +1,134 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
点击「复制」按钮可复制到剪贴板。
|
||||
@@ -0,0 +1,260 @@
|
||||
# 2.1 添加供应商
|
||||
|
||||
## 打开添加面板
|
||||
|
||||
点击主界面右上角的 **+** 按钮,打开添加供应商面板。
|
||||
|
||||
面板分为两个 Tab:
|
||||
- **应用专属供应商**:仅用于当前选中的应用(Claude/Codex/Gemini)
|
||||
- **统一供应商**:跨应用共享的配置
|
||||
|
||||
## 使用预设添加
|
||||
|
||||
预设是预先配置好的供应商模板,只需填写 API Key 即可使用。
|
||||
|
||||
### 操作步骤
|
||||
|
||||
1. 在「预设」下拉框中选择供应商
|
||||
2. 名称和端点会自动填充
|
||||
3. 填写你的 **API Key**
|
||||
4. (可选)填写备注
|
||||
5. 点击「添加」
|
||||
|
||||
### 常用预设
|
||||
|
||||
#### Claude 预设
|
||||
|
||||
| 预设名称 | 说明 |
|
||||
|----------|------|
|
||||
| 官方登录 | 使用 Anthropic 官方账号登录 |
|
||||
| DeepSeek | DeepSeek-V3.2 模型 |
|
||||
| 智谱 GLM | 智谱 AI 的 GLM-4.7 模型 ⭐ |
|
||||
| Qwen Coder | 通义千问(阿里云百炼) |
|
||||
| Kimi k2 | Moonshot Kimi-k2 模型 |
|
||||
| MiniMax | MiniMax-M2.1 模型 ⭐ |
|
||||
| PackyCode | PackyCode 中转服务 ⭐ |
|
||||
| OpenRouter | 聚合路由服务 |
|
||||
| 自定义 | 手动配置所有参数 |
|
||||
|
||||
> ⭐ 标注为官方合作伙伴
|
||||
|
||||
#### Codex 预设
|
||||
|
||||
| 预设名称 | 说明 |
|
||||
|----------|------|
|
||||
| 官方登录 | 使用 OpenAI 官方账号登录 |
|
||||
| PackyCode | PackyCode 中转服务 |
|
||||
| 自定义 | 手动配置所有参数 |
|
||||
|
||||
#### Gemini 预设
|
||||
|
||||
| 预设名称 | 说明 |
|
||||
|----------|------|
|
||||
| Google 官方 | 使用 Google OAuth 登录 |
|
||||
| PackyCode | PackyCode 中转服务 |
|
||||
| 自定义 | 手动配置所有参数 |
|
||||
|
||||
## 自定义配置
|
||||
|
||||
选择「自定义」预设后,需要手动编辑 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"
|
||||
},
|
||||
"authMode": "api_key"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `GEMINI_API_KEY` | 是 | API 密钥 |
|
||||
| `GOOGLE_GEMINI_BASE_URL` | 否 | 自定义端点地址 |
|
||||
| `GEMINI_MODEL` | 否 | 指定模型 |
|
||||
| `authMode` | 否 | 认证模式:`api_key` 或 `oauth` |
|
||||
|
||||
## 统一供应商
|
||||
|
||||
统一供应商可以跨 Claude/Codex/Gemini 共享配置,适用于支持多种 API 格式的中转服务。
|
||||
|
||||
### 创建统一供应商
|
||||
|
||||
1. 切换到「统一供应商」Tab
|
||||
2. 点击「添加统一供应商」
|
||||
3. 填写通用配置:
|
||||
- 名称
|
||||
- API Key
|
||||
- 端点地址
|
||||
4. 勾选要同步的应用(Claude/Codex/Gemini)
|
||||
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(较慢)
|
||||
|
||||

|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# 2.2 切换供应商
|
||||
|
||||
## 主界面切换
|
||||
|
||||
在供应商列表中,点击目标供应商卡片的「启用」按钮。
|
||||
|
||||
### 切换流程
|
||||
|
||||
1. 点击「启用」按钮
|
||||
2. CC Switch 更新配置文件
|
||||
3. 卡片状态变为「当前启用」
|
||||
4. Claude/Gemini 即时生效,Codex 需重启终端
|
||||
|
||||
### 状态指示
|
||||
|
||||
| 状态 | 显示 | 说明 |
|
||||
|------|------|------|
|
||||
| 当前启用 | 蓝色边框 + 标签 | 配置文件中的当前供应商 |
|
||||
| 代理活跃 | 绿色边框 | 代理模式下实际使用的供应商 |
|
||||
| 普通 | 默认样式 | 未启用的供应商 |
|
||||
|
||||
## 托盘快速切换
|
||||
|
||||
通过系统托盘可以快速切换,无需打开主界面。
|
||||
|
||||
### 操作步骤
|
||||
|
||||
1. 右键点击系统托盘的 CC Switch 图标
|
||||
2. 在菜单中找到对应应用(Claude/Codex/Gemini)
|
||||
3. 点击要切换的供应商名称
|
||||
4. 切换完成,托盘会短暂提示
|
||||
|
||||
### 托盘菜单结构
|
||||
|
||||

|
||||
|
||||
## 生效方式
|
||||
|
||||
### 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 格式。
|
||||
@@ -0,0 +1,145 @@
|
||||
# 2.3 编辑供应商
|
||||
|
||||
## 打开编辑面板
|
||||
|
||||
1. 找到要编辑的供应商卡片
|
||||
2. 鼠标悬停在卡片上,显示操作按钮
|
||||
3. 点击「编辑」按钮
|
||||
|
||||
## 可编辑内容
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| 名称 | 供应商显示名称 |
|
||||
| 备注 | 附加说明信息 |
|
||||
| 网站链接 | 供应商官网或控制台地址 |
|
||||
| 图标 | 自定义图标和颜色 |
|
||||
|
||||
### 图标自定义
|
||||
|
||||
CC Switch 提供丰富的图标自定义功能:
|
||||
|
||||
#### 图标选择器
|
||||
|
||||
1. 点击图标区域打开图标选择器
|
||||
2. 使用搜索框按名称搜索图标
|
||||
3. 点击选择想要的图标
|
||||
|
||||
图标库包含常见的 AI 服务商和技术图标,支持:
|
||||
- 按名称模糊搜索
|
||||
- 显示图标名称提示
|
||||
- 实时预览选中效果
|
||||
|
||||

|
||||
|
||||
### 配置信息
|
||||
|
||||
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` 键关闭编辑面板,所有修改都不会保存。
|
||||
@@ -0,0 +1,76 @@
|
||||
# 2.4 排序与复制
|
||||
|
||||
## 拖拽排序
|
||||
|
||||
通过拖拽调整供应商的显示顺序。
|
||||
|
||||
### 操作步骤
|
||||
|
||||
1. 将鼠标移到供应商卡片左侧的 **≡** 拖拽手柄
|
||||
2. 按住鼠标左键
|
||||
3. 上下拖动到目标位置
|
||||
4. 松开鼠标完成排序
|
||||
|
||||
### 排序用途
|
||||
|
||||
- **常用优先**:将常用的供应商放在列表顶部
|
||||
- **故障转移顺序**:排序会影响故障转移队列的默认顺序
|
||||
|
||||
## 复制供应商
|
||||
|
||||
快速创建供应商的副本,适用于:
|
||||
|
||||
- 基于现有配置创建变体
|
||||
- 备份当前配置
|
||||
- 创建测试用配置
|
||||
|
||||
### 操作步骤
|
||||
|
||||
1. 鼠标悬停在供应商卡片上,显示操作按钮
|
||||
2. 点击「复制」按钮
|
||||
3. 自动创建副本,名称后缀 `copy`
|
||||
4. 编辑副本修改配置
|
||||
|
||||
### 复制内容
|
||||
|
||||
复制会创建完整的副本,包括:
|
||||
|
||||
| 内容 | 是否复制 |
|
||||
|------|----------|
|
||||
| 名称 | ✅ 复制(添加 `copy` 后缀) |
|
||||
| 配置 | ✅ 完整复制 |
|
||||
| 备注 | ✅ 复制 |
|
||||
| 网站链接 | ✅ 复制 |
|
||||
| 图标 | ✅ 复制 |
|
||||
| 端点列表 | ✅ 复制 |
|
||||
| 排序位置 | ✅ 插入到原供应商下方 |
|
||||
|
||||
### 复制后编辑
|
||||
|
||||
复制完成后,通常需要修改:
|
||||
|
||||
1. **名称**:改为有意义的名称
|
||||
2. **API Key**:如果是不同账号
|
||||
3. **端点**:如果是不同服务
|
||||
|
||||
## 删除供应商
|
||||
|
||||
### 操作步骤
|
||||
|
||||
1. 鼠标悬停在供应商卡片上,显示操作按钮
|
||||
2. 点击「删除」按钮
|
||||
3. 确认删除
|
||||
|
||||
### 删除确认
|
||||
|
||||
删除前会弹出确认对话框,显示:
|
||||
|
||||
- 供应商名称
|
||||
- 删除后无法恢复的提示
|
||||
|
||||
### 删除限制
|
||||
|
||||
- **当前启用的供应商**:可以删除,但建议先切换到其他供应商
|
||||
- **统一供应商**:删除后,关联的应用配置也会被删除
|
||||
|
||||

|
||||
@@ -0,0 +1,181 @@
|
||||
# 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 Token(New API) |
|
||||
| `{{userId}}` | 配置的 User ID(New API) |
|
||||
|
||||
## 常见供应商配置示例
|
||||
|
||||
### 故障排除
|
||||
|
||||
### 查询失败
|
||||
|
||||
**检查**:
|
||||
1. API Key 是否正确
|
||||
2. Base URL 是否正确
|
||||
3. 网络是否可访问
|
||||
4. 超时时间是否足够
|
||||
|
||||
### 返回数据为空
|
||||
|
||||
**检查**:
|
||||
1. 提取器函数是否有 `return` 语句
|
||||
2. 响应数据结构是否与提取器匹配
|
||||
3. 使用「测试脚本」查看原始响应
|
||||
|
||||
### 格式化失败
|
||||
|
||||
脚本语法错误时,点击「格式化」按钮会提示错误位置。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 用量查询会消耗少量 API 请求配额
|
||||
- 建议设置合理的自动查询间隔,避免频繁请求
|
||||
- 敏感信息(API Key、Token)会安全存储在本地
|
||||
@@ -0,0 +1,205 @@
|
||||
# 3.1 MCP 服务器管理
|
||||
|
||||
## 什么是 MCP
|
||||
|
||||
MCP (Model Context Protocol) 是一种协议,允许 AI 工具访问外部数据源和工具。通过 MCP 服务器,你可以让 AI:
|
||||
|
||||
- 访问文件系统
|
||||
- 执行网络请求
|
||||
- 查询数据库
|
||||
- 调用外部 API
|
||||
|
||||
## 打开 MCP 面板
|
||||
|
||||
点击顶部导航栏的 **MCP** 按钮。
|
||||
|
||||
## 面板概览
|
||||
|
||||

|
||||
|
||||
## 添加 MCP 服务器
|
||||
|
||||
### 使用预设模板
|
||||
|
||||
1. 点击右上角 **+** 按钮
|
||||
2. 在「预设」下拉框中选择模板
|
||||
3. 根据需要修改配置
|
||||
4. 点击「保存」
|
||||
|
||||

|
||||
|
||||
### 常用预设
|
||||
|
||||
| 预设 | 包名 | 功能说明 |
|
||||
|------|------|----------|
|
||||
| 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"
|
||||
}
|
||||
```
|
||||
|
||||
### sse(Server-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` |
|
||||
|
||||
### 开关实现机制
|
||||
|
||||
当开启某个应用的开关时,CC Switch 会:
|
||||
|
||||
1. **更新数据库**:将服务器的 `apps.claude/codex/gemini` 状态设为 `true`
|
||||
2. **同步到 Live 配置**:将服务器配置写入对应应用的配置文件
|
||||
3. **即时生效**:下次启动 CLI 工具时自动加载新的 MCP 服务器
|
||||
|
||||
当关闭某个应用的开关时,CC Switch 会:
|
||||
|
||||
1. **更新数据库**:将对应应用状态设为 `false`
|
||||
2. **从 Live 配置移除**:从应用配置文件中删除该服务器
|
||||
3. **即时生效**:下次启动 CLI 工具时不再加载该 MCP 服务器
|
||||
|
||||
### 同步条件
|
||||
|
||||
MCP 服务器同步仅在对应应用已安装时执行:
|
||||
|
||||
- **Claude**:需存在 `~/.claude/` 目录或 `~/.claude.json` 文件
|
||||
- **Codex**:需存在 `~/.codex/` 目录
|
||||
- **Gemini**:需存在 `~/.gemini/` 目录
|
||||
|
||||
> 💡 **提示**:如果某个 CLI 工具未安装,开启对应开关不会报错,但配置不会写入。
|
||||
|
||||
关闭开关后,配置会从文件中移除。
|
||||
|
||||
## 编辑服务器
|
||||
|
||||
1. 点击服务器行右侧的「编辑」按钮
|
||||
2. 修改配置
|
||||
3. 点击「保存」
|
||||
|
||||
修改会立即同步到已启用的应用配置文件。
|
||||
|
||||
## 删除服务器
|
||||
|
||||
1. 点击服务器行右侧的「删除」按钮
|
||||
2. 确认删除
|
||||
|
||||
删除后,配置会从所有应用的配置文件中移除。
|
||||
|
||||
## 导入现有配置
|
||||
|
||||
如果你已经在 CLI 工具中配置了 MCP 服务器,可以导入到 CC Switch:
|
||||
|
||||
1. 点击「导入」按钮
|
||||
2. 选择要导入的应用(Claude/Codex/Gemini)
|
||||
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 工具
|
||||
@@ -0,0 +1,156 @@
|
||||
# 3.2 Prompts 提示词管理
|
||||
|
||||
## 功能说明
|
||||
|
||||
Prompts 功能用于管理系统提示词预设。系统提示词会影响 AI 的行为和回复风格。
|
||||
|
||||
通过 CC Switch,你可以:
|
||||
|
||||
- 创建多个提示词预设
|
||||
- 快速切换不同场景的提示词
|
||||
- 跨设备同步提示词配置
|
||||
|
||||
## 打开 Prompts 面板
|
||||
|
||||
点击顶部导航栏的 **Prompts** 按钮。
|
||||
|
||||
## 面板概览
|
||||
|
||||

|
||||
|
||||
## 创建预设
|
||||
|
||||
### 操作步骤
|
||||
|
||||
1. 点击右上角 **+** 按钮
|
||||
2. 输入预设名称
|
||||
3. 在 Markdown 编辑器中编写提示词
|
||||
4. 点击「保存」
|
||||
|
||||
### Markdown 编辑器
|
||||
|
||||
编辑器提供:
|
||||
|
||||
- 语法高亮
|
||||
- 实时预览
|
||||
- 常用格式快捷键
|
||||
|
||||
### 提示词编写建议
|
||||
|
||||
**结构化格式**:
|
||||
|
||||
```markdown
|
||||
# 角色定义
|
||||
|
||||
你是一个专业的代码审查专家。
|
||||
|
||||
## 核心能力
|
||||
|
||||
- 代码质量分析
|
||||
- 性能优化建议
|
||||
- 安全漏洞检测
|
||||
|
||||
## 回复风格
|
||||
|
||||
- 简洁明了
|
||||
- 提供具体示例
|
||||
- 给出改进建议
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 不要修改业务逻辑
|
||||
- 保持代码风格一致
|
||||
```
|
||||
|
||||
## 激活预设
|
||||
|
||||
### 操作方式
|
||||
|
||||
点击预设项的开关按钮,切换启用状态。
|
||||
|
||||
### 单一激活
|
||||
|
||||
同一时间只能激活一个预设。激活新预设时,之前的预设会自动停用。
|
||||
|
||||
### 同步目标
|
||||
|
||||
激活后,提示词会写入对应应用的文件:
|
||||
|
||||
| 应用 | 文件路径 |
|
||||
|------|----------|
|
||||
| Claude | `~/.claude/CLAUDE.md` |
|
||||
| Codex | `~/.codex/AGENTS.md` |
|
||||
| Gemini | `~/.gemini/GEMINI.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 的预设
|
||||
|
||||
如需在多个应用使用相同的提示词,需要分别创建。
|
||||
|
||||
## 导入导出
|
||||
|
||||
### 通过深度链接分享
|
||||
|
||||
可以生成深度链接分享预设:
|
||||
|
||||
```
|
||||
ccswitch://import/prompt?data=<base64编码的预设>
|
||||
```
|
||||
|
||||
### 通过配置导出
|
||||
|
||||
导出配置时会包含所有预设,导入后可恢复。
|
||||
@@ -0,0 +1,197 @@
|
||||
# 3.3 Skills 技能管理
|
||||
|
||||
## 功能说明
|
||||
|
||||
Skills 是可复用的能力扩展,让 AI 工具获得特定领域的专业能力。
|
||||
|
||||
技能以文件夹形式存在,包含:
|
||||
- 提示词模板
|
||||
- 工具定义
|
||||
- 示例代码
|
||||
|
||||
## 支持的应用
|
||||
|
||||
Skills 功能仅支持:
|
||||
- **Claude Code**
|
||||
- **Codex**
|
||||
|
||||
> 💡 **注意**:Gemini CLI 暂不支持 Skills 功能。后端已预留 Gemini 技能路径 (`~/.gemini/skills/`),待 Gemini CLI 官方支持后将自动开放。
|
||||
|
||||
## 打开 Skills 页面
|
||||
|
||||
点击顶部导航栏的 **Skills** 按钮。
|
||||
|
||||
> 注意:只有在 Claude 或 Codex 模式下才能看到 Skills 按钮。
|
||||
|
||||
## 页面概览
|
||||
|
||||

|
||||
|
||||
## 发现技能
|
||||
|
||||
### 预配置仓库
|
||||
|
||||
CC Switch 预配置了以下 GitHub 仓库:
|
||||
|
||||
| 仓库 | 说明 |
|
||||
|------|------|
|
||||
| Anthropic 官方 | Anthropic 提供的官方技能 |
|
||||
| ComposioHQ | 社区维护的技能集合 |
|
||||
| 社区精选 | 精选的高质量技能 |
|
||||
|
||||

|
||||
|
||||
### 搜索过滤
|
||||
|
||||
CC Switch 提供强大的搜索和过滤功能:
|
||||
|
||||
#### 搜索框
|
||||
|
||||
- 支持按技能名称搜索
|
||||
- 支持按技能描述搜索
|
||||
- 支持按目录名称搜索
|
||||
- 实时过滤,输入即搜索
|
||||
|
||||
#### 状态过滤
|
||||
|
||||
使用下拉菜单按安装状态过滤:
|
||||
|
||||
| 选项 | 说明 |
|
||||
|------|------|
|
||||
| 全部 | 显示所有技能 |
|
||||
| 已安装 | 仅显示已安装的技能 |
|
||||
| 未安装 | 仅显示未安装的技能 |
|
||||
|
||||

|
||||
|
||||
#### 组合使用
|
||||
|
||||
搜索和过滤可以组合使用:
|
||||
- 先选择「已安装」过滤
|
||||
- 再输入关键词搜索
|
||||
- 结果显示匹配数量
|
||||
|
||||
### 刷新列表
|
||||
|
||||
点击「刷新」按钮重新扫描仓库,获取最新技能。
|
||||
|
||||
## 安装技能
|
||||
|
||||
### 操作步骤
|
||||
|
||||
1. 找到要安装的技能卡片
|
||||
2. 点击「安装」按钮
|
||||
3. 等待安装完成
|
||||
|
||||
### 安装位置
|
||||
|
||||
| 应用 | 安装目录 |
|
||||
|------|----------|
|
||||
| Claude | `~/.claude/skills/` |
|
||||
| Codex | `~/.codex/skills/` |
|
||||
|
||||
### 安装内容
|
||||
|
||||
安装会将技能文件夹复制到本地:
|
||||
|
||||
```
|
||||
~/.claude/skills/
|
||||
└── skill-name/
|
||||
├── README.md
|
||||
├── prompt.md
|
||||
└── tools/
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 卸载技能
|
||||
|
||||
### 操作步骤
|
||||
|
||||
1. 找到已安装的技能卡片
|
||||
2. 点击「卸载」按钮
|
||||
3. 确认卸载
|
||||
|
||||
### 卸载效果
|
||||
|
||||
- 删除本地技能文件夹
|
||||
- 更新安装状态
|
||||
|
||||
## 仓库管理
|
||||
|
||||
### 打开仓库管理
|
||||
|
||||
点击页面顶部的「仓库管理」按钮。
|
||||
|
||||
### 添加自定义仓库
|
||||
|
||||
1. 点击「添加仓库」
|
||||
2. 填写仓库信息:
|
||||
- Owner:GitHub 用户名或组织名
|
||||
- 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
|
||||
- 仓库配置错误
|
||||
|
||||
解决方法:
|
||||
- 检查网络连接
|
||||
- 点击「刷新」重试
|
||||
- 检查仓库配置
|
||||
|
||||
### 安装失败
|
||||
|
||||
可能原因:
|
||||
- 网络问题
|
||||
- 磁盘空间不足
|
||||
- 权限问题
|
||||
|
||||
解决方法:
|
||||
- 检查网络连接
|
||||
- 检查磁盘空间
|
||||
- 检查目录权限
|
||||
@@ -0,0 +1,222 @@
|
||||
# 4.1 代理服务
|
||||
|
||||
## 功能说明
|
||||
|
||||
代理服务在本地启动一个 HTTP 代理,所有 API 请求都通过代理转发。
|
||||
|
||||
**主要用途**:
|
||||
- 记录请求日志
|
||||
- 统计 API 用量
|
||||
- 支持故障转移
|
||||
- 集中管理多个应用的请求
|
||||
|
||||
## 启动代理
|
||||
|
||||
### 方式一:主界面开关
|
||||
|
||||
点击主界面顶部的 **代理开关** 按钮。
|
||||
|
||||
开关状态:
|
||||
- 🔴 白色:代理未运行
|
||||
- 🟢 绿色:代理运行中
|
||||
|
||||

|
||||
|
||||
### 方式二:设置页面
|
||||
|
||||
1. 打开「设置 → 高级 → 代理服务」
|
||||
2. 点击右上角的开关
|
||||
|
||||

|
||||
|
||||
## 代理配置
|
||||
|
||||
### 基础配置
|
||||
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| 监听地址 | 代理绑定的 IP 地址 | `127.0.0.1` |
|
||||
| 监听端口 | 代理监听的端口 | `15762` |
|
||||
| 启用日志 | 是否记录请求日志 | 开启 |
|
||||
|
||||
### 修改配置
|
||||
|
||||
1. **停止代理服务**(必须先停止)
|
||||
2. 修改监听地址或端口
|
||||
3. 点击「保存」
|
||||
4. 重新启动代理
|
||||
|
||||
> ⚠️ 修改地址/端口需要先停止代理服务
|
||||
|
||||
### 监听地址说明
|
||||
|
||||
| 地址 | 说明 |
|
||||
|------|------|
|
||||
| `127.0.0.1` | 仅本机可访问(推荐) |
|
||||
| `0.0.0.0` | 允许局域网访问 |
|
||||
|
||||
## 运行状态
|
||||
|
||||
代理运行时,面板显示以下信息:
|
||||
|
||||
### 服务地址
|
||||
|
||||
```
|
||||
http://127.0.0.1:15762
|
||||
```
|
||||
|
||||
点击「复制」按钮可复制地址。
|
||||
|
||||
### 当前供应商
|
||||
|
||||
显示各应用当前使用的供应商:
|
||||
|
||||
```
|
||||
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:15762"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Codex**:
|
||||
```toml
|
||||
base_url = "http://127.0.0.1:15762/v1"
|
||||
```
|
||||
|
||||
**Gemini**:
|
||||
```
|
||||
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15762
|
||||
```
|
||||
|
||||
## 停止代理
|
||||
|
||||
### 方式一:主界面开关
|
||||
|
||||
点击代理开关按钮关闭。
|
||||
|
||||
### 方式二:设置页面
|
||||
|
||||
在代理服务面板中关闭开关。
|
||||
|
||||
### 停止后的处理
|
||||
|
||||
停止代理时,CC Switch 会:
|
||||
|
||||
1. 恢复应用配置到原始状态
|
||||
2. 保存请求日志
|
||||
3. 关闭所有连接
|
||||
|
||||
## 日志记录
|
||||
|
||||
### 开启日志
|
||||
|
||||
在代理面板中开启「启用日志」开关。
|
||||
|
||||
### 日志内容
|
||||
|
||||
每条请求记录包含:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| 时间 | 请求时间 |
|
||||
| 应用 | Claude/Codex/Gemini |
|
||||
| 供应商 | 使用的供应商 |
|
||||
| 模型 | 请求的模型 |
|
||||
| Token | 输入/输出 token 数 |
|
||||
| 延迟 | 请求耗时 |
|
||||
| 状态 | 成功/失败 |
|
||||
|
||||
### 查看日志
|
||||
|
||||
在「设置 → 用量」Tab 中查看请求日志。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 端口被占用
|
||||
|
||||
错误信息:`Address already in use`
|
||||
|
||||
解决方法:
|
||||
1. 更换端口(如 5001)
|
||||
2. 或关闭占用端口的程序
|
||||
|
||||
### 代理启动失败
|
||||
|
||||
检查:
|
||||
- 端口是否被占用
|
||||
- 是否有足够权限
|
||||
- 防火墙是否阻止
|
||||
|
||||
### 请求超时
|
||||
|
||||
可能原因:
|
||||
- 网络问题
|
||||
- 供应商服务器问题
|
||||
- 代理配置错误
|
||||
|
||||
解决方法:
|
||||
- 检查网络连接
|
||||
- 尝试直接访问供应商 API
|
||||
- 检查供应商配置
|
||||
@@ -0,0 +1,195 @@
|
||||
# 4.2 应用接管
|
||||
|
||||
## 功能说明
|
||||
|
||||
应用接管是指让 CC Switch 代理接管特定应用的 API 请求。
|
||||
|
||||
开启接管后:
|
||||
- 应用的 API 请求会通过本地代理转发
|
||||
- 可以记录请求日志和统计用量
|
||||
- 可以使用故障转移功能
|
||||
|
||||
## 前提条件
|
||||
|
||||
使用应用接管功能前,需要先启动代理服务。
|
||||
|
||||
## 开启接管
|
||||
|
||||
### 操作位置
|
||||
|
||||
设置 → 高级 → 代理服务 → 应用接管区域
|
||||
|
||||
### 操作步骤
|
||||
|
||||
1. 确保代理服务已启动
|
||||
2. 找到「应用接管」区域
|
||||
3. 为需要的应用开启开关
|
||||
|
||||
### 接管开关
|
||||
|
||||
| 开关 | 作用 |
|
||||
|------|------|
|
||||
| Claude 接管 | 接管 Claude Code 的请求 |
|
||||
| Codex 接管 | 接管 Codex 的请求 |
|
||||
| Gemini 接管 | 接管 Gemini CLI 的请求 |
|
||||
|
||||
可以同时开启多个应用的接管。
|
||||
|
||||
## 接管原理
|
||||
|
||||
### 配置修改
|
||||
|
||||
开启接管后,CC Switch 会修改应用的配置文件,将 API 端点指向本地代理。
|
||||
|
||||
**Claude 配置变更**:
|
||||
|
||||
```json
|
||||
// 接管前
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||
}
|
||||
}
|
||||
|
||||
// 接管后
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Codex 配置变更**:
|
||||
|
||||
```toml
|
||||
# 接管前
|
||||
base_url = "https://api.openai.com/v1"
|
||||
|
||||
# 接管后
|
||||
base_url = "http://127.0.0.1:5000/v1"
|
||||
```
|
||||
|
||||
**Gemini 配置变更**:
|
||||
|
||||
```bash
|
||||
# 接管前
|
||||
GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
|
||||
|
||||
# 接管后
|
||||
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:5000
|
||||
```
|
||||
|
||||
### 请求转发
|
||||
|
||||
代理收到请求后:
|
||||
|
||||
1. 识别请求来源(Claude/Codex/Gemini)
|
||||
2. 查找该应用当前启用的供应商
|
||||
3. 将请求转发到供应商的实际端点
|
||||
4. 记录请求日志
|
||||
5. 返回响应给应用
|
||||
|
||||
## 接管状态指示
|
||||
|
||||
### 主界面指示
|
||||
|
||||
开启接管后,主界面会有以下变化:
|
||||
|
||||
- **代理 Logo 颜色**:从无色变为绿色
|
||||
- **供应商卡片**:当前活跃的供应商显示绿色边框
|
||||
|
||||
### 供应商卡片状态
|
||||
|
||||
| 状态 | 边框颜色 | 说明 |
|
||||
|------|----------|------|
|
||||
| 当前启用 | 蓝色 | 配置文件中的供应商(非代理模式) |
|
||||
| 代理活跃 | 绿色 | 代理实际使用的供应商 |
|
||||
| 普通 | 默认 | 未使用的供应商 |
|
||||
|
||||
## 关闭接管
|
||||
|
||||
### 操作步骤
|
||||
|
||||
1. 在代理面板中关闭对应应用的接管开关
|
||||
2. 或直接停止代理服务
|
||||
|
||||
### 配置恢复
|
||||
|
||||
关闭接管时,CC Switch 会:
|
||||
|
||||
1. 将应用配置恢复到接管前的状态
|
||||
2. 保存当前的请求日志
|
||||
|
||||
## 接管与供应商切换
|
||||
|
||||
### 接管模式下切换供应商
|
||||
|
||||
在接管模式下切换供应商:
|
||||
|
||||
1. 在主界面点击供应商的「启用」按钮
|
||||
2. 代理立即使用新供应商转发请求
|
||||
3. **无需重启 CLI 工具**
|
||||
|
||||
这是接管模式的一大优势:切换供应商即时生效。
|
||||
|
||||
### 非接管模式下切换
|
||||
|
||||
在非接管模式下切换供应商:
|
||||
|
||||
1. 修改配置文件
|
||||
2. 需要重启 CLI 工具才能生效
|
||||
|
||||
## 多应用接管
|
||||
|
||||
可以同时接管多个应用,每个应用独立管理:
|
||||
|
||||
- 独立的供应商配置
|
||||
- 独立的故障转移队列
|
||||
- 独立的请求统计
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 场景一:用量监控
|
||||
|
||||
开启接管 + 日志记录,监控 API 使用情况。
|
||||
|
||||
### 场景二:快速切换
|
||||
|
||||
开启接管后,切换供应商无需重启 CLI 工具。
|
||||
|
||||
### 场景三:故障转移
|
||||
|
||||
开启接管是使用故障转移功能的前提。
|
||||
|
||||
## 注意事项
|
||||
|
||||
### 性能影响
|
||||
|
||||
代理会增加少量延迟(通常 < 10ms),对于大多数场景可以忽略。
|
||||
|
||||
### 网络要求
|
||||
|
||||
接管模式下,CLI 工具需要能够访问本地代理地址。
|
||||
|
||||
### 配置备份
|
||||
|
||||
开启接管前,CC Switch 会备份原始配置,关闭时恢复。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 接管后请求失败
|
||||
|
||||
检查:
|
||||
- 代理服务是否正常运行
|
||||
- 供应商配置是否正确
|
||||
- 网络是否正常
|
||||
|
||||
### 关闭接管后配置未恢复
|
||||
|
||||
可能原因:
|
||||
- 代理异常退出
|
||||
- 配置文件被其他程序修改
|
||||
|
||||
解决方法:
|
||||
- 手动编辑供应商,重新保存
|
||||
- 或重新启用再关闭接管
|
||||
@@ -0,0 +1,226 @@
|
||||
# 4.3 故障转移
|
||||
|
||||
## 功能说明
|
||||
|
||||
故障转移功能在主供应商请求失败时,自动切换到备用供应商,确保服务不中断。
|
||||
|
||||
**适用场景**:
|
||||
- 供应商服务不稳定
|
||||
- 需要高可用性
|
||||
- 长时间运行的任务
|
||||
|
||||
## 前提条件
|
||||
|
||||
使用故障转移功能需要:
|
||||
|
||||
1. ✅ 启动代理服务
|
||||
2. ✅ 开启应用接管
|
||||
3. ✅ 配置故障转移队列
|
||||
4. ✅ 开启自动故障转移
|
||||
|
||||
## 配置故障转移队列
|
||||
|
||||
### 打开配置页面
|
||||
|
||||
设置 → 高级 → 故障转移
|
||||
|
||||
### 选择应用
|
||||
|
||||
页面顶部有三个 Tab:
|
||||
- Claude
|
||||
- Codex
|
||||
- Gemini
|
||||
|
||||
选择要配置的应用。
|
||||
|
||||
### 添加备用供应商
|
||||
|
||||
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[返回错误]
|
||||
```
|
||||
|
||||
## 熔断器配置
|
||||
|
||||
熔断器防止频繁重试失败的供应商。
|
||||
|
||||
### 配置项
|
||||
|
||||
| 配置 | 说明 | 默认值 | 范围 |
|
||||
|------|------|--------|------|
|
||||
| 失败阈值 | 连续失败多少次触发熔断 | 5 | 1-20 |
|
||||
| 恢复成功阈值 | 半开状态下成功多少次后关闭熔断器 | 2 | 1-10 |
|
||||
| 恢复等待时间 | 熔断后多久尝试恢复(秒) | 60 | 10-300 |
|
||||
| 错误率阈值 | 错误率超过此值时打开熔断器 | 50% | 0-100% |
|
||||
| 最小请求数 | 计算错误率前的最小请求数 | 10 | 5-100 |
|
||||
|
||||
### 超时配置
|
||||
|
||||
| 配置 | 说明 | 默认值 | 范围 |
|
||||
|------|------|--------|------|
|
||||
| 流式首字节超时 | 等待首个数据块的最大时间(秒) | 30 | 0-180 |
|
||||
| 流式静默超时 | 数据块之间的最大间隔(秒) | 60 | 0-600 |
|
||||
| 非流式超时 | 非流式请求的总超时时间(秒) | 300 | 0-1800 |
|
||||
|
||||
### 重试配置
|
||||
|
||||
| 配置 | 说明 | 默认值 | 范围 |
|
||||
|------|------|--------|------|
|
||||
| 最大重试次数 | 请求失败时的重试次数 | 3 | 0-10 |
|
||||
|
||||
### 熔断状态
|
||||
|
||||
| 状态 | 说明 |
|
||||
|------|------|
|
||||
| 关闭 | 正常状态,允许请求 |
|
||||
| 开启 | 熔断状态,跳过此供应商 |
|
||||
| 半开 | 尝试恢复,发送试探请求 |
|
||||
|
||||
### 状态转换
|
||||
|
||||
```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. 重置熔断状态
|
||||
@@ -0,0 +1,290 @@
|
||||
# 4.4 用量统计
|
||||
|
||||
## 功能说明
|
||||
|
||||
用量统计功能记录和分析 API 请求数据,帮助你:
|
||||
|
||||
- 了解 API 使用情况
|
||||
- 估算费用支出
|
||||
- 分析使用模式
|
||||
- 排查问题
|
||||
|
||||
## 前提条件
|
||||
|
||||
使用用量统计功能需要:
|
||||
|
||||
1. ✅ 启动代理服务
|
||||
2. ✅ 开启应用接管
|
||||
3. ✅ 开启日志记录
|
||||
|
||||
## 打开用量统计
|
||||
|
||||
设置 → 用量 Tab
|
||||
|
||||
## 统计概览
|
||||
|
||||
### 汇总卡片
|
||||
|
||||
页面顶部显示关键指标:
|
||||
|
||||
| 指标 | 说明 |
|
||||
|------|------|
|
||||
| 总请求数 | 统计周期内的请求总数 |
|
||||
| 总 Token | 输入 + 输出 Token 总数 |
|
||||
| 估算费用 | 基于定价配置计算的费用 |
|
||||
| 成功率 | 成功请求的百分比 |
|
||||
|
||||
### 时间范围
|
||||
|
||||
可选择统计的时间范围:
|
||||
|
||||
| 选项 | 范围 |
|
||||
|------|------|
|
||||
| 今日 | 当天 00:00 至今 |
|
||||
| 最近 7 天 | 过去 7 天 |
|
||||
| 最近 30 天 | 过去 30 天 |
|
||||
|
||||

|
||||
|
||||
## 趋势图表
|
||||
|
||||
### 请求趋势
|
||||
|
||||
折线图展示请求数量的变化趋势:
|
||||
|
||||
- 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 天**:按天显示
|
||||
|
||||
|
||||
|
||||

|
||||
|
||||
## 详细数据
|
||||
|
||||
页面下方有三个数据 Tab:
|
||||
|
||||
### 请求日志
|
||||
|
||||
每条请求的详细记录:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| 时间 | 请求时间 |
|
||||
| 供应商 | 使用的供应商名称 |
|
||||
| 模型 | 请求的模型(计费模型) |
|
||||
| 输入 Token | 输入的 Token 数 |
|
||||
| 输出 Token | 输出的 Token 数 |
|
||||
| 缓存读取 | 缓存命中的 Token 数 |
|
||||
| 缓存创建 | 缓存创建的 Token 数 |
|
||||
| 总费用 | 估算费用(美元) |
|
||||
| 耗时信息 | 请求耗时、首 Token 时间、流式/非流式 |
|
||||
| 状态 | HTTP 状态码 |
|
||||
|
||||
#### 耗时信息说明
|
||||
|
||||
耗时信息列显示多个徽章:
|
||||
|
||||
| 徽章 | 说明 | 颜色规则 |
|
||||
|------|------|----------|
|
||||
| 总耗时 | 请求总时长(秒) | ≤5s 绿色,≤120s 橙色,>120s 红色 |
|
||||
| 首 Token | 流式请求首个 Token 时间 | ≤5s 绿色,≤120s 橙色,>120s 红色 |
|
||||
| 流式/非流式 | 请求类型 | 流式蓝色,非流式紫色 |
|
||||
|
||||
#### 查看详情
|
||||
|
||||
点击请求行可查看详细信息:
|
||||
|
||||
- 完整的请求参数
|
||||
- 响应内容摘要
|
||||
- 错误信息(如果失败)
|
||||
|
||||
#### 筛选日志
|
||||
|
||||
支持按以下条件筛选:
|
||||
|
||||
| 筛选项 | 选项 |
|
||||
|--------|------|
|
||||
| 应用类型 | 全部 / Claude / Codex / Gemini |
|
||||
| 状态码 | 全部 / 200 / 400 / 401 / 429 / 500 |
|
||||
| 供应商 | 文本搜索 |
|
||||
| 模型 | 文本搜索 |
|
||||
| 时间范围 | 开始时间 - 结束时间(日期时间选择器) |
|
||||
|
||||
操作按钮:
|
||||
- **搜索**:应用筛选条件
|
||||
- **重置**:恢复默认(过去 24 小时)
|
||||
- **刷新**:重新加载数据
|
||||
|
||||

|
||||
|
||||
### 供应商统计
|
||||
|
||||
按供应商分组的统计数据:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| 供应商 | 供应商名称 |
|
||||
| 请求数 | 该供应商的请求总数 |
|
||||
| 成功数 | 成功的请求数 |
|
||||
| 失败数 | 失败的请求数 |
|
||||
| 成功率 | 成功百分比 |
|
||||
| 总 Token | Token 使用总量 |
|
||||
| 估算费用 | 该供应商的费用 |
|
||||
|
||||

|
||||
|
||||
### 模型统计
|
||||
|
||||
按模型分组的统计数据:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| 模型 | 模型名称 |
|
||||
| 请求数 | 该模型的请求总数 |
|
||||
| 输入 Token | 输入 Token 总量 |
|
||||
| 输出 Token | 输出 Token 总量 |
|
||||
| 平均延迟 | 平均响应时间 |
|
||||
| 估算费用 | 该模型的费用 |
|
||||
|
||||

|
||||
|
||||
## 定价配置
|
||||
|
||||
### 打开定价配置
|
||||
|
||||
设置 → 高级 → 定价配置
|
||||
|
||||
### 配置模型价格
|
||||
|
||||
为每个模型设置价格(每百万 Token):
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| 模型 ID | 模型标识符(如 claude-3-sonnet) |
|
||||
| 显示名称 | 自定义显示名称 |
|
||||
| 输入价格 | 每百万输入 Token 的价格 |
|
||||
| 输出价格 | 每百万输出 Token 的价格 |
|
||||
| 缓存读取价格 | 每百万缓存命中 Token 的价格 |
|
||||
| 缓存创建价格 | 每百万缓存创建 Token 的价格 |
|
||||
|
||||
### 操作
|
||||
|
||||
- **添加**:点击「添加」按钮新增模型定价
|
||||
- **编辑**:点击行末的编辑图标修改
|
||||
- **删除**:点击行末的删除图标移除
|
||||
|
||||

|
||||
|
||||
### 预设价格
|
||||
|
||||
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-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 数,可能与供应商的计算方式略有差异。以供应商账单为准。
|
||||
@@ -0,0 +1,156 @@
|
||||
# 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 配额
|
||||
- 建议使用低成本模型进行测试
|
||||
- 测试频率不宜过高,避免浪费配额
|
||||
- 不同供应商支持的模型可能不同
|
||||
@@ -0,0 +1,257 @@
|
||||
# 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 | 供应商配置 |
|
||||
| mcp_servers | MCP 服务器配置 |
|
||||
| prompts | 提示词预设 |
|
||||
| skills | 技能安装状态 |
|
||||
| usage_logs | 用量日志 |
|
||||
|
||||
### 设备设置
|
||||
|
||||
`settings.json` 存储设备级设置:
|
||||
|
||||
```json
|
||||
{
|
||||
"language": "zh",
|
||||
"theme": "system",
|
||||
"windowBehavior": "minimize",
|
||||
"autoStart": false,
|
||||
"claudeConfigDir": null,
|
||||
"codexConfigDir": null,
|
||||
"geminiConfigDir": 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
|
||||
{
|
||||
"authMode": "api_key",
|
||||
"mcpServers": {
|
||||
"mcp-fetch": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-fetch"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `authMode` | 认证模式:`api_key` 或 `oauth` |
|
||||
| `mcpServers` | MCP 服务器配置 |
|
||||
|
||||
## 配置优先级
|
||||
|
||||
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.8.0 从 JSON 文件迁移到 SQLite:
|
||||
|
||||
- 首次启动自动迁移
|
||||
- 迁移成功后显示提示
|
||||
- 旧配置文件保留作为备份
|
||||
|
||||
### 跨设备迁移
|
||||
|
||||
1. 在源设备导出配置
|
||||
2. 在目标设备导入配置
|
||||
3. 或使用云同步功能
|
||||
|
||||
## 配置备份建议
|
||||
|
||||
### 定期备份
|
||||
|
||||
建议定期导出配置:
|
||||
|
||||
1. 设置 → 高级 → 数据管理
|
||||
2. 点击「导出」
|
||||
3. 保存到安全位置
|
||||
|
||||
### 备份内容
|
||||
|
||||
导出文件包含:
|
||||
|
||||
- 所有供应商配置
|
||||
- MCP 服务器配置
|
||||
- Prompts 预设
|
||||
- 应用设置
|
||||
|
||||
### 不包含的内容
|
||||
|
||||
- 用量日志(数据量大)
|
||||
- 设备级设置(不适合跨设备)
|
||||
@@ -0,0 +1,220 @@
|
||||
# 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\`
|
||||
@@ -0,0 +1,311 @@
|
||||
# 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. 分享给他人或在其他设备使用
|
||||
|
||||
## 协议格式
|
||||
|
||||
CC Switch 支持两种协议格式:
|
||||
|
||||
### V1 协议(推荐)
|
||||
|
||||
使用 URL 参数格式,更易读和生成:
|
||||
|
||||
```
|
||||
ccswitch://v1/import?resource={type}&app={app}&name={name}&...
|
||||
```
|
||||
|
||||
**通用参数**:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `resource` | 是 | 资源类型:`provider` / `mcp` / `prompt` |
|
||||
| `app` | 是 | 应用类型:`claude` / `codex` / `gemini` |
|
||||
| `name` | 是 | 名称 |
|
||||
|
||||
**供应商参数**(resource=provider):
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `endpoint` | 是 | API 端点地址 |
|
||||
| `apiKey` | 是 | API 密钥 |
|
||||
| `homepage` | 否 | 供应商官网 |
|
||||
| `model` | 否 | 默认模型 |
|
||||
| `haikuModel` | 否 | Haiku 模型(仅 Claude) |
|
||||
| `sonnetModel` | 否 | Sonnet 模型(仅 Claude) |
|
||||
| `opusModel` | 否 | Opus 模型(仅 Claude) |
|
||||
| `notes` | 否 | 备注 |
|
||||
| `usageScript` | 否 | 用量查询脚本 |
|
||||
| `usageEnabled` | 否 | 是否启用用量查询(默认 true) |
|
||||
| `usageApiKey` | 否 | 用量查询专用 API Key |
|
||||
| `usageBaseUrl` | 否 | 用量查询专用地址 |
|
||||
| `usageAutoInterval` | 否 | 自动查询间隔(分钟) |
|
||||
|
||||
**示例**:
|
||||
```
|
||||
ccswitch://v1/import?resource=provider&app=claude&name=My%20Provider&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-xxx
|
||||
```
|
||||
|
||||
### Legacy 协议
|
||||
|
||||
使用 Base64 编码的 JSON 数据:
|
||||
|
||||
```
|
||||
ccswitch://import/{type}?data={base64_encoded_data}
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `type` | 导入类型:`provider` / `mcp` / `prompt` / `skill` |
|
||||
| `data` | Base64 编码的配置数据 |
|
||||
|
||||
## 导入类型
|
||||
|
||||
### 导入供应商
|
||||
|
||||
```
|
||||
ccswitch://import/provider?data=<base64>
|
||||
```
|
||||
|
||||
数据结构:
|
||||
```json
|
||||
{
|
||||
"name": "供应商名称",
|
||||
"appId": "claude",
|
||||
"settingsConfig": {
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "sk-xxx",
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||
}
|
||||
},
|
||||
"websiteUrl": "https://example.com",
|
||||
"icon": "cloud",
|
||||
"iconColor": "#3b82f6"
|
||||
}
|
||||
```
|
||||
|
||||
### 导入 MCP 服务器
|
||||
|
||||
```
|
||||
ccswitch://import/mcp?data=<base64>
|
||||
```
|
||||
|
||||
数据结构:
|
||||
```json
|
||||
{
|
||||
"id": "mcp-fetch",
|
||||
"name": "HTTP Fetch",
|
||||
"description": "HTTP 请求工具",
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-fetch"],
|
||||
"transportType": "stdio",
|
||||
"apps": {
|
||||
"claude": true,
|
||||
"codex": true,
|
||||
"gemini": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 导入 Prompt 预设
|
||||
|
||||
```
|
||||
ccswitch://import/prompt?data=<base64>
|
||||
```
|
||||
|
||||
数据结构:
|
||||
```json
|
||||
{
|
||||
"name": "代码审查专家",
|
||||
"content": "# 角色\n\n你是一个专业的代码审查专家...",
|
||||
"app": "claude"
|
||||
}
|
||||
```
|
||||
|
||||
### 导入 Skill
|
||||
|
||||
```
|
||||
ccswitch://import/skill?data=<base64>
|
||||
```
|
||||
|
||||
数据结构:
|
||||
```json
|
||||
{
|
||||
"name": "skill-name",
|
||||
"repoOwner": "owner",
|
||||
"repoName": "repo",
|
||||
"repoBranch": "main",
|
||||
"directory": "skills/skill-name"
|
||||
}
|
||||
```
|
||||
|
||||
## 生成深度链接
|
||||
|
||||
### 手动生成
|
||||
|
||||
1. 准备配置数据(JSON 格式)
|
||||
2. 将 JSON 转换为 Base64 编码
|
||||
3. 拼接成完整 URL
|
||||
|
||||
**示例**:
|
||||
|
||||
```javascript
|
||||
const config = {
|
||||
name: "My Provider",
|
||||
appId: "claude",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: "sk-xxx"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const base64 = btoa(JSON.stringify(config));
|
||||
const url = `ccswitch://import/provider?data=${base64}`;
|
||||
```
|
||||
|
||||
### 在线工具
|
||||
|
||||
可以使用在线 Base64 编码工具:
|
||||
- https://www.base64encode.org/
|
||||
|
||||
## 使用深度链接
|
||||
|
||||
### 点击链接
|
||||
|
||||
在浏览器或其他应用中点击深度链接:
|
||||
|
||||
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://import/provider?data=eyJuYW1lIjoiVGVzdCBQcm92aWRlciIsImFwcElkIjoiY2xhdWRlIiwic2V0dGluZ3NDb25maWciOnsiZW52Ijp7IkFOVEhST1BJQ19BUElfS0VZIjoic2steHh4In19fQ==
|
||||
```
|
||||
|
||||
解码后的数据:
|
||||
```json
|
||||
{
|
||||
"name": "Test Provider",
|
||||
"appId": "claude",
|
||||
"settingsConfig": {
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "sk-xxx"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 示例:导入 MCP 服务器
|
||||
|
||||
```
|
||||
ccswitch://import/mcp?data=eyJpZCI6Im1jcC1mZXRjaCIsIm5hbWUiOiJIVFRQIEZldGNoIiwiY29tbWFuZCI6InV2eCIsImFyZ3MiOlsibWNwLXNlcnZlci1mZXRjaCJdLCJ0cmFuc3BvcnRUeXBlIjoic3RkaW8iLCJhcHBzIjp7ImNsYXVkZSI6dHJ1ZSwiY29kZXgiOnRydWUsImdlbWluaSI6dHJ1ZX19
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 链接无法打开
|
||||
|
||||
**检查**:
|
||||
1. CC Switch 是否已安装
|
||||
2. 协议是否正确注册
|
||||
3. 链接格式是否正确
|
||||
|
||||
### 导入失败
|
||||
|
||||
**可能原因**:
|
||||
- Base64 编码错误
|
||||
- JSON 格式错误
|
||||
- 缺少必填字段
|
||||
|
||||
**解决方法**:
|
||||
1. 检查原始 JSON 格式
|
||||
2. 重新进行 Base64 编码
|
||||
3. 确保所有必填字段都存在
|
||||
@@ -0,0 +1,108 @@
|
||||
# 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. 手动恢复变量到系统环境
|
||||
@@ -0,0 +1,111 @@
|
||||
# CC Switch 用户手册
|
||||
|
||||
> Claude Code / Codex / Gemini CLI 全方位辅助工具
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
📚 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.9.1
|
||||
- 最后更新:2025-12-30
|
||||
- 适用于 CC Switch v3.9.0+
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 或 PR 改进文档:
|
||||
|
||||
- [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
|
||||
- [GitHub Repository](https://github.com/farion1231/cc-switch)
|
||||
|
After Width: | Height: | Size: 395 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 425 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 611 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 415 KiB |
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 218 KiB |
@@ -22,59 +22,6 @@ finish-args:
|
||||
- --filesystem=home
|
||||
|
||||
modules:
|
||||
# Required for libdbusmenu build (intltool was removed from GNOME SDK since 2019)
|
||||
- name: intltool
|
||||
cleanup:
|
||||
- "*"
|
||||
sources:
|
||||
- type: archive
|
||||
url: https://launchpad.net/intltool/trunk/0.51.0/+download/intltool-0.51.0.tar.gz
|
||||
sha256: 67c74d94196b153b774ab9f89b2fa6c6ba79352407037c8c14d5aeb334e959cd
|
||||
|
||||
# Required for tray icon support
|
||||
- name: libayatana-ido
|
||||
buildsystem: cmake-ninja
|
||||
config-opts:
|
||||
- -DENABLE_TESTS=NO
|
||||
sources:
|
||||
- type: git
|
||||
url: https://github.com/AyatanaIndicators/ayatana-ido.git
|
||||
tag: 0.10.4
|
||||
|
||||
- name: libdbusmenu-gtk3
|
||||
buildsystem: autotools
|
||||
build-options:
|
||||
cflags: -Wno-error
|
||||
config-opts:
|
||||
- --with-gtk=3
|
||||
- --disable-dumper
|
||||
- --disable-static
|
||||
- --disable-nls
|
||||
sources:
|
||||
- type: archive
|
||||
url: https://launchpad.net/libdbusmenu/16.04/16.04.0/+download/libdbusmenu-16.04.0.tar.gz
|
||||
sha256: b9cc4a2acd74509435892823607d966d424bd9ad5d0b00938f27240a1bfa878a
|
||||
|
||||
- name: libayatana-indicator
|
||||
buildsystem: cmake-ninja
|
||||
config-opts:
|
||||
- -DENABLE_TESTS=NO
|
||||
- -DENABLE_IDO=YES
|
||||
sources:
|
||||
- type: git
|
||||
url: https://github.com/AyatanaIndicators/libayatana-indicator.git
|
||||
tag: 0.9.4
|
||||
|
||||
- name: libayatana-appindicator
|
||||
buildsystem: cmake-ninja
|
||||
config-opts:
|
||||
- -DENABLE_BINDINGS_MONO=NO
|
||||
- -DENABLE_BINDINGS_VALA=NO
|
||||
sources:
|
||||
- type: git
|
||||
url: https://github.com/AyatanaIndicators/libayatana-appindicator.git
|
||||
tag: 0.5.93
|
||||
|
||||
- name: cc-switch
|
||||
buildsystem: simple
|
||||
sources:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.9.1",
|
||||
"version": "3.9.0-3",
|
||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -87,4 +87,4 @@
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
}
|
||||
@@ -701,7 +701,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.9.1"
|
||||
version = "3.9.0-3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.9.1"
|
||||
version = "3.9.0-3"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
@@ -37,7 +37,7 @@ tauri-plugin-deep-link = "2"
|
||||
dirs = "5.0"
|
||||
toml = "0.8"
|
||||
toml_edit = "0.22"
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
||||
futures = "0.3"
|
||||
async-stream = "0.3"
|
||||
@@ -77,8 +77,7 @@ objc2-app-kit = { version = "0.2", features = ["NSColor"] }
|
||||
codegen-units = 1
|
||||
lto = "thin"
|
||||
opt-level = "s"
|
||||
# 使用 unwind 以便 panic hook 能捕获 backtrace(abort 会直接终止无法捕获)
|
||||
panic = "unwind"
|
||||
panic = "abort"
|
||||
strip = "symbols"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -13,8 +13,6 @@ pub struct McpApps {
|
||||
pub codex: bool,
|
||||
#[serde(default)]
|
||||
pub gemini: bool,
|
||||
#[serde(default)]
|
||||
pub opencode: bool,
|
||||
}
|
||||
|
||||
impl McpApps {
|
||||
@@ -24,7 +22,6 @@ impl McpApps {
|
||||
AppType::Claude => self.claude,
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::OpenCode => self.opencode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +31,6 @@ impl McpApps {
|
||||
AppType::Claude => self.claude = enabled,
|
||||
AppType::Codex => self.codex = enabled,
|
||||
AppType::Gemini => self.gemini = enabled,
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,15 +46,12 @@ impl McpApps {
|
||||
if self.gemini {
|
||||
apps.push(AppType::Gemini);
|
||||
}
|
||||
if self.opencode {
|
||||
apps.push(AppType::OpenCode);
|
||||
}
|
||||
apps
|
||||
}
|
||||
|
||||
/// 检查是否所有应用都未启用
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.claude && !self.codex && !self.gemini && !self.opencode
|
||||
!self.claude && !self.codex && !self.gemini
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,8 +64,6 @@ pub struct SkillApps {
|
||||
pub codex: bool,
|
||||
#[serde(default)]
|
||||
pub gemini: bool,
|
||||
#[serde(default)]
|
||||
pub opencode: bool,
|
||||
}
|
||||
|
||||
impl SkillApps {
|
||||
@@ -82,7 +73,6 @@ impl SkillApps {
|
||||
AppType::Claude => self.claude,
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::OpenCode => self.opencode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +82,6 @@ impl SkillApps {
|
||||
AppType::Claude => self.claude = enabled,
|
||||
AppType::Codex => self.codex = enabled,
|
||||
AppType::Gemini => self.gemini = enabled,
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,15 +97,12 @@ impl SkillApps {
|
||||
if self.gemini {
|
||||
apps.push(AppType::Gemini);
|
||||
}
|
||||
if self.opencode {
|
||||
apps.push(AppType::OpenCode);
|
||||
}
|
||||
apps
|
||||
}
|
||||
|
||||
/// 检查是否所有应用都未启用
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.claude && !self.codex && !self.gemini && !self.opencode
|
||||
!self.claude && !self.codex && !self.gemini
|
||||
}
|
||||
|
||||
/// 仅启用指定应用(其他应用设为禁用)
|
||||
@@ -219,9 +205,6 @@ pub struct McpRoot {
|
||||
pub codex: McpConfig,
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub gemini: McpConfig,
|
||||
/// OpenCode MCP 配置(v4.0.0+,实际使用 opencode.json)
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub opencode: McpConfig,
|
||||
}
|
||||
|
||||
impl Default for McpRoot {
|
||||
@@ -233,7 +216,6 @@ impl Default for McpRoot {
|
||||
claude: McpConfig::default(),
|
||||
codex: McpConfig::default(),
|
||||
gemini: McpConfig::default(),
|
||||
opencode: McpConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,8 +236,6 @@ pub struct PromptRoot {
|
||||
pub codex: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub gemini: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub opencode: PromptConfig,
|
||||
}
|
||||
|
||||
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
||||
@@ -269,8 +249,7 @@ use crate::provider::ProviderManager;
|
||||
pub enum AppType {
|
||||
Claude,
|
||||
Codex,
|
||||
Gemini,
|
||||
OpenCode,
|
||||
Gemini, // 新增
|
||||
}
|
||||
|
||||
impl AppType {
|
||||
@@ -278,8 +257,7 @@ impl AppType {
|
||||
match self {
|
||||
AppType::Claude => "claude",
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini",
|
||||
AppType::OpenCode => "opencode",
|
||||
AppType::Gemini => "gemini", // 新增
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -292,12 +270,11 @@ impl FromStr for AppType {
|
||||
match normalized.as_str() {
|
||||
"claude" => Ok(AppType::Claude),
|
||||
"codex" => Ok(AppType::Codex),
|
||||
"gemini" => Ok(AppType::Gemini),
|
||||
"opencode" => Ok(AppType::OpenCode),
|
||||
"gemini" => Ok(AppType::Gemini), // 新增
|
||||
other => Err(AppError::localized(
|
||||
"unsupported_app",
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode."),
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini."),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -314,9 +291,6 @@ pub struct CommonConfigSnippets {
|
||||
|
||||
#[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 {
|
||||
@@ -326,7 +300,6 @@ impl CommonConfigSnippets {
|
||||
AppType::Claude => self.claude.as_ref(),
|
||||
AppType::Codex => self.codex.as_ref(),
|
||||
AppType::Gemini => self.gemini.as_ref(),
|
||||
AppType::OpenCode => self.opencode.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,7 +309,6 @@ impl CommonConfigSnippets {
|
||||
AppType::Claude => self.claude = snippet,
|
||||
AppType::Codex => self.codex = snippet,
|
||||
AppType::Gemini => self.gemini = snippet,
|
||||
AppType::OpenCode => self.opencode = snippet,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -375,8 +347,7 @@ impl Default for MultiAppConfig {
|
||||
let mut apps = HashMap::new();
|
||||
apps.insert("claude".to_string(), ProviderManager::default());
|
||||
apps.insert("codex".to_string(), ProviderManager::default());
|
||||
apps.insert("gemini".to_string(), ProviderManager::default());
|
||||
apps.insert("opencode".to_string(), ProviderManager::default());
|
||||
apps.insert("gemini".to_string(), ProviderManager::default()); // 新增
|
||||
|
||||
Self {
|
||||
version: 2,
|
||||
@@ -535,7 +506,6 @@ impl MultiAppConfig {
|
||||
AppType::Claude => &self.mcp.claude,
|
||||
AppType::Codex => &self.mcp.codex,
|
||||
AppType::Gemini => &self.mcp.gemini,
|
||||
AppType::OpenCode => &self.mcp.opencode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,7 +515,6 @@ impl MultiAppConfig {
|
||||
AppType::Claude => &mut self.mcp.claude,
|
||||
AppType::Codex => &mut self.mcp.codex,
|
||||
AppType::Gemini => &mut self.mcp.gemini,
|
||||
AppType::OpenCode => &mut self.mcp.opencode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,7 +528,6 @@ impl MultiAppConfig {
|
||||
Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
|
||||
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)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
@@ -579,7 +547,6 @@ impl MultiAppConfig {
|
||||
if !self.prompts.claude.prompts.is_empty()
|
||||
|| !self.prompts.codex.prompts.is_empty()
|
||||
|| !self.prompts.gemini.prompts.is_empty()
|
||||
|| !self.prompts.opencode.prompts.is_empty()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -587,12 +554,7 @@ impl MultiAppConfig {
|
||||
log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
|
||||
|
||||
let mut imported = false;
|
||||
for app in [
|
||||
AppType::Claude,
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::OpenCode,
|
||||
] {
|
||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||
// 复用已有的单应用导入逻辑
|
||||
if Self::auto_import_prompt_if_exists(self, app)? {
|
||||
imported = true;
|
||||
@@ -661,7 +623,6 @@ impl MultiAppConfig {
|
||||
AppType::Claude => &mut config.prompts.claude.prompts,
|
||||
AppType::Codex => &mut config.prompts.codex.prompts,
|
||||
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
||||
AppType::OpenCode => &mut config.prompts.opencode.prompts,
|
||||
};
|
||||
|
||||
prompts.insert(id, prompt);
|
||||
@@ -695,7 +656,6 @@ impl MultiAppConfig {
|
||||
AppType::Claude => &self.mcp.claude.servers,
|
||||
AppType::Codex => &self.mcp.codex.servers,
|
||||
AppType::Gemini => &self.mcp.gemini.servers,
|
||||
AppType::OpenCode => &self.mcp.opencode.servers,
|
||||
};
|
||||
|
||||
for (id, entry) in old_servers {
|
||||
|
||||
@@ -65,30 +65,6 @@ fn wrap_command_for_windows(_obj: &mut Map<String, Value>) {
|
||||
// 非 Windows 平台不做任何处理
|
||||
}
|
||||
|
||||
/// 检测路径是否为 WSL 网络路径(如 \\wsl$\Ubuntu\... 或 \\wsl.localhost\Ubuntu\...)
|
||||
/// WSL 环境运行的是 Linux,不需要 cmd /c 包装
|
||||
/// 注意:仅检测直接 UNC 路径,映射磁盘符(如 Z: -> \\wsl$\...)无法检测
|
||||
#[cfg(windows)]
|
||||
fn is_wsl_path(path: &Path) -> bool {
|
||||
use std::path::{Component, Prefix};
|
||||
if let Some(Component::Prefix(prefix)) = path.components().next() {
|
||||
match prefix.kind() {
|
||||
Prefix::UNC(server, _) | Prefix::VerbatimUNC(server, _) => {
|
||||
let s = server.to_string_lossy();
|
||||
s.eq_ignore_ascii_case("wsl$") || s.eq_ignore_ascii_case("wsl.localhost")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn is_wsl_path(_path: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpStatus {
|
||||
@@ -395,11 +371,6 @@ pub fn set_mcp_servers_map(
|
||||
};
|
||||
|
||||
// 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范
|
||||
// 检测目标路径是否为 WSL,若是则跳过 cmd /c 包装
|
||||
let is_wsl_target = is_wsl_path(&path);
|
||||
if is_wsl_target {
|
||||
log::info!("检测到 WSL 路径,跳过 cmd /c 包装: {}", path.display());
|
||||
}
|
||||
let mut out: Map<String, Value> = Map::new();
|
||||
for (id, spec) in servers.iter() {
|
||||
let mut obj = if let Some(map) = spec.as_object() {
|
||||
@@ -426,10 +397,8 @@ pub fn set_mcp_servers_map(
|
||||
obj.remove("homepage");
|
||||
obj.remove("docs");
|
||||
|
||||
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式(WSL 路径除外)
|
||||
if !is_wsl_target {
|
||||
wrap_command_for_windows(&mut obj);
|
||||
}
|
||||
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式
|
||||
wrap_command_for_windows(&mut obj);
|
||||
|
||||
out.insert(id.clone(), Value::Object(obj));
|
||||
}
|
||||
@@ -576,68 +545,4 @@ mod tests {
|
||||
assert_eq!(obj["args"], json!(["/c", "NPX", "-y", "foo"]));
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 WSL 路径检测功能
|
||||
#[test]
|
||||
fn test_is_wsl_path_wsl_dollar() {
|
||||
// wsl$ 格式 - 各种发行版
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\Ubuntu\home\user\.claude")));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\Debian\home\user\.claude")));
|
||||
assert!(is_wsl_path(Path::new(
|
||||
r"\\wsl$\openSUSE-Leap-15.2\home\user"
|
||||
)));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\kali-linux\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\Arch\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\Alpine\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl$\Fedora\home\user")));
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// 非 Windows 平台始终返回 false
|
||||
assert!(!is_wsl_path(Path::new(r"\\wsl$\Ubuntu\home\user\.claude")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_wsl_path_wsl_localhost() {
|
||||
// wsl.localhost 格式
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert!(is_wsl_path(Path::new(
|
||||
r"\\wsl.localhost\Ubuntu\home\user\.claude"
|
||||
)));
|
||||
assert!(is_wsl_path(Path::new(r"\\wsl.localhost\Debian\home\user")));
|
||||
assert!(is_wsl_path(Path::new(
|
||||
r"\\wsl.localhost\openSUSE-Leap-15.2\home\user"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_wsl_path_case_insensitive() {
|
||||
// 大小写不敏感
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert!(is_wsl_path(Path::new(r"\\WSL$\Ubuntu\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\Wsl$\Ubuntu\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\WSL.LOCALHOST\Ubuntu\home\user")));
|
||||
assert!(is_wsl_path(Path::new(r"\\Wsl.Localhost\Ubuntu\home\user")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_wsl_path_non_wsl() {
|
||||
// 非 WSL 路径
|
||||
assert!(!is_wsl_path(Path::new(r"C:\Users\user\.claude")));
|
||||
assert!(!is_wsl_path(Path::new(r"D:\Workspace\project")));
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert!(!is_wsl_path(Path::new(r"\\server\share\path")));
|
||||
assert!(!is_wsl_path(Path::new(r"\\localhost\c$\Users")));
|
||||
assert!(!is_wsl_path(Path::new(r"\\192.168.1.1\share")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,21 +9,13 @@ use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// 获取用户主目录,带回退和日志
|
||||
fn get_home_dir() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| {
|
||||
log::warn!("无法获取用户主目录,回退到当前目录");
|
||||
PathBuf::from(".")
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 Codex 配置目录路径
|
||||
pub fn get_codex_config_dir() -> PathBuf {
|
||||
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
||||
return custom;
|
||||
}
|
||||
|
||||
get_home_dir().join(".codex")
|
||||
dirs::home_dir().expect("无法获取用户主目录").join(".codex")
|
||||
}
|
||||
|
||||
/// 获取 Codex auth.json 路径
|
||||
|
||||
@@ -51,15 +51,6 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
||||
|
||||
Ok(ConfigStatus { exists, path })
|
||||
}
|
||||
AppType::OpenCode => {
|
||||
let config_path = crate::opencode_config::get_opencode_config_path();
|
||||
let exists = config_path.exists();
|
||||
let path = crate::opencode_config::get_opencode_dir()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(ConfigStatus { exists, path })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +67,6 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
|
||||
AppType::Claude => config::get_claude_config_dir(),
|
||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
};
|
||||
|
||||
Ok(dir.to_string_lossy().to_string())
|
||||
@@ -89,7 +79,6 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
|
||||
AppType::Claude => config::get_claude_config_dir(),
|
||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
};
|
||||
|
||||
if !config_dir.exists() {
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
//! 全局出站代理相关命令
|
||||
//!
|
||||
//! 提供获取、设置和测试全局代理的 Tauri 命令。
|
||||
|
||||
use crate::proxy::http_client;
|
||||
use crate::store::AppState;
|
||||
use serde::Serialize;
|
||||
use std::net::{Ipv4Addr, SocketAddrV4, TcpStream};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// 获取全局代理 URL
|
||||
///
|
||||
/// 返回当前配置的代理 URL,null 表示直连。
|
||||
#[tauri::command]
|
||||
pub fn get_global_proxy_url(state: tauri::State<'_, AppState>) -> Result<Option<String>, String> {
|
||||
let result = state.db.get_global_proxy_url().map_err(|e| e.to_string())?;
|
||||
log::debug!(
|
||||
"[GlobalProxy] [GP-010] Read from database: {}",
|
||||
result
|
||||
.as_ref()
|
||||
.map(|u| http_client::mask_url(u))
|
||||
.unwrap_or_else(|| "None".to_string())
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 设置全局代理 URL
|
||||
///
|
||||
/// - 传入非空字符串:启用代理
|
||||
/// - 传入空字符串:清除代理(直连)
|
||||
///
|
||||
/// 执行顺序:先验证 → 写 DB → 再应用
|
||||
/// 这样确保 DB 写失败时不会出现运行态与持久化不一致的问题
|
||||
#[tauri::command]
|
||||
pub fn set_global_proxy_url(state: tauri::State<'_, AppState>, url: String) -> Result<(), String> {
|
||||
// 调试:显示接收到的 URL 信息(不包含敏感内容)
|
||||
let has_auth = url.contains('@') && (url.starts_with("http://") || url.starts_with("socks"));
|
||||
log::debug!(
|
||||
"[GlobalProxy] [GP-011] Received URL: length={}, has_auth={}",
|
||||
url.len(),
|
||||
has_auth
|
||||
);
|
||||
|
||||
let url_opt = if url.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(url.as_str())
|
||||
};
|
||||
|
||||
// 1. 先验证代理配置是否有效(不应用)
|
||||
http_client::validate_proxy(url_opt)?;
|
||||
|
||||
// 2. 验证成功后保存到数据库
|
||||
state
|
||||
.db
|
||||
.set_global_proxy_url(url_opt)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 3. DB 写入成功后再应用到运行态
|
||||
http_client::apply_proxy(url_opt)?;
|
||||
|
||||
log::info!(
|
||||
"[GlobalProxy] [GP-009] Configuration updated: {}",
|
||||
url_opt
|
||||
.map(http_client::mask_url)
|
||||
.unwrap_or_else(|| "direct connection".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 代理测试结果
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyTestResult {
|
||||
/// 是否连接成功
|
||||
pub success: bool,
|
||||
/// 延迟(毫秒)
|
||||
pub latency_ms: u64,
|
||||
/// 错误信息
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 测试代理连接
|
||||
///
|
||||
/// 通过指定的代理 URL 发送测试请求,返回连接结果和延迟。
|
||||
/// 使用多个测试目标,任一成功即认为代理可用。
|
||||
#[tauri::command]
|
||||
pub async fn test_proxy_url(url: String) -> Result<ProxyTestResult, String> {
|
||||
if url.trim().is_empty() {
|
||||
return Err("Proxy URL is empty".to_string());
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
// 构建带代理的临时客户端
|
||||
let proxy = reqwest::Proxy::all(&url).map_err(|e| format!("Invalid proxy URL: {e}"))?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build client: {e}"))?;
|
||||
|
||||
// 使用多个测试目标,提高兼容性
|
||||
// 优先使用 httpbin(专门用于 HTTP 测试),回退到其他公共端点
|
||||
let test_urls = [
|
||||
"https://httpbin.org/get",
|
||||
"https://www.google.com",
|
||||
"https://api.anthropic.com",
|
||||
];
|
||||
|
||||
let mut last_error = None;
|
||||
|
||||
for test_url in test_urls {
|
||||
match client.head(test_url).send().await {
|
||||
Ok(resp) => {
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
log::debug!(
|
||||
"[GlobalProxy] Test successful: {} -> {} via {} ({}ms)",
|
||||
http_client::mask_url(&url),
|
||||
test_url,
|
||||
resp.status(),
|
||||
latency
|
||||
);
|
||||
return Ok(ProxyTestResult {
|
||||
success: true,
|
||||
latency_ms: latency,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("[GlobalProxy] Test to {test_url} failed: {e}");
|
||||
last_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有测试目标都失败
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
let error_msg = last_error
|
||||
.map(|e| e.to_string())
|
||||
.unwrap_or_else(|| "All test targets failed".to_string());
|
||||
|
||||
log::debug!(
|
||||
"[GlobalProxy] Test failed: {} -> {} ({}ms)",
|
||||
http_client::mask_url(&url),
|
||||
error_msg,
|
||||
latency
|
||||
);
|
||||
|
||||
Ok(ProxyTestResult {
|
||||
success: false,
|
||||
latency_ms: latency,
|
||||
error: Some(error_msg),
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取当前出站代理状态
|
||||
///
|
||||
/// 返回当前是否启用了出站代理以及代理 URL。
|
||||
#[tauri::command]
|
||||
pub fn get_upstream_proxy_status() -> UpstreamProxyStatus {
|
||||
let url = http_client::get_current_proxy_url();
|
||||
UpstreamProxyStatus {
|
||||
enabled: url.is_some(),
|
||||
proxy_url: url,
|
||||
}
|
||||
}
|
||||
|
||||
/// 出站代理状态信息
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpstreamProxyStatus {
|
||||
/// 是否启用代理
|
||||
pub enabled: bool,
|
||||
/// 代理 URL
|
||||
pub proxy_url: Option<String>,
|
||||
}
|
||||
|
||||
/// 检测到的代理信息
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DetectedProxy {
|
||||
/// 代理 URL
|
||||
pub url: String,
|
||||
/// 代理类型 (http/socks5)
|
||||
pub proxy_type: String,
|
||||
/// 端口
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
/// 常见代理端口配置
|
||||
/// 格式:(端口, 主要类型, 是否同时支持 http 和 socks5)
|
||||
/// 对于 mixed 端口,会同时返回两种协议供用户选择
|
||||
const PROXY_PORTS: &[(u16, &str, bool)] = &[
|
||||
(7890, "http", true), // Clash (mixed mode)
|
||||
(7891, "socks5", false), // Clash SOCKS only
|
||||
(1080, "socks5", false), // 通用 SOCKS5
|
||||
(8080, "http", false), // 通用 HTTP
|
||||
(8888, "http", false), // Charles/Fiddler
|
||||
(3128, "http", false), // Squid
|
||||
(10808, "socks5", false), // V2Ray SOCKS
|
||||
(10809, "http", false), // V2Ray HTTP
|
||||
];
|
||||
|
||||
/// 扫描本地代理
|
||||
///
|
||||
/// 检测常见端口是否有代理服务在运行。
|
||||
/// 使用异步任务避免阻塞 UI 线程。
|
||||
#[tauri::command]
|
||||
pub async fn scan_local_proxies() -> Vec<DetectedProxy> {
|
||||
// 使用 spawn_blocking 避免阻塞主线程
|
||||
tokio::task::spawn_blocking(|| {
|
||||
let mut found = Vec::new();
|
||||
|
||||
for &(port, primary_type, is_mixed) in PROXY_PORTS {
|
||||
let addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, port);
|
||||
if TcpStream::connect_timeout(&addr.into(), Duration::from_millis(100)).is_ok() {
|
||||
// 添加主要类型
|
||||
found.push(DetectedProxy {
|
||||
url: format!("{primary_type}://127.0.0.1:{port}"),
|
||||
proxy_type: primary_type.to_string(),
|
||||
port,
|
||||
});
|
||||
// 对于 mixed 端口,同时添加另一种协议
|
||||
if is_mixed {
|
||||
let alt_type = if primary_type == "http" {
|
||||
"socks5"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
found.push(DetectedProxy {
|
||||
url: format!("{alt_type}://127.0.0.1:{port}"),
|
||||
proxy_type: alt_type.to_string(),
|
||||
port,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
found
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -122,7 +122,6 @@ pub async fn upsert_mcp_server_in_config(
|
||||
new_server.apps.claude = true;
|
||||
new_server.apps.codex = true;
|
||||
new_server.apps.gemini = true;
|
||||
new_server.apps.opencode = true;
|
||||
}
|
||||
|
||||
McpService::upsert_server(&state, new_server)
|
||||
@@ -201,6 +200,5 @@ pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result<usize, S
|
||||
total += McpService::import_from_claude(&state).unwrap_or(0);
|
||||
total += McpService::import_from_codex(&state).unwrap_or(0);
|
||||
total += McpService::import_from_gemini(&state).unwrap_or(0);
|
||||
total += McpService::import_from_opencode(&state).unwrap_or(0);
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
|
||||
use crate::services::ProviderService;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use tauri::AppHandle;
|
||||
use tauri::State;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -92,14 +85,15 @@ pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
||||
let tools = vec!["claude", "codex", "gemini"];
|
||||
let mut results = Vec::new();
|
||||
|
||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||
let client = crate::proxy::http_client::get();
|
||||
// 用于获取远程版本的 client
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("cc-switch/1.0")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
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 {
|
||||
let (local_version, local_error) = {
|
||||
// 先尝试直接执行
|
||||
let direct_result = try_get_version(tool);
|
||||
|
||||
@@ -148,14 +142,11 @@ async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Op
|
||||
}
|
||||
}
|
||||
|
||||
/// 预编译的版本号正则表达式
|
||||
static VERSION_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex"));
|
||||
|
||||
/// 从版本输出中提取纯版本号
|
||||
fn extract_version(raw: &str) -> String {
|
||||
VERSION_RE
|
||||
.find(raw)
|
||||
// 匹配 semver 格式: x.y.z 或 x.y.z-xxx
|
||||
let re = regex::Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").unwrap();
|
||||
re.find(raw)
|
||||
.map(|m| m.as_str().to_string())
|
||||
.unwrap_or_else(|| raw.to_string())
|
||||
}
|
||||
@@ -187,7 +178,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
if out.status.success() {
|
||||
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||
if raw.is_empty() {
|
||||
(None, Some("not installed or not executable".to_string()))
|
||||
(None, Some("未安装或无法执行".to_string()))
|
||||
} else {
|
||||
(Some(extract_version(raw)), None)
|
||||
}
|
||||
@@ -196,7 +187,7 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
(
|
||||
None,
|
||||
Some(if err.is_empty() {
|
||||
"not installed or not executable".to_string()
|
||||
"未安装或无法执行".to_string()
|
||||
} else {
|
||||
err
|
||||
}),
|
||||
@@ -207,88 +198,6 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验 WSL 发行版名称是否合法
|
||||
/// WSL 发行版名称只允许字母、数字、连字符和下划线
|
||||
#[cfg(target_os = "windows")]
|
||||
fn is_valid_wsl_distro_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 64
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<String>) {
|
||||
use std::process::Command;
|
||||
|
||||
// 防御性断言:tool 只能是预定义的值
|
||||
debug_assert!(
|
||||
["claude", "codex", "gemini"].contains(&tool),
|
||||
"unexpected tool name: {tool}"
|
||||
);
|
||||
|
||||
// 校验 distro 名称,防止命令注入
|
||||
if !is_valid_wsl_distro_name(distro) {
|
||||
return (None, Some(format!("[WSL:{distro}] invalid distro name")));
|
||||
}
|
||||
|
||||
let output = Command::new("wsl.exe")
|
||||
.args([
|
||||
"-d",
|
||||
distro,
|
||||
"--",
|
||||
"sh",
|
||||
"-lc",
|
||||
&format!("{tool} --version"),
|
||||
])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(out) => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
if out.status.success() {
|
||||
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||
if raw.is_empty() {
|
||||
(
|
||||
None,
|
||||
Some(format!("[WSL:{distro}] not installed or not executable")),
|
||||
)
|
||||
} else {
|
||||
(Some(extract_version(raw)), None)
|
||||
}
|
||||
} else {
|
||||
let err = if stderr.is_empty() { stdout } else { stderr };
|
||||
(
|
||||
None,
|
||||
Some(format!(
|
||||
"[WSL:{distro}] {}",
|
||||
if err.is_empty() {
|
||||
"not installed or not executable".to_string()
|
||||
} else {
|
||||
err
|
||||
}
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(e) => (None, Some(format!("[WSL:{distro}] exec failed: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// 非 Windows 平台的 WSL 版本检测存根
|
||||
/// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。
|
||||
/// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn try_get_version_wsl(_tool: &str, _distro: &str) -> (Option<String>, Option<String>) {
|
||||
(
|
||||
None,
|
||||
Some("WSL check not supported on this platform".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
/// 扫描常见路径查找 CLI
|
||||
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
use std::process::Command;
|
||||
@@ -384,328 +293,5 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
(None, Some("not installed or not executable".to_string()))
|
||||
}
|
||||
|
||||
fn wsl_distro_for_tool(tool: &str) -> Option<String> {
|
||||
let override_dir = match tool {
|
||||
"claude" => crate::settings::get_claude_override_dir(),
|
||||
"codex" => crate::settings::get_codex_override_dir(),
|
||||
"gemini" => crate::settings::get_gemini_override_dir(),
|
||||
_ => None,
|
||||
}?;
|
||||
|
||||
wsl_distro_from_path(&override_dir)
|
||||
}
|
||||
|
||||
/// 从 UNC 路径中提取 WSL 发行版名称
|
||||
/// 支持 `\\wsl$\Ubuntu\...` 和 `\\wsl.localhost\Ubuntu\...` 两种格式
|
||||
#[cfg(target_os = "windows")]
|
||||
fn wsl_distro_from_path(path: &Path) -> Option<String> {
|
||||
use std::path::{Component, Prefix};
|
||||
let Some(Component::Prefix(prefix)) = path.components().next() else {
|
||||
return None;
|
||||
};
|
||||
match prefix.kind() {
|
||||
Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
|
||||
let server_name = server.to_string_lossy();
|
||||
if server_name.eq_ignore_ascii_case("wsl$")
|
||||
|| server_name.eq_ignore_ascii_case("wsl.localhost")
|
||||
{
|
||||
let distro = share.to_string_lossy().to_string();
|
||||
if !distro.is_empty() {
|
||||
return Some(distro);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 非 Windows 平台不支持 WSL 路径解析
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn wsl_distro_from_path(_path: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 打开指定提供商的终端
|
||||
///
|
||||
/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端
|
||||
/// 无需检查是否为当前激活的提供商,任何提供商都可以打开终端
|
||||
#[allow(non_snake_case)]
|
||||
#[tauri::command]
|
||||
pub async fn open_provider_terminal(
|
||||
state: State<'_, crate::store::AppState>,
|
||||
app: String,
|
||||
#[allow(non_snake_case)] providerId: String,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
|
||||
// 获取提供商配置
|
||||
let providers = ProviderService::list(state.inner(), app_type.clone())
|
||||
.map_err(|e| format!("获取提供商列表失败: {e}"))?;
|
||||
|
||||
let provider = providers
|
||||
.get(&providerId)
|
||||
.ok_or_else(|| format!("提供商 {providerId} 不存在"))?;
|
||||
|
||||
// 从提供商配置中提取环境变量
|
||||
let config = &provider.settings_config;
|
||||
let env_vars = extract_env_vars_from_config(config, &app_type);
|
||||
|
||||
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
|
||||
launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 从提供商配置中提取环境变量
|
||||
fn extract_env_vars_from_config(
|
||||
config: &serde_json::Value,
|
||||
app_type: &AppType,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut env_vars = Vec::new();
|
||||
|
||||
let Some(obj) = config.as_object() else {
|
||||
return env_vars;
|
||||
};
|
||||
|
||||
// 处理 env 字段(Claude/Gemini 通用)
|
||||
if let Some(env) = obj.get("env").and_then(|v| v.as_object()) {
|
||||
for (key, value) in env {
|
||||
if let Some(str_val) = value.as_str() {
|
||||
env_vars.push((key.clone(), str_val.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 base_url: 根据应用类型添加对应的环境变量
|
||||
let base_url_key = match app_type {
|
||||
AppType::Claude => Some("ANTHROPIC_BASE_URL"),
|
||||
AppType::Gemini => Some("GOOGLE_GEMINI_BASE_URL"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(key) = base_url_key {
|
||||
if let Some(url_str) = env.get(key).and_then(|v| v.as_str()) {
|
||||
env_vars.push((key.to_string(), url_str.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Codex 使用 auth 字段转换为 OPENAI_API_KEY
|
||||
if *app_type == AppType::Codex {
|
||||
if let Some(auth) = obj.get("auth").and_then(|v| v.as_str()) {
|
||||
env_vars.push(("OPENAI_API_KEY".to_string(), auth.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini 使用 api_key 字段转换为 GEMINI_API_KEY
|
||||
if *app_type == AppType::Gemini {
|
||||
if let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) {
|
||||
env_vars.push(("GEMINI_API_KEY".to_string(), api_key.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
env_vars
|
||||
}
|
||||
|
||||
/// 创建临时配置文件并启动 claude 终端
|
||||
/// 使用 --settings 参数传入提供商特定的 API 配置
|
||||
fn launch_terminal_with_env(
|
||||
env_vars: Vec<(String, String)>,
|
||||
provider_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let config_file = temp_dir.join(format!(
|
||||
"claude_{}_{}.json",
|
||||
provider_id,
|
||||
std::process::id()
|
||||
));
|
||||
|
||||
// 创建并写入配置文件
|
||||
write_claude_config(&config_file, &env_vars)?;
|
||||
|
||||
// 转义配置文件路径用于 shell
|
||||
let config_path_escaped = escape_shell_path(&config_file);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
launch_macos_terminal(&config_file, &config_path_escaped)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
launch_linux_terminal(&config_file, &config_path_escaped)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
launch_windows_terminal(&temp_dir, &config_file)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
Err("不支持的操作系统".to_string())
|
||||
}
|
||||
|
||||
/// 写入 claude 配置文件
|
||||
fn write_claude_config(
|
||||
config_file: &std::path::Path,
|
||||
env_vars: &[(String, String)],
|
||||
) -> Result<(), String> {
|
||||
let mut config_obj = serde_json::Map::new();
|
||||
let mut env_obj = serde_json::Map::new();
|
||||
|
||||
for (key, value) in env_vars {
|
||||
env_obj.insert(key.clone(), serde_json::Value::String(value.clone()));
|
||||
}
|
||||
|
||||
config_obj.insert("env".to_string(), serde_json::Value::Object(env_obj));
|
||||
|
||||
let config_json =
|
||||
serde_json::to_string_pretty(&config_obj).map_err(|e| format!("序列化配置失败: {e}"))?;
|
||||
|
||||
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
|
||||
}
|
||||
|
||||
/// 转义 shell 路径
|
||||
fn escape_shell_path(path: &std::path::Path) -> String {
|
||||
path.to_string_lossy()
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('$', "\\$")
|
||||
.replace(' ', "\\ ")
|
||||
}
|
||||
|
||||
/// 生成 bash 包装脚本,用于清理临时文件
|
||||
fn generate_wrapper_script(config_path: &str, escaped_path: &str) -> String {
|
||||
format!(
|
||||
"bash -c 'trap \"rm -f \\\"{config_path}\\\"\" EXIT; echo \"Using provider-specific claude config:\"; echo \"{escaped_path}\"; claude --settings \"{escaped_path}\"; exec bash --norc --noprofile'"
|
||||
)
|
||||
}
|
||||
|
||||
/// macOS: 使用 Terminal.app 启动
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_terminal(
|
||||
config_file: &std::path::Path,
|
||||
config_path_escaped: &str,
|
||||
) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let config_path_for_script = config_file.to_string_lossy().replace('\"', "\\\"");
|
||||
|
||||
let shell_script = generate_wrapper_script(&config_path_for_script, config_path_escaped);
|
||||
|
||||
let script = format!(
|
||||
r#"tell application "Terminal"
|
||||
activate
|
||||
do script "{}"
|
||||
end tell"#,
|
||||
shell_script.replace('\"', "\\\"")
|
||||
);
|
||||
|
||||
Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg(&script)
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动 macOS 终端失败: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Linux: 尝试使用常见终端启动
|
||||
#[cfg(target_os = "linux")]
|
||||
fn launch_linux_terminal(
|
||||
config_file: &std::path::Path,
|
||||
config_path_escaped: &str,
|
||||
) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let terminals = [
|
||||
"gnome-terminal",
|
||||
"konsole",
|
||||
"xfce4-terminal",
|
||||
"mate-terminal",
|
||||
"lxterminal",
|
||||
"alacritty",
|
||||
"kitty",
|
||||
];
|
||||
|
||||
let config_path_for_bash = config_file.to_string_lossy();
|
||||
let shell_cmd = generate_wrapper_script(&config_path_for_bash, config_path_escaped);
|
||||
|
||||
let mut last_error = String::from("未找到可用的终端");
|
||||
|
||||
for terminal in terminals {
|
||||
// 检查终端是否存在
|
||||
if std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|
||||
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
|
||||
{
|
||||
let result = match terminal {
|
||||
"gnome-terminal" | "mate-terminal" => Command::new(terminal)
|
||||
.arg("--")
|
||||
.arg("bash")
|
||||
.arg("-c")
|
||||
.arg(&shell_cmd)
|
||||
.spawn(),
|
||||
_ => Command::new(terminal)
|
||||
.arg("-e")
|
||||
.arg("bash")
|
||||
.arg("-c")
|
||||
.arg(&shell_cmd)
|
||||
.spawn(),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) => {
|
||||
last_error = format!("启动 {} 失败: {}", terminal, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理配置文件
|
||||
let _ = std::fs::remove_file(config_file);
|
||||
Err(last_error)
|
||||
}
|
||||
|
||||
/// Windows: 创建临时批处理文件启动
|
||||
#[cfg(target_os = "windows")]
|
||||
fn launch_windows_terminal(
|
||||
temp_dir: &std::path::Path,
|
||||
config_file: &std::path::Path,
|
||||
) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
|
||||
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
|
||||
|
||||
let content = format!(
|
||||
"@echo off
|
||||
echo Using provider-specific claude config:
|
||||
echo {}
|
||||
claude --settings \"{}\"
|
||||
del \"{}\" >nul 2>&1
|
||||
del \"%~f0\" >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo Press any key to close...
|
||||
pause >nul
|
||||
)",
|
||||
config_path_for_batch, config_path_for_batch, config_path_for_batch
|
||||
);
|
||||
|
||||
std::fs::write(&bat_file, content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
|
||||
|
||||
Command::new("cmd")
|
||||
.args(["/C", "start", "cmd", "/C", &bat_file.to_string_lossy()])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动 Windows 终端失败: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
(None, Some("未安装或无法执行".to_string()))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ mod config;
|
||||
mod deeplink;
|
||||
mod env;
|
||||
mod failover;
|
||||
mod global_proxy;
|
||||
mod import_export;
|
||||
mod mcp;
|
||||
mod misc;
|
||||
@@ -21,7 +20,6 @@ pub use config::*;
|
||||
pub use deeplink::*;
|
||||
pub use env::*;
|
||||
pub use failover::*;
|
||||
pub use global_proxy::*;
|
||||
pub use import_export::*;
|
||||
pub use mcp::*;
|
||||
pub use misc::*;
|
||||
|
||||
@@ -60,16 +60,6 @@ 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(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(app_type, &id)
|
||||
.map(|_| true)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 切换供应商
|
||||
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||
ProviderService::switch(state, app_type, id)
|
||||
@@ -143,7 +133,6 @@ pub async fn testUsageScript(
|
||||
#[allow(non_snake_case)] baseUrl: Option<String>,
|
||||
#[allow(non_snake_case)] accessToken: Option<String>,
|
||||
#[allow(non_snake_case)] userId: Option<String>,
|
||||
#[allow(non_snake_case)] templateType: Option<String>,
|
||||
) -> Result<crate::provider::UsageResult, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
ProviderService::test_usage_script(
|
||||
@@ -156,7 +145,6 @@ pub async fn testUsageScript(
|
||||
baseUrl.as_deref(),
|
||||
accessToken.as_deref(),
|
||||
userId.as_deref(),
|
||||
templateType.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
@@ -335,27 +323,3 @@ pub fn sync_universal_provider(
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -59,24 +59,3 @@ pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
|
||||
pub async fn get_auto_launch_status() -> Result<bool, String> {
|
||||
crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}"))
|
||||
}
|
||||
|
||||
/// 获取整流器配置
|
||||
#[tauri::command]
|
||||
pub async fn get_rectifier_config(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> Result<crate::proxy::types::RectifierConfig, String> {
|
||||
state.db.get_rectifier_config().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 设置整流器配置
|
||||
#[tauri::command]
|
||||
pub async fn set_rectifier_config(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
config: crate::proxy::types::RectifierConfig,
|
||||
) -> Result<bool, String> {
|
||||
state
|
||||
.db
|
||||
.set_rectifier_config(&config)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ fn parse_app_type(app: &str) -> Result<AppType, String> {
|
||||
"claude" => Ok(AppType::Claude),
|
||||
"codex" => Ok(AppType::Codex),
|
||||
"gemini" => Ok(AppType::Gemini),
|
||||
"opencode" => Ok(AppType::OpenCode),
|
||||
_ => Err(format!("不支持的 app 类型: {app}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,26 +5,22 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// 获取用户主目录,带回退和日志
|
||||
fn get_home_dir() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| {
|
||||
log::warn!("无法获取用户主目录,回退到当前目录");
|
||||
PathBuf::from(".")
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 Claude Code 配置目录路径
|
||||
pub fn get_claude_config_dir() -> PathBuf {
|
||||
if let Some(custom) = crate::settings::get_claude_override_dir() {
|
||||
return custom;
|
||||
}
|
||||
|
||||
get_home_dir().join(".claude")
|
||||
dirs::home_dir()
|
||||
.expect("无法获取用户主目录")
|
||||
.join(".claude")
|
||||
}
|
||||
|
||||
/// 默认 Claude MCP 配置文件路径 (~/.claude.json)
|
||||
pub fn get_default_claude_mcp_path() -> PathBuf {
|
||||
get_home_dir().join(".claude.json")
|
||||
dirs::home_dir()
|
||||
.expect("无法获取用户主目录")
|
||||
.join(".claude.json")
|
||||
}
|
||||
|
||||
fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
|
||||
|
||||
@@ -13,7 +13,7 @@ impl Database {
|
||||
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
|
||||
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini
|
||||
FROM mcp_servers
|
||||
ORDER BY name ASC, id ASC"
|
||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -30,7 +30,6 @@ impl Database {
|
||||
let enabled_claude: bool = row.get(7)?;
|
||||
let enabled_codex: bool = row.get(8)?;
|
||||
let enabled_gemini: bool = row.get(9)?;
|
||||
let enabled_opencode: bool = row.get(10)?;
|
||||
|
||||
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
|
||||
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
@@ -45,7 +44,6 @@ impl Database {
|
||||
claude: enabled_claude,
|
||||
codex: enabled_codex,
|
||||
gemini: enabled_gemini,
|
||||
opencode: enabled_opencode,
|
||||
},
|
||||
description,
|
||||
homepage,
|
||||
@@ -70,23 +68,19 @@ impl Database {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO mcp_servers (
|
||||
id, name, server_config, description, homepage, docs, tags,
|
||||
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
enabled_claude, enabled_codex, enabled_gemini
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
params![
|
||||
server.id,
|
||||
server.name,
|
||||
serde_json::to_string(&server.server).map_err(|e| AppError::Database(format!(
|
||||
"Failed to serialize server config: {e}"
|
||||
)))?,
|
||||
serde_json::to_string(&server.server).unwrap(),
|
||||
server.description,
|
||||
server.homepage,
|
||||
server.docs,
|
||||
serde_json::to_string(&server.tags)
|
||||
.map_err(|e| AppError::Database(format!("Failed to serialize tags: {e}")))?,
|
||||
serde_json::to_string(&server.tags).unwrap(),
|
||||
server.apps.claude,
|
||||
server.apps.codex,
|
||||
server.apps.gemini,
|
||||
server.apps.opencode,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
@@ -220,9 +220,7 @@ impl Database {
|
||||
WHERE id = ?13 AND app_type = ?14",
|
||||
params![
|
||||
provider.name,
|
||||
serde_json::to_string(&provider.settings_config).map_err(|e| {
|
||||
AppError::Database(format!("Failed to serialize settings_config: {e}"))
|
||||
})?,
|
||||
serde_json::to_string(&provider.settings_config).unwrap(),
|
||||
provider.website_url,
|
||||
provider.category,
|
||||
provider.created_at,
|
||||
@@ -230,9 +228,7 @@ impl Database {
|
||||
provider.notes,
|
||||
provider.icon,
|
||||
provider.icon_color,
|
||||
serde_json::to_string(&meta_clone).map_err(|e| AppError::Database(format!(
|
||||
"Failed to serialize meta: {e}"
|
||||
)))?,
|
||||
serde_json::to_string(&meta_clone).unwrap(),
|
||||
is_current,
|
||||
in_failover_queue,
|
||||
provider.id,
|
||||
@@ -251,8 +247,7 @@ impl Database {
|
||||
provider.id,
|
||||
app_type,
|
||||
provider.name,
|
||||
serde_json::to_string(&provider.settings_config)
|
||||
.map_err(|e| AppError::Database(format!("Failed to serialize settings_config: {e}")))?,
|
||||
serde_json::to_string(&provider.settings_config).unwrap(),
|
||||
provider.website_url,
|
||||
provider.category,
|
||||
provider.created_at,
|
||||
@@ -260,8 +255,7 @@ impl Database {
|
||||
provider.notes,
|
||||
provider.icon,
|
||||
provider.icon_color,
|
||||
serde_json::to_string(&meta_clone)
|
||||
.map_err(|e| AppError::Database(format!("Failed to serialize meta: {e}")))?,
|
||||
serde_json::to_string(&meta_clone).unwrap(),
|
||||
is_current,
|
||||
in_failover_queue,
|
||||
],
|
||||
@@ -330,9 +324,7 @@ impl Database {
|
||||
conn.execute(
|
||||
"UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3",
|
||||
params![
|
||||
serde_json::to_string(settings_config).map_err(|e| AppError::Database(format!(
|
||||
"Failed to serialize settings_config: {e}"
|
||||
)))?,
|
||||
serde_json::to_string(settings_config).unwrap(),
|
||||
provider_id,
|
||||
app_type
|
||||
],
|
||||
|
||||
@@ -41,7 +41,7 @@ impl Database {
|
||||
Ok(GlobalProxyConfig {
|
||||
proxy_enabled: false,
|
||||
listen_address: "127.0.0.1".to_string(),
|
||||
listen_port: 15721,
|
||||
listen_port: 5000,
|
||||
enable_logging: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -63,41 +63,6 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 全局出站代理 ---
|
||||
|
||||
/// 全局代理 URL 的存储键名
|
||||
const GLOBAL_PROXY_URL_KEY: &'static str = "global_proxy_url";
|
||||
|
||||
/// 获取全局出站代理 URL
|
||||
///
|
||||
/// 返回 None 表示未配置或已清除代理(直连)
|
||||
/// 返回 Some(url) 表示已配置代理
|
||||
pub fn get_global_proxy_url(&self) -> Result<Option<String>, AppError> {
|
||||
self.get_setting(Self::GLOBAL_PROXY_URL_KEY)
|
||||
}
|
||||
|
||||
/// 设置全局出站代理 URL
|
||||
///
|
||||
/// - 传入非空字符串:启用代理
|
||||
/// - 传入空字符串或 None:清除代理设置(直连)
|
||||
pub fn set_global_proxy_url(&self, url: Option<&str>) -> Result<(), AppError> {
|
||||
match url {
|
||||
Some(u) if !u.trim().is_empty() => {
|
||||
self.set_setting(Self::GLOBAL_PROXY_URL_KEY, u.trim())
|
||||
}
|
||||
_ => {
|
||||
// 清除代理设置
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"DELETE FROM settings WHERE key = ?1",
|
||||
params![Self::GLOBAL_PROXY_URL_KEY],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)---
|
||||
|
||||
/// 获取指定应用的代理接管状态
|
||||
@@ -163,27 +128,4 @@ impl Database {
|
||||
log::info!("已清除所有代理接管状态");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- 整流器配置 ---
|
||||
|
||||
/// 获取整流器配置
|
||||
///
|
||||
/// 返回整流器配置,如果不存在则返回默认值(全部启用)
|
||||
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)
|
||||
.map_err(|e| AppError::Database(format!("解析整流器配置失败: {e}"))),
|
||||
None => Ok(crate::proxy::types::RectifierConfig::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新整流器配置
|
||||
pub fn set_rectifier_config(
|
||||
&self,
|
||||
config: &crate::proxy::types::RectifierConfig,
|
||||
) -> Result<(), AppError> {
|
||||
let json = serde_json::to_string(config)
|
||||
.map_err(|e| AppError::Database(format!("序列化整流器配置失败: {e}")))?;
|
||||
self.set_setting("rectifier_config", &json)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
|
||||
//!
|
||||
//! v3.10.0+ 统一管理架构:
|
||||
//! - Skills 使用统一的 id 主键,支持四应用启用标志
|
||||
//! - Skills 使用统一的 id 主键,支持三应用启用标志
|
||||
//! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录
|
||||
|
||||
use crate::app_config::{InstalledSkill, SkillApps};
|
||||
@@ -22,7 +22,7 @@ impl Database {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
|
||||
FROM skills ORDER BY name ASC",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -42,9 +42,8 @@ impl Database {
|
||||
claude: row.get(8)?,
|
||||
codex: row.get(9)?,
|
||||
gemini: row.get(10)?,
|
||||
opencode: row.get(11)?,
|
||||
},
|
||||
installed_at: row.get(12)?,
|
||||
installed_at: row.get(11)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -63,7 +62,7 @@ impl Database {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
|
||||
FROM skills WHERE id = ?1",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -82,9 +81,8 @@ impl Database {
|
||||
claude: row.get(8)?,
|
||||
codex: row.get(9)?,
|
||||
gemini: row.get(10)?,
|
||||
opencode: row.get(11)?,
|
||||
},
|
||||
installed_at: row.get(12)?,
|
||||
installed_at: row.get(11)?,
|
||||
})
|
||||
});
|
||||
|
||||
@@ -101,8 +99,8 @@ impl Database {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO skills
|
||||
(id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
params![
|
||||
skill.id,
|
||||
skill.name,
|
||||
@@ -115,7 +113,6 @@ impl Database {
|
||||
skill.apps.claude,
|
||||
skill.apps.codex,
|
||||
skill.apps.gemini,
|
||||
skill.apps.opencode,
|
||||
skill.installed_at,
|
||||
],
|
||||
)
|
||||
@@ -145,8 +142,8 @@ impl Database {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let affected = conn
|
||||
.execute(
|
||||
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4 WHERE id = ?5",
|
||||
params![apps.claude, apps.codex, apps.gemini, apps.opencode, id],
|
||||
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3 WHERE id = ?4",
|
||||
params![apps.claude, apps.codex, apps.gemini, id],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
|
||||
@@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 4;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 3;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
|
||||
@@ -58,7 +58,7 @@ impl Database {
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
|
||||
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
|
||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
|
||||
)",
|
||||
[],
|
||||
)
|
||||
@@ -85,7 +85,6 @@ impl Database {
|
||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||
installed_at INTEGER NOT NULL DEFAULT 0
|
||||
)",
|
||||
[],
|
||||
@@ -113,7 +112,7 @@ impl Database {
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS proxy_config (
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
|
||||
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
|
||||
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
|
||||
@@ -254,7 +253,7 @@ impl Database {
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN listen_port INTEGER NOT NULL DEFAULT 15721",
|
||||
"ALTER TABLE proxy_config ADD COLUMN listen_port INTEGER NOT NULL DEFAULT 5000",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
@@ -347,11 +346,6 @@ impl Database {
|
||||
Self::migrate_v2_to_v3(conn)?;
|
||||
Self::set_user_version(conn, 3)?;
|
||||
}
|
||||
3 => {
|
||||
log::info!("迁移数据库从 v3 到 v4(OpenCode 支持)");
|
||||
Self::migrate_v3_to_v4(conn)?;
|
||||
Self::set_user_version(conn, 4)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -475,7 +469,7 @@ impl Database {
|
||||
conn,
|
||||
"proxy_config",
|
||||
"listen_port",
|
||||
"INTEGER NOT NULL DEFAULT 15721",
|
||||
"INTEGER NOT NULL DEFAULT 5000",
|
||||
)?;
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
@@ -670,7 +664,7 @@ impl Database {
|
||||
conn.execute("CREATE TABLE proxy_config_new (
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
|
||||
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
|
||||
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
|
||||
@@ -855,30 +849,6 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v3 -> v4 迁移:添加 OpenCode 支持
|
||||
///
|
||||
/// 为 mcp_servers 和 skills 表添加 enabled_opencode 列。
|
||||
fn migrate_v3_to_v4(conn: &Connection) -> Result<(), AppError> {
|
||||
// 为 mcp_servers 表添加 enabled_opencode 列
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"mcp_servers",
|
||||
"enabled_opencode",
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
|
||||
// 为 skills 表添加 enabled_opencode 列
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"skills",
|
||||
"enabled_opencode",
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
|
||||
log::info!("v3 -> v4 迁移完成:已添加 OpenCode 支持");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
|
||||
@@ -166,7 +166,6 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
};
|
||||
|
||||
for app in apps_str.split(',') {
|
||||
@@ -174,7 +173,6 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
||||
"claude" => apps.claude = true,
|
||||
"codex" => apps.codex = true,
|
||||
"gemini" => apps.gemini = true,
|
||||
"opencode" => apps.opencode = true,
|
||||
other => {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Invalid app in 'apps': {other}"
|
||||
|
||||
@@ -55,7 +55,7 @@ pub struct DeepLinkImportRequest {
|
||||
/// Provider homepage URL
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub homepage: Option<String>,
|
||||
/// API endpoint/base URL (supports comma-separated multiple URLs)
|
||||
/// API endpoint/base URL
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub endpoint: Option<String>,
|
||||
/// API key
|
||||
|
||||
@@ -101,13 +101,9 @@ fn parse_provider_deeplink(
|
||||
validate_url(hp, "homepage")?;
|
||||
}
|
||||
}
|
||||
// Validate each endpoint (supports comma-separated multiple URLs)
|
||||
if let Some(ref ep) = endpoint {
|
||||
for (i, url) in ep.split(',').enumerate() {
|
||||
let trimmed = url.trim();
|
||||
if !trimmed.is_empty() {
|
||||
validate_url(trimmed, &format!("endpoint[{i}]"))?;
|
||||
}
|
||||
if !ep.is_empty() {
|
||||
validate_url(ep, "endpoint")?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,12 +33,12 @@ pub fn import_provider_from_deeplink(
|
||||
}
|
||||
|
||||
// Step 1: Merge config file if provided (v3.8+)
|
||||
let mut merged_request = parse_and_merge_config(&request)?;
|
||||
let merged_request = parse_and_merge_config(&request)?;
|
||||
|
||||
// Extract required fields (now as Option)
|
||||
let app_str = merged_request
|
||||
.app
|
||||
.clone()
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?;
|
||||
|
||||
let api_key = merged_request.api_key.as_ref().ok_or_else(|| {
|
||||
@@ -51,29 +51,14 @@ pub fn import_provider_from_deeplink(
|
||||
));
|
||||
}
|
||||
|
||||
// Get endpoint: supports comma-separated multiple URLs (first is primary)
|
||||
let endpoint_str = merged_request.endpoint.as_ref().ok_or_else(|| {
|
||||
let endpoint = merged_request.endpoint.as_ref().ok_or_else(|| {
|
||||
AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string())
|
||||
})?;
|
||||
|
||||
// Parse endpoints: split by comma, first is primary
|
||||
let all_endpoints: Vec<String> = endpoint_str
|
||||
.split(',')
|
||||
.map(|e| e.trim().to_string())
|
||||
.filter(|e| !e.is_empty())
|
||||
.collect();
|
||||
|
||||
let primary_endpoint = all_endpoints
|
||||
.first()
|
||||
.ok_or_else(|| AppError::InvalidInput("Endpoint cannot be empty".to_string()))?;
|
||||
|
||||
// Auto-infer homepage from endpoint if not provided
|
||||
if merged_request
|
||||
.homepage
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.is_empty())
|
||||
{
|
||||
merged_request.homepage = infer_homepage_from_endpoint(primary_endpoint);
|
||||
if endpoint.is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"Endpoint cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let homepage = merged_request.homepage.as_ref().ok_or_else(|| {
|
||||
@@ -88,11 +73,11 @@ pub fn import_provider_from_deeplink(
|
||||
|
||||
let name = merged_request
|
||||
.name
|
||||
.clone()
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::InvalidInput("Missing 'name' field for provider".to_string()))?;
|
||||
|
||||
// Parse app type
|
||||
let app_type = AppType::from_str(&app_str)
|
||||
let app_type = AppType::from_str(app_str)
|
||||
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?;
|
||||
|
||||
// Build provider configuration based on app type
|
||||
@@ -112,21 +97,6 @@ pub fn import_provider_from_deeplink(
|
||||
// Use ProviderService to add the provider
|
||||
ProviderService::add(state, app_type.clone(), provider)?;
|
||||
|
||||
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
|
||||
for ep in all_endpoints.iter().skip(1) {
|
||||
let normalized = ep.trim().trim_end_matches('/').to_string();
|
||||
if !normalized.is_empty() {
|
||||
if let Err(e) = ProviderService::add_custom_endpoint(
|
||||
state,
|
||||
app_type.clone(),
|
||||
&provider_id,
|
||||
normalized.clone(),
|
||||
) {
|
||||
log::warn!("Failed to add custom endpoint '{normalized}': {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If enabled=true, set as current provider
|
||||
if merged_request.enabled.unwrap_or(false) {
|
||||
ProviderService::switch(state, app_type.clone(), &provider_id)?;
|
||||
@@ -145,7 +115,6 @@ pub(crate) fn build_provider_from_request(
|
||||
AppType::Claude => build_claude_settings(request),
|
||||
AppType::Codex => build_codex_settings(request),
|
||||
AppType::Gemini => build_gemini_settings(request),
|
||||
AppType::OpenCode => build_opencode_settings(request),
|
||||
};
|
||||
|
||||
// Build usage script configuration if provided
|
||||
@@ -169,16 +138,6 @@ pub(crate) fn build_provider_from_request(
|
||||
Ok(provider)
|
||||
}
|
||||
|
||||
/// Get primary endpoint from request (first one if comma-separated)
|
||||
fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String {
|
||||
request
|
||||
.endpoint
|
||||
.as_ref()
|
||||
.and_then(|ep| ep.split(',').next())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Build provider meta with usage script configuration
|
||||
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
|
||||
// Check if any usage script fields are provided
|
||||
@@ -206,7 +165,6 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
||||
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
||||
|
||||
// Build UsageScript - use provider's API key and endpoint as defaults
|
||||
// Note: use primary endpoint only (first one if comma-separated)
|
||||
let usage_script = UsageScript {
|
||||
enabled,
|
||||
language: "javascript".to_string(),
|
||||
@@ -216,17 +174,12 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
||||
.usage_api_key
|
||||
.clone()
|
||||
.or_else(|| request.api_key.clone()),
|
||||
base_url: request.usage_base_url.clone().or_else(|| {
|
||||
let primary = get_primary_endpoint(request);
|
||||
if primary.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(primary)
|
||||
}
|
||||
}),
|
||||
base_url: request
|
||||
.usage_base_url
|
||||
.clone()
|
||||
.or_else(|| request.endpoint.clone()),
|
||||
access_token: request.usage_access_token.clone(),
|
||||
user_id: request.usage_user_id.clone(),
|
||||
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
||||
auto_query_interval: request.usage_auto_interval,
|
||||
};
|
||||
|
||||
@@ -245,7 +198,7 @@ fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
);
|
||||
env.insert(
|
||||
"ANTHROPIC_BASE_URL".to_string(),
|
||||
json!(get_primary_endpoint(request)),
|
||||
json!(request.endpoint.clone().unwrap_or_default()),
|
||||
);
|
||||
|
||||
// Add default model if provided
|
||||
@@ -318,8 +271,11 @@ fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
.unwrap_or("gpt-5-codex")
|
||||
.to_string();
|
||||
|
||||
// Endpoint: normalize trailing slashes (use primary endpoint only)
|
||||
let endpoint = get_primary_endpoint(request)
|
||||
// Endpoint: normalize trailing slashes
|
||||
let endpoint = request
|
||||
.endpoint
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
@@ -353,7 +309,7 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
|
||||
env.insert(
|
||||
"GOOGLE_GEMINI_BASE_URL".to_string(),
|
||||
json!(get_primary_endpoint(request)),
|
||||
json!(request.endpoint),
|
||||
);
|
||||
|
||||
// Add model if provided
|
||||
@@ -364,33 +320,6 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
json!({ "env": env })
|
||||
}
|
||||
|
||||
/// Build OpenCode settings configuration
|
||||
fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||
let endpoint = get_primary_endpoint(request);
|
||||
|
||||
// Build options object
|
||||
let mut options = serde_json::Map::new();
|
||||
if !endpoint.is_empty() {
|
||||
options.insert("baseURL".to_string(), json!(endpoint));
|
||||
}
|
||||
if let Some(api_key) = &request.api_key {
|
||||
options.insert("apiKey".to_string(), json!(api_key));
|
||||
}
|
||||
|
||||
// Build models object
|
||||
let mut models = serde_json::Map::new();
|
||||
if let Some(model) = &request.model {
|
||||
models.insert(model.clone(), json!({ "name": model }));
|
||||
}
|
||||
|
||||
// Default to openai-compatible npm package
|
||||
json!({
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"options": options,
|
||||
"models": models
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Config Merge Logic
|
||||
// =============================================================================
|
||||
@@ -480,26 +409,27 @@ fn merge_claude_config(
|
||||
})?;
|
||||
|
||||
// Auto-fill API key if not provided in URL
|
||||
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
|
||||
if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) {
|
||||
request.api_key = Some(token.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-fill endpoint if not provided in URL
|
||||
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
|
||||
if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
|
||||
request.endpoint = Some(base_url.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-fill homepage from endpoint if not provided
|
||||
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);
|
||||
if request.homepage.is_none() {
|
||||
request.homepage = Some("https://anthropic.com".to_string());
|
||||
}
|
||||
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
|
||||
&& request.endpoint.is_some()
|
||||
&& !request.endpoint.as_ref().unwrap().is_empty()
|
||||
{
|
||||
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
|
||||
if request.homepage.is_none() {
|
||||
request.homepage = Some("https://anthropic.com".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,7 +468,7 @@ fn merge_codex_config(
|
||||
config: &serde_json::Value,
|
||||
) -> Result<(), AppError> {
|
||||
// Auto-fill API key from auth.OPENAI_API_KEY
|
||||
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
|
||||
if let Some(api_key) = config
|
||||
.get("auth")
|
||||
.and_then(|v| v.get("OPENAI_API_KEY"))
|
||||
@@ -553,7 +483,7 @@ fn merge_codex_config(
|
||||
// Parse TOML config string to extract base_url and model
|
||||
if let Ok(toml_value) = toml::from_str::<toml::Value>(config_str) {
|
||||
// Extract base_url from model_providers section
|
||||
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
|
||||
if let Some(base_url) = extract_codex_base_url(&toml_value) {
|
||||
request.endpoint = Some(base_url);
|
||||
}
|
||||
@@ -569,12 +499,13 @@ fn merge_codex_config(
|
||||
}
|
||||
|
||||
// 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);
|
||||
if request.homepage.is_none() {
|
||||
request.homepage = Some("https://openai.com".to_string());
|
||||
}
|
||||
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
|
||||
&& request.endpoint.is_some()
|
||||
&& !request.endpoint.as_ref().unwrap().is_empty()
|
||||
{
|
||||
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
|
||||
if request.homepage.is_none() {
|
||||
request.homepage = Some("https://openai.com".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,18 +518,14 @@ fn merge_gemini_config(
|
||||
config: &serde_json::Value,
|
||||
) -> Result<(), AppError> {
|
||||
// Gemini uses flat env structure
|
||||
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() {
|
||||
if let Some(api_key) = config.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
|
||||
request.api_key = Some(api_key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if let Some(base_url) = config
|
||||
.get("GOOGLE_GEMINI_BASE_URL")
|
||||
.or_else(|| config.get("GEMINI_BASE_URL"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() {
|
||||
if let Some(base_url) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) {
|
||||
request.endpoint = Some(base_url.to_string());
|
||||
}
|
||||
}
|
||||
@@ -611,12 +538,13 @@ fn merge_gemini_config(
|
||||
}
|
||||
|
||||
// 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);
|
||||
if request.homepage.is_none() {
|
||||
request.homepage = Some("https://ai.google.dev".to_string());
|
||||
}
|
||||
if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty())
|
||||
&& request.endpoint.is_some()
|
||||
&& !request.endpoint.as_ref().unwrap().is_empty()
|
||||
{
|
||||
request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap());
|
||||
if request.homepage.is_none() {
|
||||
request.homepage = Some("https://ai.google.dev".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -404,57 +404,3 @@ fn test_parse_skill_deeplink() {
|
||||
assert_eq!(request.directory.unwrap(), "skills");
|
||||
assert_eq!(request.branch.unwrap(), "dev");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Multiple Endpoints Tests
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_parse_multiple_endpoints_comma_separated() {
|
||||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com,https%3A%2F%2Fapi2.example.com,https%3A%2F%2Fapi3.example.com&apiKey=sk-test";
|
||||
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
assert!(request.endpoint.is_some());
|
||||
let endpoint = request.endpoint.unwrap();
|
||||
// Should contain all endpoints comma-separated
|
||||
assert!(endpoint.contains("https://api1.example.com"));
|
||||
assert!(endpoint.contains("https://api2.example.com"));
|
||||
assert!(endpoint.contains("https://api3.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_single_endpoint_backward_compatible() {
|
||||
// Old format with single endpoint should still work
|
||||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test";
|
||||
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
request.endpoint,
|
||||
Some("https://api.example.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_endpoints_with_spaces_trimmed() {
|
||||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com%20,%20https%3A%2F%2Fapi2.example.com&apiKey=sk-test";
|
||||
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
// Validation should pass (spaces are trimmed during validation)
|
||||
assert!(request.endpoint.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_infer_homepage_from_endpoint_without_homepage() {
|
||||
// Test that homepage is auto-inferred from endpoint when not provided
|
||||
assert_eq!(
|
||||
infer_homepage_from_endpoint("https://api.cubence.com/v1"),
|
||||
Some("https://cubence.com".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
infer_homepage_from_endpoint("https://cubence.com"),
|
||||
Some("https://cubence.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,21 +5,15 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 获取用户主目录,带回退和日志
|
||||
fn get_home_dir() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| {
|
||||
log::warn!("无法获取用户主目录,回退到当前目录");
|
||||
PathBuf::from(".")
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 Gemini 配置目录路径(支持设置覆盖)
|
||||
pub fn get_gemini_dir() -> PathBuf {
|
||||
if let Some(custom) = crate::settings::get_gemini_override_dir() {
|
||||
return custom;
|
||||
}
|
||||
|
||||
get_home_dir().join(".gemini")
|
||||
dirs::home_dir()
|
||||
.expect("无法获取用户主目录")
|
||||
.join(".gemini")
|
||||
}
|
||||
|
||||
/// 获取 Gemini .env 文件路径
|
||||
|
||||
@@ -134,33 +134,6 @@ pub fn set_mcp_servers_map(
|
||||
obj.remove("homepage");
|
||||
obj.remove("docs");
|
||||
|
||||
// Timeout 转换:Claude/Codex 使用 startup_timeout_sec/tool_timeout_sec
|
||||
// Gemini CLI 只支持 timeout(单位 ms)
|
||||
// 默认值:startup=10s, tool=60s
|
||||
const DEFAULT_STARTUP_MS: u64 = 10_000;
|
||||
const DEFAULT_TOOL_MS: u64 = 60_000;
|
||||
|
||||
let extract_timeout =
|
||||
|obj: &mut Map<String, Value>, key: &str, multiplier: u64| -> Option<u64> {
|
||||
obj.remove(key).and_then(|val| {
|
||||
val.as_u64()
|
||||
.map(|n| n * multiplier)
|
||||
.or_else(|| val.as_f64().map(|f| (f * multiplier as f64) as u64))
|
||||
})
|
||||
};
|
||||
|
||||
// 分别收集 startup 和 tool timeout,未设置时使用默认值
|
||||
let startup_ms = extract_timeout(&mut obj, "startup_timeout_sec", 1000)
|
||||
.or_else(|| extract_timeout(&mut obj, "startup_timeout_ms", 1))
|
||||
.unwrap_or(DEFAULT_STARTUP_MS);
|
||||
let tool_ms = extract_timeout(&mut obj, "tool_timeout_sec", 1000)
|
||||
.or_else(|| extract_timeout(&mut obj, "tool_timeout_ms", 1))
|
||||
.unwrap_or(DEFAULT_TOOL_MS);
|
||||
|
||||
// 取最大值作为 Gemini timeout
|
||||
let final_timeout = startup_ms.max(tool_ms);
|
||||
obj.insert("timeout".to_string(), Value::Number(final_timeout.into()));
|
||||
|
||||
out.insert(id.clone(), Value::Object(obj));
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,6 @@ mod gemini_config;
|
||||
mod gemini_mcp;
|
||||
mod init_status;
|
||||
mod mcp;
|
||||
mod opencode_config;
|
||||
mod panic_hook;
|
||||
mod prompt;
|
||||
mod prompt_files;
|
||||
mod provider;
|
||||
@@ -28,7 +26,6 @@ mod usage_script;
|
||||
|
||||
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
|
||||
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
|
||||
pub use commands::open_provider_terminal;
|
||||
pub use commands::*;
|
||||
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
|
||||
pub use database::Database;
|
||||
@@ -57,37 +54,6 @@ use tauri::tray::{TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::RunEvent;
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
fn redact_url_for_log(url_str: &str) -> String {
|
||||
match url::Url::parse(url_str) {
|
||||
Ok(url) => {
|
||||
let mut output = format!("{}://", url.scheme());
|
||||
if let Some(host) = url.host_str() {
|
||||
output.push_str(host);
|
||||
}
|
||||
output.push_str(url.path());
|
||||
|
||||
let mut keys: Vec<String> = url.query_pairs().map(|(k, _)| k.to_string()).collect();
|
||||
keys.sort();
|
||||
keys.dedup();
|
||||
|
||||
if !keys.is_empty() {
|
||||
output.push_str("?[keys:");
|
||||
output.push_str(&keys.join(","));
|
||||
output.push(']');
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
Err(_) => {
|
||||
let base = url_str.split('#').next().unwrap_or(url_str);
|
||||
match base.split_once('?') {
|
||||
Some((prefix, _)) => format!("{prefix}?[redacted]"),
|
||||
None => base.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一处理 ccswitch:// 深链接 URL
|
||||
///
|
||||
/// - 解析 URL
|
||||
@@ -103,9 +69,7 @@ fn handle_deeplink_url(
|
||||
return false;
|
||||
}
|
||||
|
||||
let redacted_url = redact_url_for_log(url_str);
|
||||
log::info!("✓ Deep link URL detected from {source}: {redacted_url}");
|
||||
log::debug!("Deep link URL (raw) from {source}: {url_str}");
|
||||
log::info!("✓ Deep link URL detected from {source}: {url_str}");
|
||||
|
||||
match crate::deeplink::parse_deeplink_url(url_str) {
|
||||
Ok(request) => {
|
||||
@@ -186,18 +150,15 @@ fn macos_tray_icon() -> Option<Image<'static>> {
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// 设置 panic hook,在应用崩溃时记录日志到 <app_config_dir>/crash.log(默认 ~/.cc-switch/crash.log)
|
||||
panic_hook::setup_panic_hook();
|
||||
|
||||
let mut builder = tauri::Builder::default();
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
||||
{
|
||||
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
log::info!("=== Single Instance Callback Triggered ===");
|
||||
log::debug!("Args count: {}", args.len());
|
||||
log::info!("Args count: {}", args.len());
|
||||
for (i, arg) in args.iter().enumerate() {
|
||||
log::debug!(" arg[{i}]: {}", redact_url_for_log(arg));
|
||||
log::info!(" arg[{i}]: {arg}");
|
||||
}
|
||||
|
||||
// Check for deep link URL in args (mainly for Windows/Linux command line)
|
||||
@@ -251,10 +212,6 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.setup(|app| {
|
||||
// 预先刷新 Store 覆盖配置,确保后续路径读取正确(日志/数据库等)
|
||||
app_store::refresh_app_config_dir_override(app.handle());
|
||||
panic_hook::init_app_config_dir(crate::config::get_app_config_dir());
|
||||
|
||||
// 注册 Updater 插件(桌面端)
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
@@ -266,35 +223,18 @@ pub fn run() {
|
||||
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
|
||||
}
|
||||
}
|
||||
// 初始化日志(Debug 和 Release 模式都启用 Info 级别)
|
||||
// 日志同时输出到控制台和文件(<app_config_dir>/logs/;若设置了覆盖则使用覆盖目录)
|
||||
{
|
||||
use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy};
|
||||
|
||||
let log_dir = panic_hook::get_log_dir();
|
||||
|
||||
// 初始化日志
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.targets([
|
||||
// 输出到控制台
|
||||
Target::new(TargetKind::Stdout),
|
||||
// 输出到日志文件
|
||||
Target::new(TargetKind::Folder {
|
||||
path: log_dir,
|
||||
file_name: Some("cc-switch".into()),
|
||||
}),
|
||||
])
|
||||
.rotation_strategy(RotationStrategy::KeepAll)
|
||||
.max_file_size(5_000_000) // 5MB 单文件上限
|
||||
.timezone_strategy(TimezoneStrategy::UseLocal)
|
||||
.build(),
|
||||
)?;
|
||||
|
||||
// 清理旧日志文件,只保留最近 2 个
|
||||
panic_hook::cleanup_old_logs();
|
||||
}
|
||||
|
||||
// 预先刷新 Store 覆盖配置,确保 AppState 初始化时可读取到最新路径
|
||||
app_store::refresh_app_config_dir_override(app.handle());
|
||||
|
||||
// 初始化数据库
|
||||
let app_config_dir = crate::config::get_app_config_dir();
|
||||
let db_path = app_config_dir.join("cc-switch.db");
|
||||
@@ -483,17 +423,6 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 2.1 OpenCode 供应商导入(累加式模式,需特殊处理)
|
||||
// OpenCode 与其他应用不同:配置文件中可同时存在多个供应商
|
||||
// 需要遍历 provider 字段下的每个供应商并导入
|
||||
match crate::services::provider::import_opencode_providers_from_live(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} OpenCode provider(s) from live config");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No OpenCode providers found to import"),
|
||||
Err(e) => log::debug!("○ Failed to import OpenCode providers: {e}"),
|
||||
}
|
||||
|
||||
// 3. 导入 MCP 服务器配置(表空时触发)
|
||||
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
|
||||
log::info!("MCP table empty, importing from live configurations...");
|
||||
@@ -521,14 +450,6 @@ pub fn run() {
|
||||
Ok(_) => log::debug!("○ No Gemini MCP servers found to import"),
|
||||
Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"),
|
||||
}
|
||||
|
||||
match crate::services::mcp::McpService::import_from_opencode(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} MCP server(s) from OpenCode");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No OpenCode MCP servers found to import"),
|
||||
Err(e) => log::warn!("✗ Failed to import OpenCode MCP: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 导入提示词文件(表空时触发)
|
||||
@@ -608,7 +529,7 @@ pub fn run() {
|
||||
|
||||
for (i, url) in urls.iter().enumerate() {
|
||||
let url_str = url.as_str();
|
||||
log::debug!(" URL[{i}]: {}", redact_url_for_log(url_str));
|
||||
log::info!(" URL[{i}]: {url_str}");
|
||||
|
||||
if handle_deeplink_url(&app_handle, url_str, true, "on_open_url") {
|
||||
break; // Process only first ccswitch:// URL
|
||||
@@ -664,37 +585,6 @@ pub fn run() {
|
||||
let skill_service = SkillService::new();
|
||||
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
||||
|
||||
// 初始化全局出站代理 HTTP 客户端
|
||||
{
|
||||
let db = &app.state::<AppState>().db;
|
||||
let proxy_url = db.get_global_proxy_url().ok().flatten();
|
||||
|
||||
if let Err(e) = crate::proxy::http_client::init(proxy_url.as_deref()) {
|
||||
log::error!(
|
||||
"[GlobalProxy] [GP-005] Failed to initialize with saved config: {e}"
|
||||
);
|
||||
|
||||
// 清除无效的代理配置
|
||||
if proxy_url.is_some() {
|
||||
log::warn!(
|
||||
"[GlobalProxy] [GP-006] Clearing invalid proxy config from database"
|
||||
);
|
||||
if let Err(clear_err) = db.set_global_proxy_url(None) {
|
||||
log::error!(
|
||||
"[GlobalProxy] [GP-007] Failed to clear invalid config: {clear_err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 使用直连模式重新初始化
|
||||
if let Err(fallback_err) = crate::proxy::http_client::init(None) {
|
||||
log::error!(
|
||||
"[GlobalProxy] [GP-008] Failed to initialize direct connection: {fallback_err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 异常退出恢复 + 代理状态自动恢复
|
||||
let app_handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
@@ -732,7 +622,6 @@ pub fn run() {
|
||||
commands::add_provider,
|
||||
commands::update_provider,
|
||||
commands::delete_provider,
|
||||
commands::remove_provider_from_live_config,
|
||||
commands::switch_provider,
|
||||
commands::import_default_config,
|
||||
commands::get_claude_config_status,
|
||||
@@ -755,8 +644,6 @@ pub fn run() {
|
||||
commands::read_live_provider_settings,
|
||||
commands::get_settings,
|
||||
commands::save_settings,
|
||||
commands::get_rectifier_config,
|
||||
commands::set_rectifier_config,
|
||||
commands::restart_app,
|
||||
commands::check_for_updates,
|
||||
commands::is_portable_mode,
|
||||
@@ -887,23 +774,12 @@ pub fn run() {
|
||||
commands::get_stream_check_config,
|
||||
commands::save_stream_check_config,
|
||||
commands::get_tool_versions,
|
||||
// Provider terminal
|
||||
commands::open_provider_terminal,
|
||||
// Universal Provider management
|
||||
commands::get_universal_providers,
|
||||
commands::get_universal_provider,
|
||||
commands::upsert_universal_provider,
|
||||
commands::delete_universal_provider,
|
||||
commands::sync_universal_provider,
|
||||
// OpenCode specific
|
||||
commands::import_opencode_providers_from_live,
|
||||
commands::get_opencode_live_provider_ids,
|
||||
// Global upstream proxy
|
||||
commands::get_global_proxy_url,
|
||||
commands::set_global_proxy_url,
|
||||
commands::test_proxy_url,
|
||||
commands::get_upstream_proxy_status,
|
||||
commands::scan_local_proxies,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
|
||||
@@ -91,7 +91,6 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
|
||||
@@ -235,7 +235,6 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
|
||||
@@ -87,7 +87,6 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: true,
|
||||
opencode: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
|
||||
@@ -8,12 +8,10 @@
|
||||
//! - `claude` - Claude MCP 同步和导入
|
||||
//! - `codex` - Codex MCP 同步和导入(含 TOML 转换)
|
||||
//! - `gemini` - Gemini MCP 同步和导入
|
||||
//! - `opencode` - OpenCode MCP 同步和导入(含 local/remote 格式转换)
|
||||
|
||||
mod claude;
|
||||
mod codex;
|
||||
mod gemini;
|
||||
mod opencode;
|
||||
mod validation;
|
||||
|
||||
// 重新导出公共 API
|
||||
@@ -28,6 +26,3 @@ pub use gemini::{
|
||||
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
|
||||
sync_single_server_to_gemini,
|
||||
};
|
||||
pub use opencode::{
|
||||
import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode,
|
||||
};
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
//! OpenCode MCP 同步和导入模块
|
||||
//!
|
||||
//! 本模块处理 CC Switch 统一 MCP 格式与 OpenCode 格式之间的转换。
|
||||
//!
|
||||
//! ## 格式差异
|
||||
//!
|
||||
//! | CC Switch 统一格式 | OpenCode 格式 |
|
||||
//! |----------------------|---------------------|
|
||||
//! | `type: "stdio"` | `type: "local"` |
|
||||
//! | `command` + `args` | `command: [cmd, ...args]` |
|
||||
//! | `env` | `environment` |
|
||||
//! | `type: "sse"/"http"` | `type: "remote"` |
|
||||
//! | `url` | `url` |
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::app_config::{McpApps, McpServer, MultiAppConfig};
|
||||
use crate::error::AppError;
|
||||
use crate::opencode_config;
|
||||
|
||||
use super::validation::validate_server_spec;
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Check if OpenCode MCP sync should proceed
|
||||
fn should_sync_opencode_mcp() -> bool {
|
||||
// Skip if OpenCode config directory doesn't exist
|
||||
opencode_config::get_opencode_dir().exists()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Format Conversion: CC Switch → OpenCode
|
||||
// ============================================================================
|
||||
|
||||
/// Convert CC Switch unified format to OpenCode format
|
||||
///
|
||||
/// Conversion rules:
|
||||
/// - `stdio` → `local`, command+args → command array, env → environment
|
||||
/// - `sse`/`http` → `remote`, url preserved
|
||||
pub fn convert_to_opencode_format(spec: &Value) -> Result<Value, AppError> {
|
||||
let obj = spec
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::McpValidation("MCP spec must be a JSON object".into()))?;
|
||||
|
||||
let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
|
||||
|
||||
let mut result = serde_json::Map::new();
|
||||
|
||||
match typ {
|
||||
"stdio" => {
|
||||
// Convert to "local" type
|
||||
result.insert("type".into(), json!("local"));
|
||||
|
||||
// Merge command and args into a single array
|
||||
let cmd = obj.get("command").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let mut command_arr = vec![json!(cmd)];
|
||||
|
||||
if let Some(args) = obj.get("args").and_then(|v| v.as_array()) {
|
||||
for arg in args {
|
||||
command_arr.push(arg.clone());
|
||||
}
|
||||
}
|
||||
result.insert("command".into(), Value::Array(command_arr));
|
||||
|
||||
// Convert env → environment
|
||||
if let Some(env) = obj.get("env") {
|
||||
if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
|
||||
result.insert("environment".into(), env.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Add enabled flag (OpenCode expects this)
|
||||
result.insert("enabled".into(), json!(true));
|
||||
}
|
||||
"sse" | "http" => {
|
||||
// Convert to "remote" type
|
||||
result.insert("type".into(), json!("remote"));
|
||||
|
||||
// Preserve url
|
||||
if let Some(url) = obj.get("url") {
|
||||
result.insert("url".into(), url.clone());
|
||||
}
|
||||
|
||||
// Convert headers if present
|
||||
if let Some(headers) = obj.get("headers") {
|
||||
if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
|
||||
{
|
||||
result.insert("headers".into(), headers.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Add enabled flag
|
||||
result.insert("enabled".into(), json!(true));
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::McpValidation(format!(
|
||||
"Unknown MCP type: {}",
|
||||
typ
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Value::Object(result))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Format Conversion: OpenCode → CC Switch
|
||||
// ============================================================================
|
||||
|
||||
/// Convert OpenCode format to CC Switch unified format
|
||||
///
|
||||
/// Conversion rules:
|
||||
/// - `local` → `stdio`, command array → command+args, environment → env
|
||||
/// - `remote` → `sse`, url preserved
|
||||
pub fn convert_from_opencode_format(spec: &Value) -> Result<Value, AppError> {
|
||||
let obj = spec
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::McpValidation("OpenCode MCP spec must be a JSON object".into()))?;
|
||||
|
||||
let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("local");
|
||||
|
||||
let mut result = serde_json::Map::new();
|
||||
|
||||
match typ {
|
||||
"local" => {
|
||||
// Convert to "stdio" type
|
||||
result.insert("type".into(), json!("stdio"));
|
||||
|
||||
// Split command array into command and args
|
||||
if let Some(cmd_arr) = obj.get("command").and_then(|v| v.as_array()) {
|
||||
if !cmd_arr.is_empty() {
|
||||
// First element is the command
|
||||
if let Some(cmd) = cmd_arr.first().and_then(|v| v.as_str()) {
|
||||
result.insert("command".into(), json!(cmd));
|
||||
}
|
||||
|
||||
// Rest are args
|
||||
if cmd_arr.len() > 1 {
|
||||
let args: Vec<Value> = cmd_arr[1..].to_vec();
|
||||
result.insert("args".into(), Value::Array(args));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert environment → env
|
||||
if let Some(env) = obj.get("environment") {
|
||||
if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
|
||||
result.insert("env".into(), env.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
"remote" => {
|
||||
// Convert to "sse" type (default remote protocol)
|
||||
result.insert("type".into(), json!("sse"));
|
||||
|
||||
// Preserve url
|
||||
if let Some(url) = obj.get("url") {
|
||||
result.insert("url".into(), url.clone());
|
||||
}
|
||||
|
||||
// Preserve headers
|
||||
if let Some(headers) = obj.get("headers") {
|
||||
if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
|
||||
{
|
||||
result.insert("headers".into(), headers.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::McpValidation(format!(
|
||||
"Unknown OpenCode MCP type: {}",
|
||||
typ
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Value::Object(result))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public API: Sync Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Sync a single MCP server to OpenCode live config
|
||||
pub fn sync_single_server_to_opencode(
|
||||
_config: &MultiAppConfig,
|
||||
id: &str,
|
||||
server_spec: &Value,
|
||||
) -> Result<(), AppError> {
|
||||
if !should_sync_opencode_mcp() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Convert to OpenCode format
|
||||
let opencode_spec = convert_to_opencode_format(server_spec)?;
|
||||
|
||||
// Set in OpenCode config
|
||||
opencode_config::set_mcp_server(id, opencode_spec)
|
||||
}
|
||||
|
||||
/// Remove a single MCP server from OpenCode live config
|
||||
pub fn remove_server_from_opencode(id: &str) -> Result<(), AppError> {
|
||||
if !should_sync_opencode_mcp() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
opencode_config::remove_mcp_server(id)
|
||||
}
|
||||
|
||||
/// Import MCP servers from OpenCode config to unified structure
|
||||
///
|
||||
/// Existing servers will have OpenCode app enabled without overwriting other fields.
|
||||
pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||
let mcp_map = opencode_config::get_mcp_servers()?;
|
||||
if mcp_map.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Ensure servers map exists
|
||||
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
|
||||
|
||||
let mut changed = 0;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for (id, spec) in mcp_map {
|
||||
// Convert from OpenCode format to unified format
|
||||
let unified_spec = match convert_from_opencode_format(&spec) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::warn!("Skip invalid OpenCode MCP server '{}': {}", id, e);
|
||||
errors.push(format!("{}: {}", id, e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Validate the converted spec
|
||||
if let Err(e) = validate_server_spec(&unified_spec) {
|
||||
log::warn!("Skip invalid MCP server '{}' after conversion: {}", id, e);
|
||||
errors.push(format!("{}: {}", id, e));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(existing) = servers.get_mut(&id) {
|
||||
// Existing server: just enable OpenCode app
|
||||
if !existing.apps.opencode {
|
||||
existing.apps.opencode = true;
|
||||
changed += 1;
|
||||
log::info!("MCP server '{}' enabled for OpenCode", id);
|
||||
}
|
||||
} else {
|
||||
// New server: default to only OpenCode enabled
|
||||
servers.insert(
|
||||
id.clone(),
|
||||
McpServer {
|
||||
id: id.clone(),
|
||||
name: id.clone(),
|
||||
server: unified_spec,
|
||||
apps: McpApps {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: true,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
);
|
||||
changed += 1;
|
||||
log::info!("Imported new MCP server '{}' from OpenCode", id);
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
log::warn!(
|
||||
"Import completed with {} failures: {:?}",
|
||||
errors.len(),
|
||||
errors
|
||||
);
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_convert_stdio_to_local() {
|
||||
let spec = json!({
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
|
||||
"env": { "HOME": "/Users/test" }
|
||||
});
|
||||
|
||||
let result = convert_to_opencode_format(&spec).unwrap();
|
||||
assert_eq!(result["type"], "local");
|
||||
assert_eq!(result["command"][0], "npx");
|
||||
assert_eq!(result["command"][1], "-y");
|
||||
assert_eq!(
|
||||
result["command"][2],
|
||||
"@modelcontextprotocol/server-filesystem"
|
||||
);
|
||||
assert_eq!(result["environment"]["HOME"], "/Users/test");
|
||||
assert_eq!(result["enabled"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_sse_to_remote() {
|
||||
let spec = json!({
|
||||
"type": "sse",
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": { "Authorization": "Bearer xxx" }
|
||||
});
|
||||
|
||||
let result = convert_to_opencode_format(&spec).unwrap();
|
||||
assert_eq!(result["type"], "remote");
|
||||
assert_eq!(result["url"], "https://example.com/mcp");
|
||||
assert_eq!(result["headers"]["Authorization"], "Bearer xxx");
|
||||
assert_eq!(result["enabled"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_local_to_stdio() {
|
||||
let spec = json!({
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "@modelcontextprotocol/server-filesystem"],
|
||||
"environment": { "HOME": "/Users/test" }
|
||||
});
|
||||
|
||||
let result = convert_from_opencode_format(&spec).unwrap();
|
||||
assert_eq!(result["type"], "stdio");
|
||||
assert_eq!(result["command"], "npx");
|
||||
assert_eq!(result["args"][0], "-y");
|
||||
assert_eq!(result["args"][1], "@modelcontextprotocol/server-filesystem");
|
||||
assert_eq!(result["env"]["HOME"], "/Users/test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_remote_to_sse() {
|
||||
let spec = json!({
|
||||
"type": "remote",
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": { "Authorization": "Bearer xxx" }
|
||||
});
|
||||
|
||||
let result = convert_from_opencode_format(&spec).unwrap();
|
||||
assert_eq!(result["type"], "sse");
|
||||
assert_eq!(result["url"], "https://example.com/mcp");
|
||||
assert_eq!(result["headers"]["Authorization"], "Bearer xxx");
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
//! 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;
|
||||
use crate::settings::get_opencode_override_dir;
|
||||
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;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Windows: %APPDATA%\opencode
|
||||
dirs::data_dir()
|
||||
.map(|d| d.join("opencode"))
|
||||
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Unix: ~/.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"
|
||||
}));
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||
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
|
||||
.get("provider")
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// 设置供应商配置(原始 JSON)
|
||||
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
|
||||
let mut full_config = read_opencode_config()?;
|
||||
|
||||
if full_config.get("provider").is_none() {
|
||||
full_config["provider"] = json!({});
|
||||
}
|
||||
|
||||
if let Some(providers) = full_config
|
||||
.get_mut("provider")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
providers.insert(id.to_string(), config);
|
||||
}
|
||||
|
||||
write_opencode_config(&full_config)
|
||||
}
|
||||
|
||||
/// 删除供应商配置
|
||||
pub fn remove_provider(id: &str) -> Result<(), AppError> {
|
||||
let mut config = read_opencode_config()?;
|
||||
|
||||
if let Some(providers) = config.get_mut("provider").and_then(|v| v.as_object_mut()) {
|
||||
providers.remove(id);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
for (id, value) in providers {
|
||||
match serde_json::from_value::<OpenCodeProviderConfig>(value.clone()) {
|
||||
Ok(config) => {
|
||||
result.insert(id, config);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to parse provider '{}': {}", id, e);
|
||||
// Skip invalid providers but continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
.get("mcp")
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// 设置 MCP 服务器配置
|
||||
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
|
||||
let mut full_config = read_opencode_config()?;
|
||||
|
||||
if full_config.get("mcp").is_none() {
|
||||
full_config["mcp"] = json!({});
|
||||
}
|
||||
|
||||
if let Some(mcp) = full_config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
|
||||
mcp.insert(id.to_string(), config);
|
||||
}
|
||||
|
||||
write_opencode_config(&full_config)
|
||||
}
|
||||
|
||||
/// 删除 MCP 服务器配置
|
||||
pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
|
||||
let mut config = read_opencode_config()?;
|
||||
|
||||
if let Some(mcp) = config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
|
||||
mcp.remove(id);
|
||||
}
|
||||
|
||||
write_opencode_config(&config)
|
||||
}
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
//! Panic Hook 模块
|
||||
//!
|
||||
//! 在应用崩溃时捕获 panic 信息并记录到 `<app_config_dir>/crash.log` 文件中(默认 `~/.cc-switch/crash.log`)。
|
||||
//! 便于用户和开发者诊断闪退问题。
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::panic;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// 应用版本号(从 Cargo.toml 读取)
|
||||
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// 日志文件保留数量
|
||||
const LOG_FILES_TO_KEEP: usize = 2;
|
||||
|
||||
static APP_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
pub fn init_app_config_dir(dir: PathBuf) {
|
||||
let _ = APP_CONFIG_DIR.set(dir);
|
||||
}
|
||||
|
||||
/// 获取默认应用配置目录(不会 panic)
|
||||
fn default_app_config_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".cc-switch")
|
||||
}
|
||||
|
||||
/// 获取应用配置目录(优先使用初始化时写入的值;不会 panic)
|
||||
fn get_app_config_dir() -> PathBuf {
|
||||
APP_CONFIG_DIR
|
||||
.get()
|
||||
.cloned()
|
||||
.unwrap_or_else(default_app_config_dir)
|
||||
}
|
||||
|
||||
/// 获取崩溃日志文件路径
|
||||
fn get_crash_log_path() -> PathBuf {
|
||||
get_app_config_dir().join("crash.log")
|
||||
}
|
||||
|
||||
/// 获取日志目录路径
|
||||
pub fn get_log_dir() -> PathBuf {
|
||||
get_app_config_dir().join("logs")
|
||||
}
|
||||
|
||||
/// 清理旧日志文件,只保留最近 N 个
|
||||
///
|
||||
/// 在应用启动时调用,确保日志文件不会无限增长。
|
||||
pub fn cleanup_old_logs() {
|
||||
let log_dir = get_log_dir();
|
||||
|
||||
if !log_dir.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 读取目录中的所有 .log 文件
|
||||
let mut log_files: Vec<_> = match std::fs::read_dir(&log_dir) {
|
||||
Ok(entries) => entries
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().map(|ext| ext == "log").unwrap_or(false))
|
||||
.collect(),
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
// 如果文件数量不超过保留数量,无需清理
|
||||
if log_files.len() <= LOG_FILES_TO_KEEP {
|
||||
return;
|
||||
}
|
||||
|
||||
// 按修改时间排序(最新的在前)
|
||||
log_files.sort_by(|a, b| {
|
||||
let time_a = a.metadata().and_then(|m| m.modified()).ok();
|
||||
let time_b = b.metadata().and_then(|m| m.modified()).ok();
|
||||
time_b.cmp(&time_a) // 降序
|
||||
});
|
||||
|
||||
// 删除多余的旧文件
|
||||
for old_file in log_files.into_iter().skip(LOG_FILES_TO_KEEP) {
|
||||
if let Err(e) = std::fs::remove_file(&old_file) {
|
||||
log::warn!("清理旧日志文件失败 {}: {e}", old_file.display());
|
||||
} else {
|
||||
log::info!("已清理旧日志文件: {}", old_file.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全获取环境信息(不会 panic)
|
||||
fn get_system_info() -> String {
|
||||
let os = std::env::consts::OS;
|
||||
let arch = std::env::consts::ARCH;
|
||||
let family = std::env::consts::FAMILY;
|
||||
|
||||
// 安全获取当前工作目录
|
||||
let cwd = std::env::current_dir()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|_| "unknown".to_string());
|
||||
|
||||
// 安全获取当前线程信息
|
||||
let thread = std::thread::current();
|
||||
let thread_name = thread.name().unwrap_or("unnamed");
|
||||
let thread_id = format!("{:?}", thread.id());
|
||||
|
||||
format!(
|
||||
"OS: {os} ({family})\n\
|
||||
Arch: {arch}\n\
|
||||
App Version: {APP_VERSION}\n\
|
||||
Working Dir: {cwd}\n\
|
||||
Thread: {thread_name} (ID: {thread_id})"
|
||||
)
|
||||
}
|
||||
|
||||
/// 设置 panic hook,捕获崩溃信息并写入日志文件
|
||||
///
|
||||
/// 在应用启动时调用此函数,确保任何 panic 都会被记录。
|
||||
/// 日志格式包含:
|
||||
/// - 时间戳
|
||||
/// - 应用版本和系统信息
|
||||
/// - Panic 信息
|
||||
/// - 发生位置(文件:行号)
|
||||
/// - Backtrace(完整调用栈)
|
||||
pub fn setup_panic_hook() {
|
||||
// 启用 backtrace(确保 release 模式也能捕获)
|
||||
if std::env::var("RUST_BACKTRACE").is_err() {
|
||||
std::env::set_var("RUST_BACKTRACE", "1");
|
||||
}
|
||||
|
||||
let default_hook = panic::take_hook();
|
||||
|
||||
panic::set_hook(Box::new(move |panic_info| {
|
||||
let log_path = get_crash_log_path();
|
||||
|
||||
// 确保目录存在
|
||||
if let Some(parent) = log_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
// 构建崩溃信息(使用 catch_unwind 保护时间格式化,避免嵌套 panic)
|
||||
let timestamp = std::panic::catch_unwind(|| {
|
||||
chrono::Local::now()
|
||||
.format("%Y-%m-%d %H:%M:%S%.3f")
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_else(|_| {
|
||||
// chrono panic 时回退到 unix timestamp
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| format!("unix:{}.{:03}", d.as_secs(), d.subsec_millis()))
|
||||
.unwrap_or_else(|_| "unknown".to_string())
|
||||
});
|
||||
|
||||
// 获取系统信息
|
||||
let system_info = std::panic::catch_unwind(get_system_info)
|
||||
.unwrap_or_else(|_| "Failed to get system info".to_string());
|
||||
|
||||
// 获取 panic 消息(尝试多种方式提取)
|
||||
let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
|
||||
s.to_string()
|
||||
} else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
// 尝试使用 Display trait
|
||||
format!("{panic_info}")
|
||||
};
|
||||
|
||||
// 获取位置信息
|
||||
let location = if let Some(loc) = panic_info.location() {
|
||||
format!(
|
||||
"File: {}\n Line: {}\n Column: {}",
|
||||
loc.file(),
|
||||
loc.line(),
|
||||
loc.column()
|
||||
)
|
||||
} else {
|
||||
"Unknown location".to_string()
|
||||
};
|
||||
|
||||
// 捕获 backtrace(完整调用栈)
|
||||
let backtrace = std::backtrace::Backtrace::force_capture();
|
||||
let backtrace_str = format!("{backtrace}");
|
||||
|
||||
// 格式化日志条目
|
||||
let separator = "=".repeat(80);
|
||||
let sub_separator = "-".repeat(40);
|
||||
let crash_entry = format!(
|
||||
r#"
|
||||
{separator}
|
||||
[CRASH REPORT] {timestamp}
|
||||
{separator}
|
||||
|
||||
{sub_separator}
|
||||
System Information
|
||||
{sub_separator}
|
||||
{system_info}
|
||||
|
||||
{sub_separator}
|
||||
Error Details
|
||||
{sub_separator}
|
||||
Message: {message}
|
||||
|
||||
Location: {location}
|
||||
|
||||
{sub_separator}
|
||||
Stack Trace (Backtrace)
|
||||
{sub_separator}
|
||||
{backtrace_str}
|
||||
|
||||
{separator}
|
||||
"#
|
||||
);
|
||||
|
||||
// 写入文件(追加模式)
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&log_path) {
|
||||
let _ = file.write_all(crash_entry.as_bytes());
|
||||
let _ = file.flush();
|
||||
|
||||
// 记录日志文件位置到 stderr
|
||||
eprintln!("\n[CC-Switch] Crash log saved to: {}", log_path.display());
|
||||
}
|
||||
|
||||
// 同时输出到 stderr(便于开发调试)
|
||||
eprintln!("{crash_entry}");
|
||||
|
||||
// 调用默认 hook
|
||||
default_hook(panic_info);
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_crash_log_path() {
|
||||
let path = get_crash_log_path();
|
||||
assert!(path.ends_with("crash.log"));
|
||||
assert!(path.to_string_lossy().contains(".cc-switch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_info() {
|
||||
let info = get_system_info();
|
||||
assert!(info.contains("OS:"));
|
||||
assert!(info.contains("Arch:"));
|
||||
assert!(info.contains("App Version:"));
|
||||
}
|
||||
}
|
||||
@@ -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::opencode_config::get_opencode_dir;
|
||||
|
||||
/// 返回指定应用所使用的提示词文件路径。
|
||||
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
@@ -13,14 +12,12 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
|
||||
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
|
||||
AppType::Gemini => get_gemini_dir(),
|
||||
AppType::OpenCode => get_opencode_dir(),
|
||||
};
|
||||
|
||||
let filename = match app {
|
||||
AppType::Claude => "CLAUDE.md",
|
||||
AppType::Codex => "AGENTS.md",
|
||||
AppType::Gemini => "GEMINI.md",
|
||||
AppType::OpenCode => "AGENTS.md",
|
||||
};
|
||||
|
||||
Ok(base_dir.join(filename))
|
||||
|
||||
@@ -98,10 +98,6 @@ pub struct UsageScript {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "userId")]
|
||||
pub user_id: Option<String>,
|
||||
/// 模板类型(用于后端判断验证规则)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "templateType")]
|
||||
pub template_type: Option<String>,
|
||||
/// 自动查询间隔(单位:分钟,0 表示禁用自动查询)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "autoQueryInterval")]
|
||||
@@ -151,9 +147,6 @@ pub struct ProviderMeta {
|
||||
/// 用量查询脚本配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage_script: Option<UsageScript>,
|
||||
/// 请求地址管理:测速后自动选择最佳端点
|
||||
#[serde(rename = "endpointAutoSelect", skip_serializing_if = "Option::is_none")]
|
||||
pub endpoint_auto_select: Option<bool>,
|
||||
/// 合作伙伴标记(前端使用 isPartner,保持字段名一致)
|
||||
#[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
|
||||
pub is_partner: Option<bool>,
|
||||
@@ -462,95 +455,3 @@ requires_openai_auth = true"#
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OpenCode 供应商配置结构
|
||||
// ============================================================================
|
||||
|
||||
/// OpenCode 供应商的 settings_config 结构
|
||||
///
|
||||
/// OpenCode 使用 AI SDK 包名来指定供应商类型,与其他应用的配置格式不同。
|
||||
/// 配置示例:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "npm": "@ai-sdk/openai-compatible",
|
||||
/// "options": { "baseURL": "https://api.example.com/v1", "apiKey": "sk-xxx" },
|
||||
/// "models": { "gpt-4o": { "name": "GPT-4o" } }
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenCodeProviderConfig {
|
||||
/// AI SDK 包名,如 "@ai-sdk/openai-compatible", "@ai-sdk/anthropic"
|
||||
pub npm: String,
|
||||
|
||||
/// 供应商名称(可选,用于显示)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
|
||||
/// 供应商选项(API 密钥、基础 URL 等)
|
||||
#[serde(default)]
|
||||
pub options: OpenCodeProviderOptions,
|
||||
|
||||
/// 模型定义映射
|
||||
#[serde(default)]
|
||||
pub models: HashMap<String, OpenCodeModel>,
|
||||
}
|
||||
|
||||
impl Default for OpenCodeProviderConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
npm: "@ai-sdk/openai-compatible".to_string(),
|
||||
name: None,
|
||||
options: OpenCodeProviderOptions::default(),
|
||||
models: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenCode 供应商选项
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct OpenCodeProviderOptions {
|
||||
/// API 基础 URL
|
||||
#[serde(rename = "baseURL", skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
|
||||
/// API 密钥(支持环境变量引用,如 "{env:API_KEY}")
|
||||
#[serde(rename = "apiKey", skip_serializing_if = "Option::is_none")]
|
||||
pub api_key: Option<String>,
|
||||
|
||||
/// 自定义请求头
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub headers: Option<HashMap<String, String>>,
|
||||
|
||||
/// 额外选项(timeout, setCacheKey 等)
|
||||
/// 使用 flatten 捕获所有未明确定义的字段
|
||||
#[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// OpenCode 模型定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenCodeModel {
|
||||
/// 模型显示名称
|
||||
pub name: String,
|
||||
|
||||
/// 模型限制(上下文和输出 token 数)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<OpenCodeModelLimit>,
|
||||
|
||||
/// 模型额外选项(provider 路由等)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub options: Option<HashMap<String, Value>>,
|
||||
}
|
||||
|
||||
/// OpenCode 模型限制
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct OpenCodeModelLimit {
|
||||
/// 上下文 token 限制
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<u64>,
|
||||
|
||||
/// 输出 token 限制
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
//!
|
||||
//! 实现熔断器模式,用于防止向不健康的供应商发送请求
|
||||
|
||||
use super::log_codes::cb as log_cb;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -107,6 +106,7 @@ impl CircuitBreaker {
|
||||
/// 更新熔断器配置(热更新,不重置状态)
|
||||
pub async fn update_config(&self, new_config: CircuitBreakerConfig) {
|
||||
*self.config.write().await = new_config;
|
||||
log::debug!("Circuit breaker config updated");
|
||||
}
|
||||
|
||||
/// 判断当前 Provider 是否“可被纳入候选链路”
|
||||
@@ -128,8 +128,7 @@ impl CircuitBreaker {
|
||||
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
log::info!(
|
||||
"[{}] 熔断器 Open → HalfOpen (超时恢复)",
|
||||
log_cb::OPEN_TO_HALF_OPEN
|
||||
"Circuit breaker transitioning from Open to HalfOpen (timeout reached)"
|
||||
);
|
||||
self.transition_to_half_open().await;
|
||||
return true;
|
||||
@@ -156,8 +155,7 @@ impl CircuitBreaker {
|
||||
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
log::info!(
|
||||
"[{}] 熔断器 Open → HalfOpen (超时恢复)",
|
||||
log_cb::OPEN_TO_HALF_OPEN
|
||||
"Circuit breaker transitioning from Open to HalfOpen (timeout reached)"
|
||||
);
|
||||
self.transition_to_half_open().await;
|
||||
|
||||
@@ -199,17 +197,25 @@ impl CircuitBreaker {
|
||||
self.consecutive_failures.store(0, Ordering::SeqCst);
|
||||
self.total_requests.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
if state == CircuitState::HalfOpen {
|
||||
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
|
||||
if successes >= config.success_threshold {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
log::info!(
|
||||
"[{}] 熔断器 HalfOpen → Closed (恢复正常)",
|
||||
log_cb::HALF_OPEN_TO_CLOSED
|
||||
match state {
|
||||
CircuitState::HalfOpen => {
|
||||
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
log::debug!(
|
||||
"Circuit breaker HalfOpen: {} consecutive successes (threshold: {})",
|
||||
successes,
|
||||
config.success_threshold
|
||||
);
|
||||
self.transition_to_closed().await;
|
||||
|
||||
if successes >= config.success_threshold {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
log::info!("Circuit breaker transitioning from HalfOpen to Closed (success threshold reached)");
|
||||
self.transition_to_closed().await;
|
||||
}
|
||||
}
|
||||
CircuitState::Closed => {
|
||||
log::debug!("Circuit breaker Closed: request succeeded");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,14 +236,18 @@ impl CircuitBreaker {
|
||||
// 重置成功计数
|
||||
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||
|
||||
log::debug!(
|
||||
"Circuit breaker {:?}: {} consecutive failures (threshold: {})",
|
||||
state,
|
||||
failures,
|
||||
config.failure_threshold
|
||||
);
|
||||
|
||||
// 检查是否应该打开熔断器
|
||||
match state {
|
||||
CircuitState::HalfOpen => {
|
||||
// HalfOpen 状态下失败,立即转为 Open
|
||||
log::warn!(
|
||||
"[{}] 熔断器 HalfOpen 探测失败 → Open",
|
||||
log_cb::HALF_OPEN_PROBE_FAILED
|
||||
);
|
||||
log::warn!("Circuit breaker HalfOpen probe failed, transitioning to Open");
|
||||
drop(config);
|
||||
self.transition_to_open().await;
|
||||
}
|
||||
@@ -245,8 +255,9 @@ impl CircuitBreaker {
|
||||
// 检查连续失败次数
|
||||
if failures >= config.failure_threshold {
|
||||
log::warn!(
|
||||
"[{}] 熔断器触发: 连续失败 {failures} 次 → Open",
|
||||
log_cb::TRIGGERED_FAILURES
|
||||
"Circuit breaker opening due to {} consecutive failures (threshold: {})",
|
||||
failures,
|
||||
config.failure_threshold
|
||||
);
|
||||
drop(config); // 释放读锁再转换状态
|
||||
self.transition_to_open().await;
|
||||
@@ -257,12 +268,18 @@ impl CircuitBreaker {
|
||||
|
||||
if total >= config.min_requests {
|
||||
let error_rate = failed as f64 / total as f64;
|
||||
log::debug!(
|
||||
"Circuit breaker error rate: {:.2}% ({}/{} requests)",
|
||||
error_rate * 100.0,
|
||||
failed,
|
||||
total
|
||||
);
|
||||
|
||||
if error_rate >= config.error_rate_threshold {
|
||||
log::warn!(
|
||||
"[{}] 熔断器触发: 错误率 {:.1}% → Open",
|
||||
log_cb::TRIGGERED_ERROR_RATE,
|
||||
error_rate * 100.0
|
||||
"Circuit breaker opening due to high error rate: {:.2}% (threshold: {:.2}%)",
|
||||
error_rate * 100.0,
|
||||
config.error_rate_threshold * 100.0
|
||||
);
|
||||
drop(config); // 释放读锁再转换状态
|
||||
self.transition_to_open().await;
|
||||
@@ -295,16 +312,22 @@ impl CircuitBreaker {
|
||||
/// 重置熔断器(手动恢复)
|
||||
#[allow(dead_code)]
|
||||
pub async fn reset(&self) {
|
||||
log::info!("[{}] 熔断器手动重置 → Closed", log_cb::MANUAL_RESET);
|
||||
log::info!("Circuit breaker manually reset to Closed state");
|
||||
self.transition_to_closed().await;
|
||||
}
|
||||
|
||||
fn allow_half_open_probe(&self) -> AllowResult {
|
||||
// 半开状态限流:只允许有限请求通过进行探测
|
||||
// 默认最多允许 1 个请求(可在配置中扩展)
|
||||
let max_half_open_requests = 1u32;
|
||||
let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
if current < max_half_open_requests {
|
||||
log::debug!(
|
||||
"Circuit breaker HalfOpen: allowing probe request ({}/{})",
|
||||
current + 1,
|
||||
max_half_open_requests
|
||||
);
|
||||
AllowResult {
|
||||
allowed: true,
|
||||
used_half_open_permit: true,
|
||||
@@ -312,6 +335,9 @@ impl CircuitBreaker {
|
||||
} else {
|
||||
// 超过限额,回退计数,拒绝请求
|
||||
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
|
||||
log::debug!(
|
||||
"Circuit breaker HalfOpen: rejecting request (limit reached: {max_half_open_requests})"
|
||||
);
|
||||
AllowResult {
|
||||
allowed: false,
|
||||
used_half_open_permit: false,
|
||||
@@ -319,14 +345,12 @@ impl CircuitBreaker {
|
||||
}
|
||||
}
|
||||
|
||||
/// 仅释放 HalfOpen permit,不影响健康统计
|
||||
///
|
||||
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
|
||||
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
|
||||
pub fn release_half_open_permit(&self) {
|
||||
fn release_half_open_permit(&self) {
|
||||
let mut current = self.half_open_requests.load(Ordering::SeqCst);
|
||||
loop {
|
||||
if current == 0 {
|
||||
// 理论上不应该发生:说明调用方传入的 used_half_open_permit 与实际占用不一致
|
||||
log::debug!("Circuit breaker HalfOpen permit already released (counter=0)");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,12 +17,6 @@ pub enum ProxyError {
|
||||
#[error("地址绑定失败: {0}")]
|
||||
BindFailed(String),
|
||||
|
||||
#[error("停止超时")]
|
||||
StopTimeout,
|
||||
|
||||
#[error("停止失败: {0}")]
|
||||
StopFailed(String),
|
||||
|
||||
#[error("请求转发失败: {0}")]
|
||||
ForwardFailed(String),
|
||||
|
||||
@@ -119,12 +113,6 @@ impl IntoResponse for ProxyError {
|
||||
ProxyError::BindFailed(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||
}
|
||||
ProxyError::StopTimeout => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||
}
|
||||
ProxyError::StopFailed(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||
}
|
||||
ProxyError::ForwardFailed(_) => (StatusCode::BAD_GATEWAY, self.to_string()),
|
||||
ProxyError::NoAvailableProvider => {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
|
||||
|
||||
@@ -86,17 +86,17 @@ impl FailoverSwitchManager {
|
||||
let app_enabled = match self.db.get_proxy_config_for_app(app_type).await {
|
||||
Ok(config) => config.enabled,
|
||||
Err(e) => {
|
||||
log::warn!("[FO-002] 无法读取 {app_type} 配置: {e},跳过切换");
|
||||
log::warn!("[Failover] 无法读取 {app_type} 配置: {e},跳过切换");
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
if !app_enabled {
|
||||
log::debug!("[Failover] {app_type} 未启用代理,跳过切换");
|
||||
log::info!("[Failover] {app_type} 未被代理接管(enabled=false),跳过切换");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
log::info!("[FO-001] 切换: {app_type} → {provider_name}");
|
||||
log::info!("[Failover] 开始切换供应商: {app_type} -> {provider_name} ({provider_id})");
|
||||
|
||||
// 1. 更新数据库 is_current
|
||||
self.db.set_current_provider(app_type, provider_id)?;
|
||||
@@ -117,7 +117,7 @@ impl FailoverSwitchManager {
|
||||
.update_live_backup_from_provider(app_type, &provider)
|
||||
.await
|
||||
{
|
||||
log::warn!("[FO-003] Live 备份更新失败: {e}");
|
||||
log::warn!("[Failover] 更新 Live 备份失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,10 +138,12 @@ impl FailoverSwitchManager {
|
||||
"source": "failover" // 标识来源是故障转移
|
||||
});
|
||||
if let Err(e) = app.emit("provider-switched", event_data) {
|
||||
log::error!("[Failover] 发射事件失败: {e}");
|
||||
log::error!("[Failover] 发射供应商切换事件失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("[Failover] 供应商切换完成: {app_type} -> {provider_name} ({provider_id})");
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,30 +7,36 @@ use super::{
|
||||
error::*,
|
||||
failover_switch::FailoverSwitchManager,
|
||||
provider_router::ProviderRouter,
|
||||
providers::{get_adapter, ProviderAdapter, ProviderType},
|
||||
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
|
||||
types::{ProxyStatus, RectifierConfig},
|
||||
providers::{get_adapter, ProviderAdapter},
|
||||
types::ProxyStatus,
|
||||
ProxyError,
|
||||
};
|
||||
use crate::{app_config::AppType, provider::Provider};
|
||||
use reqwest::Response;
|
||||
use reqwest::{Client, Response};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Headers 黑名单 - 不透传到上游的 Headers
|
||||
///
|
||||
/// 精简版黑名单,只过滤必须覆盖或可能导致问题的 header
|
||||
/// 参考成功透传的请求,保留更多原始 header
|
||||
/// 参考 Claude Code Hub 设计,过滤以下类别:
|
||||
/// 1. 认证类(会被覆盖)
|
||||
/// 2. 连接类(由 HTTP 客户端管理)
|
||||
/// 3. 代理转发类
|
||||
/// 4. CDN/云服务商特定头
|
||||
/// 5. 请求追踪类
|
||||
/// 6. 浏览器特定头(可能被上游检测)
|
||||
///
|
||||
/// 注意:客户端 IP 类(x-forwarded-for, x-real-ip)默认透传
|
||||
const HEADER_BLACKLIST: &[&str] = &[
|
||||
// 认证类(会被覆盖)
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
// 连接类(由 HTTP 客户端管理)
|
||||
// 连接类
|
||||
"host",
|
||||
"content-length",
|
||||
"connection",
|
||||
"transfer-encoding",
|
||||
// 编码类(会被覆盖为 identity)
|
||||
"accept-encoding",
|
||||
@@ -62,9 +68,16 @@ const HEADER_BLACKLIST: &[&str] = &[
|
||||
"x-b3-sampled",
|
||||
"traceparent",
|
||||
"tracestate",
|
||||
// anthropic 特定头单独处理,避免重复
|
||||
// 浏览器特定头(可能被上游检测为非 CLI 请求)
|
||||
"sec-fetch-mode",
|
||||
"sec-fetch-site",
|
||||
"sec-fetch-dest",
|
||||
"sec-ch-ua",
|
||||
"sec-ch-ua-mobile",
|
||||
"sec-ch-ua-platform",
|
||||
"accept-language",
|
||||
// anthropic-beta 单独处理,避免重复
|
||||
"anthropic-beta",
|
||||
"anthropic-version",
|
||||
// 客户端 IP 单独处理(默认透传)
|
||||
"x-forwarded-for",
|
||||
"x-real-ip",
|
||||
@@ -81,6 +94,7 @@ pub struct ForwardError {
|
||||
}
|
||||
|
||||
pub struct RequestForwarder {
|
||||
client: Client,
|
||||
/// 共享的 ProviderRouter(持有熔断器状态)
|
||||
router: Arc<ProviderRouter>,
|
||||
status: Arc<RwLock<ProxyStatus>>,
|
||||
@@ -91,10 +105,6 @@ pub struct RequestForwarder {
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
|
||||
current_provider_id_at_start: String,
|
||||
/// 整流器配置
|
||||
rectifier_config: RectifierConfig,
|
||||
/// 非流式请求超时(秒)
|
||||
non_streaming_timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
impl RequestForwarder {
|
||||
@@ -109,17 +119,32 @@ impl RequestForwarder {
|
||||
current_provider_id_at_start: String,
|
||||
_streaming_first_byte_timeout: u64,
|
||||
_streaming_idle_timeout: u64,
|
||||
rectifier_config: RectifierConfig,
|
||||
) -> Self {
|
||||
// 全局超时设置为 1800 秒(30 分钟),确保业务层超时配置能正常工作
|
||||
// 参考 Claude Code Hub 的 undici 全局超时设计
|
||||
const GLOBAL_TIMEOUT_SECS: u64 = 1800;
|
||||
|
||||
let mut client_builder = Client::builder();
|
||||
if non_streaming_timeout > 0 {
|
||||
// 使用配置的非流式超时
|
||||
client_builder = client_builder.timeout(Duration::from_secs(non_streaming_timeout));
|
||||
} else {
|
||||
// 禁用超时时使用全局超时作为保底
|
||||
client_builder = client_builder.timeout(Duration::from_secs(GLOBAL_TIMEOUT_SECS));
|
||||
}
|
||||
|
||||
let client = client_builder
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
Self {
|
||||
client,
|
||||
router,
|
||||
status,
|
||||
current_providers,
|
||||
failover_manager,
|
||||
app_handle,
|
||||
current_provider_id_at_start,
|
||||
rectifier_config,
|
||||
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +160,7 @@ impl RequestForwarder {
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
endpoint: &str,
|
||||
mut body: Value,
|
||||
body: Value,
|
||||
headers: axum::http::HeaderMap,
|
||||
providers: Vec<Provider>,
|
||||
) -> Result<ForwardResult, ForwardError> {
|
||||
@@ -150,13 +175,16 @@ impl RequestForwarder {
|
||||
});
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[{}] 故障转移链: {} 个可用供应商",
|
||||
app_type_str,
|
||||
providers.len()
|
||||
);
|
||||
|
||||
let mut last_error = None;
|
||||
let mut last_provider = None;
|
||||
let mut attempted_providers = 0usize;
|
||||
|
||||
// 整流器重试标记:确保整流最多触发一次
|
||||
let mut rectifier_retried = false;
|
||||
|
||||
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
|
||||
let bypass_circuit_breaker = providers.len() == 1;
|
||||
|
||||
@@ -175,11 +203,25 @@ impl RequestForwarder {
|
||||
};
|
||||
|
||||
if !allowed {
|
||||
log::debug!(
|
||||
"[{}] Provider {} 熔断器拒绝本次请求,跳过",
|
||||
app_type_str,
|
||||
provider.name
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
attempted_providers += 1;
|
||||
|
||||
log::info!(
|
||||
"[{}] 尝试 {}/{} - 使用Provider: {} (sort_index: {})",
|
||||
app_type_str,
|
||||
attempted_providers,
|
||||
providers.len(),
|
||||
provider.name,
|
||||
provider.sort_index.unwrap_or(999999)
|
||||
);
|
||||
|
||||
// 更新状态中的当前Provider信息
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
@@ -189,14 +231,18 @@ impl RequestForwarder {
|
||||
status.last_request_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
// 转发请求(每个 Provider 只尝试一次,重试由客户端控制)
|
||||
match self
|
||||
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
|
||||
// 成功:记录成功并更新熔断器
|
||||
let _ = self
|
||||
if let Err(e) = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
@@ -205,7 +251,10 @@ impl RequestForwarder {
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to record success: {e}");
|
||||
}
|
||||
|
||||
// 更新当前应用类型使用的 provider
|
||||
{
|
||||
@@ -225,6 +274,12 @@ impl RequestForwarder {
|
||||
self.current_provider_id_at_start.as_str() != provider.id.as_str();
|
||||
if should_switch {
|
||||
status.failover_count += 1;
|
||||
log::info!(
|
||||
"[{}] 代理目标已切换到 Provider: {} (耗时: {}ms)",
|
||||
app_type_str,
|
||||
provider.name,
|
||||
latency
|
||||
);
|
||||
|
||||
// 异步触发供应商切换,更新 UI/托盘,并把“当前供应商”同步为实际使用的 provider
|
||||
let fm = self.failover_manager.clone();
|
||||
@@ -234,7 +289,10 @@ impl RequestForwarder {
|
||||
let at = app_type_str.to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = fm.try_switch(ah.as_ref(), &at, &pid, &pname).await;
|
||||
if let Err(e) = fm.try_switch(ah.as_ref(), &at, &pid, &pname).await
|
||||
{
|
||||
log::error!("[Failover] 切换供应商失败: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
// 重新计算成功率
|
||||
@@ -245,213 +303,23 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[{}] 请求成功 - Provider: {} - {}ms",
|
||||
app_type_str,
|
||||
provider.name,
|
||||
latency
|
||||
);
|
||||
|
||||
return Ok(ForwardResult {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
// 检测是否需要触发整流器(仅 Claude/ClaudeAuth 供应商)
|
||||
let provider_type = ProviderType::from_app_type_and_config(app_type, provider);
|
||||
let is_anthropic_provider = matches!(
|
||||
provider_type,
|
||||
ProviderType::Claude | ProviderType::ClaudeAuth
|
||||
);
|
||||
|
||||
if is_anthropic_provider {
|
||||
let error_message = extract_error_message(&e);
|
||||
if should_rectify_thinking_signature(
|
||||
error_message.as_deref(),
|
||||
&self.rectifier_config,
|
||||
) {
|
||||
// 已经重试过:直接返回错误(不可重试客户端错误)
|
||||
if rectifier_retried {
|
||||
log::warn!("[{app_type_str}] [RECT-005] 整流器已触发过,不再重试");
|
||||
// 释放 HalfOpen 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(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 rectified = rectify_anthropic_request(&mut body);
|
||||
|
||||
// 整流未生效:直接返回错误(不可重试客户端错误)
|
||||
if !rectified.applied {
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-006] 整流器触发但无可整流内容,不做无意义重试"
|
||||
);
|
||||
// 释放 HalfOpen 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(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()),
|
||||
});
|
||||
}
|
||||
|
||||
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()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
|
||||
// 失败:记录失败并更新熔断器
|
||||
let _ = self
|
||||
if let Err(record_err) = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
@@ -460,7 +328,10 @@ impl RequestForwarder {
|
||||
false,
|
||||
Some(e.to_string()),
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to record failure: {record_err}");
|
||||
}
|
||||
|
||||
// 分类错误
|
||||
let category = self.categorize_proxy_error(&e);
|
||||
@@ -475,11 +346,11 @@ impl RequestForwarder {
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"[{}] [FWD-001] Provider {} 失败,切换下一个 ({}/{})",
|
||||
"[{}] Provider {} 失败(可重试): {} - {}ms",
|
||||
app_type_str,
|
||||
provider.name,
|
||||
attempted_providers,
|
||||
providers.len()
|
||||
e,
|
||||
latency
|
||||
);
|
||||
|
||||
last_error = Some(e);
|
||||
@@ -499,6 +370,12 @@ impl RequestForwarder {
|
||||
* 100.0;
|
||||
}
|
||||
}
|
||||
log::error!(
|
||||
"[{}] Provider {} 失败(不可重试): {}",
|
||||
app_type_str,
|
||||
provider.name,
|
||||
e
|
||||
);
|
||||
return Err(ForwardError {
|
||||
error: e,
|
||||
provider: Some(provider.clone()),
|
||||
@@ -537,7 +414,11 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
log::warn!("[{app_type_str}] [FWD-002] 所有 Provider 均失败");
|
||||
log::error!(
|
||||
"[{}] 所有 {} 个供应商都失败了",
|
||||
app_type_str,
|
||||
providers.len()
|
||||
);
|
||||
|
||||
Err(ForwardError {
|
||||
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
|
||||
@@ -556,6 +437,7 @@ impl RequestForwarder {
|
||||
) -> Result<Response, ProxyError> {
|
||||
// 使用适配器提取 base_url
|
||||
let base_url = adapter.extract_base_url(provider)?;
|
||||
log::info!("[{}] base_url: {}", adapter.name(), base_url);
|
||||
|
||||
// 检查是否需要格式转换
|
||||
let needs_transform = adapter.needs_transform(provider);
|
||||
@@ -570,13 +452,36 @@ impl RequestForwarder {
|
||||
// 使用适配器构建 URL
|
||||
let url = adapter.build_url(&base_url, effective_endpoint);
|
||||
|
||||
// 记录原始请求 JSON
|
||||
log::info!(
|
||||
"[{}] ====== 请求开始 ======\n>>> 原始请求 JSON:\n{}",
|
||||
adapter.name(),
|
||||
serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string())
|
||||
);
|
||||
|
||||
// 应用模型映射(独立于格式转换)
|
||||
let (mapped_body, _original_model, _mapped_model) =
|
||||
let (mapped_body, _original_model, mapped_model) =
|
||||
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||
|
||||
if let Some(ref mapped) = mapped_model {
|
||||
log::info!(
|
||||
"[{}] >>> 模型映射后的请求 JSON:\n{}",
|
||||
adapter.name(),
|
||||
serde_json::to_string_pretty(&mapped_body).unwrap_or_default()
|
||||
);
|
||||
log::info!("[{}] 模型已映射到: {}", adapter.name(), mapped);
|
||||
}
|
||||
|
||||
// 转换请求体(如果需要)
|
||||
let request_body = if needs_transform {
|
||||
adapter.transform_request(mapped_body, provider)?
|
||||
log::info!("[{}] 转换请求格式 (Anthropic → OpenAI)", adapter.name());
|
||||
let transformed = adapter.transform_request(mapped_body, provider)?;
|
||||
log::info!(
|
||||
"[{}] >>> 转换后的请求 JSON:\n{}",
|
||||
adapter.name(),
|
||||
serde_json::to_string_pretty(&transformed).unwrap_or_default()
|
||||
);
|
||||
transformed
|
||||
} else {
|
||||
mapped_body
|
||||
};
|
||||
@@ -585,85 +490,157 @@ impl RequestForwarder {
|
||||
// 默认使用空白名单,过滤所有 _ 前缀字段
|
||||
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
|
||||
|
||||
// 每次请求时获取最新的全局 HTTP 客户端(支持热更新代理配置)
|
||||
let client = super::http_client::get();
|
||||
let mut request = client.post(&url);
|
||||
// ========== 请求体日志(截断显示) ==========
|
||||
let body_str = serde_json::to_string_pretty(&filtered_body)
|
||||
.unwrap_or_else(|_| filtered_body.to_string());
|
||||
let body_preview = if body_str.len() > 2000 {
|
||||
format!(
|
||||
"{}...\n[截断,总长度: {} 字符]",
|
||||
&body_str[..2000],
|
||||
body_str.len()
|
||||
)
|
||||
} else {
|
||||
body_str
|
||||
};
|
||||
log::info!(
|
||||
"[{}] ====== 最终请求体 ======\n{}",
|
||||
adapter.name(),
|
||||
body_preview
|
||||
);
|
||||
|
||||
// 只有当 timeout > 0 时才设置请求超时
|
||||
// Duration::ZERO 在 reqwest 中表示"立刻超时"而不是"禁用超时"
|
||||
// 故障转移关闭时会传入 0,此时应该使用 client 的默认超时(600秒)
|
||||
if !self.non_streaming_timeout.is_zero() {
|
||||
request = request.timeout(self.non_streaming_timeout);
|
||||
log::info!(
|
||||
"[{}] 转发请求: {} -> {}",
|
||||
adapter.name(),
|
||||
provider.name,
|
||||
url
|
||||
);
|
||||
|
||||
// 构建请求
|
||||
let mut request = self.client.post(&url);
|
||||
|
||||
// ========== 详细 Headers 日志 ==========
|
||||
log::info!("[{}] ====== 客户端原始 Headers ======", adapter.name());
|
||||
for (key, value) in headers {
|
||||
log::info!(
|
||||
"[{}] {}: {:?}",
|
||||
adapter.name(),
|
||||
key.as_str(),
|
||||
value.to_str().unwrap_or("<binary>")
|
||||
);
|
||||
}
|
||||
|
||||
// 过滤黑名单 Headers,保护隐私并避免冲突
|
||||
let mut filtered_headers: Vec<String> = Vec::new();
|
||||
let mut passed_headers: Vec<(String, String)> = Vec::new();
|
||||
|
||||
for (key, value) in headers {
|
||||
if HEADER_BLACKLIST
|
||||
.iter()
|
||||
.any(|h| key.as_str().eq_ignore_ascii_case(h))
|
||||
{
|
||||
let key_str = key.as_str().to_lowercase();
|
||||
if HEADER_BLACKLIST.contains(&key_str.as_str()) {
|
||||
filtered_headers.push(key_str);
|
||||
continue;
|
||||
}
|
||||
let value_str = value.to_str().unwrap_or("<binary>").to_string();
|
||||
passed_headers.push((key.as_str().to_string(), value_str.clone()));
|
||||
request = request.header(key, value);
|
||||
}
|
||||
|
||||
// 处理 anthropic-beta Header(仅 Claude)
|
||||
// 关键:确保包含 claude-code-20250219 标记,这是上游服务验证请求来源的依据
|
||||
// 如果客户端发送的 beta 标记中没有包含 claude-code-20250219,需要补充
|
||||
if adapter.name() == "Claude" {
|
||||
const CLAUDE_CODE_BETA: &str = "claude-code-20250219";
|
||||
let beta_value = if let Some(beta) = headers.get("anthropic-beta") {
|
||||
if let Ok(beta_str) = beta.to_str() {
|
||||
// 检查是否已包含 claude-code-20250219
|
||||
if beta_str.contains(CLAUDE_CODE_BETA) {
|
||||
beta_str.to_string()
|
||||
} else {
|
||||
// 补充 claude-code-20250219
|
||||
format!("{CLAUDE_CODE_BETA},{beta_str}")
|
||||
}
|
||||
} else {
|
||||
CLAUDE_CODE_BETA.to_string()
|
||||
}
|
||||
} else {
|
||||
// 如果客户端没有发送,使用默认值
|
||||
CLAUDE_CODE_BETA.to_string()
|
||||
};
|
||||
request = request.header("anthropic-beta", &beta_value);
|
||||
if !filtered_headers.is_empty() {
|
||||
log::info!(
|
||||
"[{}] ====== 被过滤的 Headers ({}) ======",
|
||||
adapter.name(),
|
||||
filtered_headers.len()
|
||||
);
|
||||
for h in &filtered_headers {
|
||||
log::info!("[{}] - {}", adapter.name(), h);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 anthropic-beta Header(透传)
|
||||
// 参考 Claude Code Hub 的实现,直接透传客户端的 beta 标记
|
||||
if let Some(beta) = headers.get("anthropic-beta") {
|
||||
if let Ok(beta_str) = beta.to_str() {
|
||||
request = request.header("anthropic-beta", beta_str);
|
||||
passed_headers.push(("anthropic-beta".to_string(), beta_str.to_string()));
|
||||
log::info!("[{}] 透传 anthropic-beta: {}", adapter.name(), beta_str);
|
||||
}
|
||||
}
|
||||
|
||||
// 客户端 IP 透传(默认开启)
|
||||
if let Some(xff) = headers.get("x-forwarded-for") {
|
||||
if let Ok(xff_str) = xff.to_str() {
|
||||
request = request.header("x-forwarded-for", xff_str);
|
||||
passed_headers.push(("x-forwarded-for".to_string(), xff_str.to_string()));
|
||||
log::debug!("[{}] 透传 x-forwarded-for: {}", adapter.name(), xff_str);
|
||||
}
|
||||
}
|
||||
if let Some(real_ip) = headers.get("x-real-ip") {
|
||||
if let Ok(real_ip_str) = real_ip.to_str() {
|
||||
request = request.header("x-real-ip", real_ip_str);
|
||||
passed_headers.push(("x-real-ip".to_string(), real_ip_str.to_string()));
|
||||
log::debug!("[{}] 透传 x-real-ip: {}", adapter.name(), real_ip_str);
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用压缩,避免 gzip 流式响应解析错误
|
||||
// 参考 CCH: undici 在连接提前关闭时会对不完整的 gzip 流抛出错误
|
||||
request = request.header("accept-encoding", "identity");
|
||||
passed_headers.push(("accept-encoding".to_string(), "identity".to_string()));
|
||||
|
||||
// 使用适配器添加认证头
|
||||
if let Some(auth) = adapter.extract_auth(provider) {
|
||||
log::debug!(
|
||||
"[{}] 使用认证: {:?} (key: {})",
|
||||
adapter.name(),
|
||||
auth.strategy,
|
||||
auth.masked_key()
|
||||
);
|
||||
request = adapter.add_auth_headers(request, &auth);
|
||||
// 记录认证头(脱敏)
|
||||
passed_headers.push((
|
||||
"authorization".to_string(),
|
||||
format!("Bearer {}...", &auth.api_key[..8.min(auth.api_key.len())]),
|
||||
));
|
||||
passed_headers.push((
|
||||
"x-api-key".to_string(),
|
||||
format!("{}...", &auth.api_key[..8.min(auth.api_key.len())]),
|
||||
));
|
||||
} else {
|
||||
log::error!(
|
||||
"[{}] 未找到 API Key!Provider: {}",
|
||||
adapter.name(),
|
||||
provider.name
|
||||
);
|
||||
}
|
||||
|
||||
// anthropic-version 统一处理(仅 Claude):优先使用客户端的版本号,否则使用默认值
|
||||
// 注意:只设置一次,避免重复
|
||||
if adapter.name() == "Claude" {
|
||||
let version_str = headers
|
||||
.get("anthropic-version")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("2023-06-01");
|
||||
request = request.header("anthropic-version", version_str);
|
||||
// anthropic-version 透传:优先使用客户端的版本号
|
||||
// 参考 Claude Code Hub:透传客户端值而非固定版本
|
||||
if let Some(version) = headers.get("anthropic-version") {
|
||||
if let Ok(version_str) = version.to_str() {
|
||||
// 覆盖适配器设置的默认版本
|
||||
request = request.header("anthropic-version", version_str);
|
||||
passed_headers.push(("anthropic-version".to_string(), version_str.to_string()));
|
||||
log::info!(
|
||||
"[{}] 透传 anthropic-version: {}",
|
||||
adapter.name(),
|
||||
version_str
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 最终发送的 Headers 日志 ==========
|
||||
log::info!(
|
||||
"[{}] ====== 最终发送的 Headers ({}) ======",
|
||||
adapter.name(),
|
||||
passed_headers.len()
|
||||
);
|
||||
for (k, v) in &passed_headers {
|
||||
log::info!("[{}] {}: {}", adapter.name(), k, v);
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
log::info!("[{}] 发送请求到: {}", adapter.name(), url);
|
||||
let response = request.json(&filtered_body).send().await.map_err(|e| {
|
||||
log::error!("[{}] 请求失败: {}", adapter.name(), e);
|
||||
if e.is_timeout() {
|
||||
ProxyError::Timeout(format!("请求超时: {e}"))
|
||||
} else if e.is_connect() {
|
||||
@@ -675,12 +652,19 @@ impl RequestForwarder {
|
||||
|
||||
// 检查响应状态
|
||||
let status = response.status();
|
||||
log::info!("[{}] 响应状态: {}", adapter.name(), status);
|
||||
|
||||
if status.is_success() {
|
||||
Ok(response)
|
||||
} else {
|
||||
let status_code = status.as_u16();
|
||||
let body_text = response.text().await.ok();
|
||||
log::error!(
|
||||
"[{}] 上游错误 ({}): {:?}",
|
||||
adapter.name(),
|
||||
status_code,
|
||||
body_text
|
||||
);
|
||||
|
||||
Err(ProxyError::UpstreamError {
|
||||
status: status_code,
|
||||
@@ -711,11 +695,3 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 ProxyError 中提取错误消息
|
||||
fn extract_error_message(error: &ProxyError) -> Option<String> {
|
||||
match error {
|
||||
ProxyError::UpstreamError { body, .. } => body.clone(),
|
||||
_ => Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||