Compare commits
296 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fadd8bcdf | |||
| 70e7feb73b | |||
| 60fdc38e6b | |||
| 24a36df140 | |||
| 2c4c2ce83d | |||
| 0020889a8f | |||
| 671cda60d9 | |||
| efa653809b | |||
| 5aa35906d8 | |||
| 4777c99b38 | |||
| 8912216cb2 | |||
| 32149b1eeb | |||
| 8979d964d6 | |||
| 37396b9c70 | |||
| 2c35372ca0 | |||
| 63bb673bf2 | |||
| 058f86aff3 | |||
| 188c94f2e3 | |||
| c049c5f2bb | |||
| c71b030662 | |||
| 2105f2d05b | |||
| 0a9de282a3 | |||
| 0e085aa01a | |||
| 2c95f697cd | |||
| ba97a5f373 | |||
| 86e802bd4b | |||
| 6460c1d5dd | |||
| 6b73e55bfe | |||
| 47aa4c6bee | |||
| 4dc59dff21 | |||
| c8750f5550 | |||
| 22460de976 | |||
| e69c1bd8aa | |||
| a17fa8098b | |||
| ff03ca1e63 | |||
| cce6ae86a5 | |||
| 5376ea042b | |||
| d0431b66ae | |||
| eaddcbedd7 | |||
| 83a5597756 | |||
| bcfc22514c | |||
| 443e23c77e | |||
| f26a01137d | |||
| 1be9c56ec5 | |||
| 2651b65b10 | |||
| 7fdaeacb5b | |||
| 9716eb797f | |||
| 91deaf094e | |||
| 3a548152a9 | |||
| a8f7cda167 | |||
| 8fe5c1041a | |||
| a24753f074 | |||
| 079ee687a8 | |||
| e08c67b88f | |||
| bb2756d0fb | |||
| c87bb43aaa | |||
| 8f58c08d0d | |||
| e6f18ba801 | |||
| 7d495aa772 | |||
| db8180aa31 | |||
| 1586451862 | |||
| bf570b6d2a | |||
| 26c3f05daf | |||
| d303706d51 | |||
| 97495d1550 | |||
| a1537807eb | |||
| f047960a33 | |||
| ace9b38cee | |||
| b67cdbb18c | |||
| c4f1e90893 | |||
| ddbff070d5 | |||
| ca7cb398c2 | |||
| 2fb3b5405a | |||
| ec649e7718 | |||
| 3da5525c79 | |||
| 44ca688253 | |||
| 5fe5ed98be | |||
| b2a9e91d70 | |||
| 4a1a997935 | |||
| c4535c894a | |||
| 64e0cabaa7 | |||
| 8ecb41d25e | |||
| 3e8f84481d | |||
| ba59483b33 | |||
| b6ff721d67 | |||
| 1706c9a26f | |||
| 5bce6d6020 | |||
| 6bdbb4df23 | |||
| 256903ee70 | |||
| f42f73ebb0 | |||
| ec6e113cf2 | |||
| ae837ade02 | |||
| a8fd1f0dd2 | |||
| fa33330b3b | |||
| 18207771ad | |||
| 0cd7d0756c | |||
| bca0997afa | |||
| 0ef8a4153f | |||
| 1b73b26c0e | |||
| 3d514c8250 | |||
| 18e973b920 | |||
| ec20ff4d8c | |||
| e6654bd7f9 | |||
| d4f33224c6 | |||
| 510a013449 | |||
| 1e5bab1cb6 | |||
| 6dcf268317 | |||
| 5efc0cdd5e | |||
| e081c7560c | |||
| 007813e09e | |||
| 9196d07925 | |||
| 1172209f49 | |||
| c49cfa5ac5 | |||
| bfe9bb6a0c | |||
| 5a5ca2a989 | |||
| 5d424b1383 | |||
| c42a0dccaf | |||
| ebe2a665ae | |||
| 1926af4988 | |||
| 1e3a978ecb | |||
| 395783e22a | |||
| 038b74b844 | |||
| f1e5afdae2 | |||
| c9ea13a7ce | |||
| 404ab5a1ae | |||
| 9a8f12a490 | |||
| 6a7c2df2d2 | |||
| 735b3b7d39 | |||
| cbc23764c0 | |||
| 2a541cfda4 | |||
| 5cc864c6aa | |||
| 3cdce2eced | |||
| 8876d67807 | |||
| 493b154a9d | |||
| 56b40bdad2 | |||
| 41267135f5 | |||
| 1fb2d5ed44 | |||
| 622a24ded4 | |||
| 6713368657 | |||
| b1103c8a59 | |||
| bf9228093b | |||
| 420b5d4389 | |||
| b1e4c37f9c | |||
| 74a2e9c08b | |||
| 169db5b6d8 | |||
| 3230b0e094 | |||
| 2347ac0ee0 | |||
| 58c5468bf6 | |||
| 98084d61aa | |||
| a627e1bb50 | |||
| 04a588694b | |||
| f5f7dfed8c | |||
| 5888c56f2a | |||
| c5cadb73bc | |||
| 17948ee031 | |||
| 2643595012 | |||
| 6ac4d1652c | |||
| 3bf37cf0ff | |||
| 526c7406fa | |||
| 9db85dd4dc | |||
| 0f333d9e5b | |||
| ba875552a6 | |||
| 75e7f9d731 | |||
| 7e6074a9a9 | |||
| c229c47c00 | |||
| 6c477a60f9 | |||
| 7eac809689 | |||
| dafa77897b | |||
| 0f959112b1 | |||
| f4c284f86c | |||
| 3878a16c4f | |||
| 00f78e4546 | |||
| 1ee1e9cb2e | |||
| 7db4b8d976 | |||
| 924ad44e6c | |||
| eecd6a3a2b | |||
| 2a6980417b | |||
| 6a9a0a7a7e | |||
| fcb090dd15 | |||
| ce4f0c02cb | |||
| 4ca4ad6bca | |||
| 964ead5d0d | |||
| 2c90ae3509 | |||
| ea7169abc0 | |||
| 120ac92e77 | |||
| a1e7961af3 | |||
| dc79e3a3da | |||
| 7adc38eb53 | |||
| 3f28648931 | |||
| 90d530fa7a | |||
| 5cd2e9fb88 | |||
| 8bf6ce2c25 | |||
| c41f3dcccb | |||
| 1caa240d6c | |||
| 7ffd3ba165 | |||
| ad131486d2 | |||
| 15c6e3aec8 | |||
| 6783a8a183 | |||
| c1c85b020d | |||
| 34dad04fb6 | |||
| 2526dbc58f | |||
| 014a7c0e30 | |||
| 87190b7af3 | |||
| c3f2f0798d | |||
| 2a842e3b33 | |||
| 1c87c8d253 | |||
| fd46dde3fb | |||
| 60fa9654e1 | |||
| 2ce8a8273c | |||
| 1af20b5f8f | |||
| ea54b7d010 | |||
| f93b21c97e | |||
| a7ca6fb985 | |||
| 67aa275599 | |||
| fe190081eb | |||
| d30562954a | |||
| cc0b9352bc | |||
| 01d8bb53ac | |||
| d38fcd63ea | |||
| 824bf796a8 | |||
| b64bb6cfa1 | |||
| d65513ae7d | |||
| 29e32f73f3 | |||
| eb46ac8592 | |||
| ba336fc416 | |||
| 7fa0a7b166 | |||
| 5e54656d45 | |||
| 74969ae968 | |||
| 1f3627add3 | |||
| 14ee122b27 | |||
| 7aecba14fe | |||
| 99b5f881e8 | |||
| 286bafbd67 | |||
| 6046cf8767 | |||
| c88afa365f | |||
| 93fa5fe29a | |||
| 3d31ad64af | |||
| bb0951552d | |||
| 00e3e6fa70 | |||
| 1ce007622e | |||
| 436f0e8e42 | |||
| 3d69da5b66 | |||
| 0ae9ed5a17 | |||
| 5ff689af82 | |||
| b9412ece0b | |||
| ec303544ca | |||
| 023726c59d | |||
| 8d2c067814 | |||
| a00eb764f7 | |||
| 67bd8f5c11 | |||
| 3051743bd3 | |||
| 883cf0346b | |||
| 1805ed586e | |||
| 98a1305684 | |||
| f79efb86cd | |||
| ed59420a83 | |||
| bfc27349b3 | |||
| 4fc7413ffa | |||
| 12112e9d7d | |||
| 6a6980c82c | |||
| 031ea3a58f | |||
| 9d431cc7ae | |||
| 685a1138e4 | |||
| 154ff4c819 | |||
| 2540f6ba08 | |||
| d32ceb9b80 | |||
| e11c7d84cd | |||
| ea8f2095e2 | |||
| 09f80d82bc | |||
| 2f18d6ec00 | |||
| fafca841cb | |||
| f4b8aed29a | |||
| 9663b4251e | |||
| 9e8abf5f26 | |||
| 32a6de074c | |||
| ac09551563 | |||
| 7ae2a9f556 | |||
| c985db8f3d | |||
| c7b235bb98 | |||
| 1616c63c0b | |||
| 146b42fb68 | |||
| 0ea434a485 | |||
| 21fd7cc9fd | |||
| 434c64f38d | |||
| 6d8e822f8d | |||
| 2fae8c9275 | |||
| 30c763ffe3 | |||
| e4d7999294 | |||
| 34f7139fda | |||
| a85f24f616 | |||
| b9743a463d | |||
| 2f02514a14 | |||
| 75866044bd | |||
| 155532ea8c | |||
| 346f916048 | |||
| 8a05e7bd3d |
@@ -53,7 +53,10 @@ jobs:
|
|||||||
wget \
|
wget \
|
||||||
file \
|
file \
|
||||||
patchelf \
|
patchelf \
|
||||||
libssl-dev
|
libssl-dev \
|
||||||
|
rpm \
|
||||||
|
flatpak \
|
||||||
|
flatpak-builder
|
||||||
# GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0
|
# GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0
|
||||||
sudo apt-get install -y --no-install-recommends \
|
sudo apt-get install -y --no-install-recommends \
|
||||||
libgtk-3-dev \
|
libgtk-3-dev \
|
||||||
@@ -153,7 +156,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Tauri App (Linux)
|
- name: Build Tauri App (Linux)
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
run: pnpm tauri build
|
run: pnpm tauri build -- --bundles appimage,deb,rpm
|
||||||
|
|
||||||
- name: Prepare macOS Assets
|
- name: Prepare macOS Assets
|
||||||
if: runner.os == 'macOS'
|
if: runner.os == 'macOS'
|
||||||
@@ -271,6 +274,28 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "No .deb found (optional)"
|
echo "No .deb found (optional)"
|
||||||
fi
|
fi
|
||||||
|
# 额外上传 .rpm(用于 Fedora/RHEL/openSUSE 等,不参与 Updater)
|
||||||
|
RPM=$(find src-tauri/target/release/bundle -name "*.rpm" | head -1 || true)
|
||||||
|
if [ -n "$RPM" ]; then
|
||||||
|
NEW_RPM="CC-Switch-${VERSION}-Linux.rpm"
|
||||||
|
cp "$RPM" "release-assets/$NEW_RPM"
|
||||||
|
echo "RPM package copied: $NEW_RPM"
|
||||||
|
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
|
- name: List prepared assets
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -299,7 +324,7 @@ jobs:
|
|||||||
|
|
||||||
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`(Homebrew)
|
- **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`(绿色版)
|
- **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)
|
- **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"`
|
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ release/
|
|||||||
.npmrc
|
.npmrc
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
GEMINI.md
|
||||||
/.claude
|
/.claude
|
||||||
|
/.codex
|
||||||
|
/.gemini
|
||||||
|
/.cc-switch
|
||||||
|
/.idea
|
||||||
/.vscode
|
/.vscode
|
||||||
vitest-report.json
|
vitest-report.json
|
||||||
|
nul
|
||||||
|
|
||||||
|
# Flatpak build artifacts
|
||||||
|
flatpak/cc-switch.deb
|
||||||
|
flatpak-build/
|
||||||
|
flatpak-repo/
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
v22.4.1
|
22.12.0
|
||||||
@@ -5,6 +5,439 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [3.9.0-3] - 2025-12-29
|
||||||
|
|
||||||
|
### Beta Release
|
||||||
|
|
||||||
|
Third beta release with important bug fixes for Windows compatibility, UI improvements, and new features.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Universal Provider** - Support for universal provider configurations (#348)
|
||||||
|
- **Provider Search Filter** - Quick filter to find providers by name (#435)
|
||||||
|
- **Keyboard Shortcut** - Open settings with Command+comma / Ctrl+comma (#436)
|
||||||
|
- **Xiaomi MiMo Icon** - Added MiMo icon and Claude provider configuration (#470)
|
||||||
|
- **Usage Model Extraction** - Extract model info from usage statistics (#455)
|
||||||
|
- **Skip First-Run Confirmation** - Option to skip Claude Code first-run confirmation dialog
|
||||||
|
- **Exit Animations** - Added exit animation to FullScreenPanel dialogs
|
||||||
|
- **Fade Transitions** - Smooth fade transitions for app/view/panel switching
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
- Wrap npx/npm commands with `cmd /c` for MCP export
|
||||||
|
- Prevent terminal windows from appearing during version check
|
||||||
|
|
||||||
|
#### macOS
|
||||||
|
- Use .app bundle path for autostart to prevent terminal window popup
|
||||||
|
|
||||||
|
#### UI
|
||||||
|
- Resolve Dialog/Modal not opening on first click (#492)
|
||||||
|
- Improve dark mode text contrast for form labels
|
||||||
|
- Reduce header spacing and fix layout shift on view switch
|
||||||
|
- Prevent header layout shift when switching views
|
||||||
|
|
||||||
|
#### Database & Schema
|
||||||
|
- Add missing base columns migration for proxy_config
|
||||||
|
- Add backward compatibility check for proxy_config seed insert
|
||||||
|
|
||||||
|
#### Other
|
||||||
|
- Use local timezone and robust DST handling in usage stats (#500)
|
||||||
|
- Remove deprecated `sync_enabled_to_codex` call
|
||||||
|
- Gracefully handle invalid Codex config.toml during MCP sync
|
||||||
|
- Add missing translations for reasoning model and OpenRouter compat mode
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- **macOS Tray** - Use macOS tray template icon
|
||||||
|
- **Header Alignment** - Remove macOS titlebar tint, align custom header
|
||||||
|
- **Shadow Removal** - Cleaner UI by removing shadow styles
|
||||||
|
- **Code Inspector** - Added code-inspector-plugin for development
|
||||||
|
- **i18n** - Complete internationalization for usage panel and settings
|
||||||
|
- **Sponsor Logos** - Made sponsor logos clickable
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- 35 commits since v3.9.0-2
|
||||||
|
- 5 files changed in test/lint fixes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.9.0-1] - 2025-12-18
|
||||||
|
|
||||||
|
### Beta Release
|
||||||
|
|
||||||
|
This beta release introduces the **Local API Proxy** feature, along with Skills multi-app support, UI improvements, and numerous bug fixes.
|
||||||
|
|
||||||
|
### Major Features
|
||||||
|
|
||||||
|
#### Local Proxy Server
|
||||||
|
- **Local HTTP Proxy** - High-performance proxy server built on Axum framework
|
||||||
|
- **Multi-app Support** - Unified proxy for Claude Code, Codex, and Gemini CLI API requests
|
||||||
|
- **Per-app Takeover** - Independent control over which apps route through the proxy
|
||||||
|
- **Live Config Takeover** - Automatically backs up and redirects CLI configurations to local proxy
|
||||||
|
|
||||||
|
#### Auto Failover
|
||||||
|
- **Circuit Breaker** - Automatically detects provider failures and triggers protection
|
||||||
|
- **Smart Failover** - Automatically switches to backup provider when current one is unavailable
|
||||||
|
- **Health Tracking** - Real-time monitoring of provider availability
|
||||||
|
- **Independent Failover Queues** - Each app maintains its own failover queue
|
||||||
|
|
||||||
|
#### Monitoring
|
||||||
|
- **Request Logging** - Detailed logging of all proxy requests
|
||||||
|
- **Usage Statistics** - Token consumption, latency, success rate metrics
|
||||||
|
- **Real-time Status** - Frontend displays proxy status and statistics
|
||||||
|
|
||||||
|
#### Skills Multi-App Support
|
||||||
|
- **Multi-app Support** - Skills now support both Claude and Codex (#365)
|
||||||
|
- **Multi-app Migration** - Existing Skills auto-migrate to multi-app structure (#378)
|
||||||
|
- **Installation Path Fix** - Use directory basename for skill installation path (#358)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Provider Icon Colors** - Customize provider icon colors (#385)
|
||||||
|
- **Deeplink Usage Config** - Import usage query config via deeplink (#400)
|
||||||
|
- **Error Request Logging** - Detailed logging for proxy requests (#401)
|
||||||
|
- **Closable Toast** - Added close button to switch notification toast (#350)
|
||||||
|
- **Icon Color Component** - ProviderIcon component supports color prop (#384)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
#### Proxy Related
|
||||||
|
- Takeover Codex base_url via model_provider
|
||||||
|
- Harden crash recovery with fallback detection
|
||||||
|
- Sync UI when active provider differs from current setting
|
||||||
|
- Resolve circuit breaker race condition and error classification
|
||||||
|
- Stabilize live takeover and provider editing
|
||||||
|
- Reset health badges when proxy stops
|
||||||
|
- Retry failover for all HTTP errors including 4xx
|
||||||
|
- Fix HalfOpen counter underflow and config field inconsistencies
|
||||||
|
- Resolve circuit breaker state persistence and HalfOpen deadlock
|
||||||
|
- Auto-recover live config after abnormal exit
|
||||||
|
- Update live backup when hot-switching provider in proxy mode
|
||||||
|
- Wait for server shutdown before exiting app
|
||||||
|
- Disable auto-start on app launch by resetting enabled flag on stop
|
||||||
|
- Sync live config tokens to database before takeover
|
||||||
|
- Resolve 404 error and auto-setup proxy targets
|
||||||
|
|
||||||
|
#### MCP Related
|
||||||
|
- Skip sync when target CLI app is not installed
|
||||||
|
- Improve upsert and import robustness
|
||||||
|
- Use browser-compatible platform detection for MCP presets
|
||||||
|
|
||||||
|
#### UI Related
|
||||||
|
- Restore fade transition for Skills button
|
||||||
|
- Add close button to all success toasts
|
||||||
|
- Prevent card jitter when health badge appears
|
||||||
|
- Update SettingsPage tab styles (#342)
|
||||||
|
|
||||||
|
#### Other
|
||||||
|
- Fix Azure website link (#407)
|
||||||
|
- Add fallback to provider config for usage credentials (#360)
|
||||||
|
- Fix Windows black screen on startup (use system titlebar)
|
||||||
|
- Add fallback for crypto.randomUUID() on older WebViews
|
||||||
|
- Use correct npm package for Codex CLI version check
|
||||||
|
- Security fixes for JavaScript executor and usage script (#151)
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- **Proxy Active Theme** - Apply emerald theme when proxy takeover is active
|
||||||
|
- **Card Animation** - Improved provider card hover animation
|
||||||
|
- **Remove Restart Prompt** - No longer prompts restart when switching providers
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- Implement per-app takeover mode
|
||||||
|
- Proxy module contains 20+ Rust files with complete layered architecture
|
||||||
|
- Add 5 new database tables for proxy functionality
|
||||||
|
- Modularize handlers.rs to reduce code duplication
|
||||||
|
- Remove is_proxy_target in favor of failover_queue
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
- 55 commits since v3.8.2
|
||||||
|
- 164 files changed
|
||||||
|
- +22,164 / -570 lines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.8.0] - 2025-11-28
|
||||||
|
|
||||||
|
### Major Updates
|
||||||
|
|
||||||
|
- **Persistence architecture upgrade** - Moved from single JSON storage to SQLite + JSON dual-layer; added schema versioning, transactions, and SQL import/export; first launch auto-migrates `config.json` to SQLite while keeping originals safe.
|
||||||
|
- **Brand new UI** - Full layout redesign, unified component/ConfirmDialog styles, smoother animations, overscroll disabled; Tailwind CSS downgraded to v3.4 for compatibility.
|
||||||
|
- **Japanese language support** - UI now localized in Chinese/English/Japanese.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Skills recursive scanning** - Discovers nested `SKILL.md` files across multi-level directories; same-name skills allowed by full-path dedup.
|
||||||
|
- **Provider icons** - Presets ship with default icons; custom icon colors; icons retained when duplicating providers.
|
||||||
|
- **Auto launch on startup** - One-click enable/disable using Registry/LaunchAgent/XDG autostart.
|
||||||
|
- **Provider preset** - Added MiniMax partner preset.
|
||||||
|
- **Form validation** - Required fields get real-time validation and unified toast messaging.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Custom endpoints loss** - Switched provider updates to `UPDATE` to avoid cascade deletes from `INSERT OR REPLACE`.
|
||||||
|
- **Gemini config writing** - Correctly writes custom env vars to `.env` and keeps auth configs isolated.
|
||||||
|
- **Provider validation** - Handles missing current provider IDs and preserves icon fields on duplicate.
|
||||||
|
- **Linux rendering** - Fixed WebKitGTK DMA-BUF rendering and preserved user `.desktop` customizations.
|
||||||
|
- **Misc** - Removed redundant usage queries; corrected DMXAPI auth token field; restored missing deeplink translations; fixed usage script template init.
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- **Database modules** - Added `schema`, `backup`, `migration`, and DAO layers for providers/MCP/prompts/skills/settings.
|
||||||
|
- **Service modularization** - Split provider service into live/auth/endpoints/usage modules; deeplink parsing/import logic modularized.
|
||||||
|
- **Code cleanup** - Removed legacy JSON-era import/export, unused MCP types; unified error handling; tests migrated to SQLite backend and MSW handlers updated.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
|
||||||
|
- First launch auto-migrates data from `config.json` to SQLite and device settings to `settings.json`; originals kept; error dialog on failure; dry-run supported.
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- 51 commits since v3.7.1; 207 files changed; +17,297 / -6,870 lines. See [release-note-v3.8.0](docs/release-note-v3.8.0-en.md) for details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.7.1] - 2025-11-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Skills third-party repository installation** (#268) - Fixed installation failure for skills repositories with custom subdirectories (e.g., `ComposioHQ/awesome-claude-skills`)
|
||||||
|
- **Gemini configuration persistence** - Resolved issue where settings.json edits were lost when switching providers
|
||||||
|
- **Dialog overlay click protection** - Prevented dialogs from closing when clicking outside, avoiding accidental form data loss (affects 11 dialog components)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Gemini configuration directory support** (#255) - Added custom configuration directory option for Gemini in settings
|
||||||
|
- **ArchLinux installation support** (#259) - Added AUR installation via `paru -S cc-switch-bin`
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- **Skills error messages i18n** - Added 28+ detailed error messages (English & Chinese) with specific resolution suggestions
|
||||||
|
- **Download timeout** - Extended from 15s to 60s to reduce network-related false positives
|
||||||
|
- **Code formatting** - Applied unified Rust (`cargo fmt`) and TypeScript (`prettier`) formatting standards
|
||||||
|
|
||||||
|
### Reverted
|
||||||
|
|
||||||
|
- **Auto-launch on system startup** - Temporarily reverted feature pending further testing and optimization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.7.0] - 2025-11-19
|
||||||
|
|
||||||
|
### Major Features
|
||||||
|
|
||||||
|
#### Gemini CLI Integration
|
||||||
|
|
||||||
|
- **Complete Gemini CLI support** - Third major application added alongside Claude Code and Codex
|
||||||
|
- **Dual-file configuration** - Support for both `.env` and `settings.json` file formats
|
||||||
|
- **Environment variable detection** - Auto-detect `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.
|
||||||
|
- **MCP management** - Full MCP configuration capabilities for Gemini
|
||||||
|
- **Provider presets**
|
||||||
|
- Google Official (OAuth authentication)
|
||||||
|
- PackyCode (partner integration)
|
||||||
|
- Custom endpoint support
|
||||||
|
- **Deep link support** - Import Gemini providers via `ccswitch://` protocol
|
||||||
|
- **System tray integration** - Quick-switch Gemini providers from tray menu
|
||||||
|
- **Backend modules** - New `gemini_config.rs` (20KB) and `gemini_mcp.rs`
|
||||||
|
|
||||||
|
#### MCP v3.7.0 Unified Architecture
|
||||||
|
|
||||||
|
- **Unified management panel** - Single interface for Claude/Codex/Gemini MCP servers
|
||||||
|
- **SSE transport type** - New Server-Sent Events support alongside stdio/http
|
||||||
|
- **Smart JSON parser** - Fault-tolerant parsing of various MCP config formats
|
||||||
|
- **Extended field support** - Preserve custom fields in Codex TOML conversion
|
||||||
|
- **Codex format correction** - Proper `[mcp_servers]` format (auto-cleanup of incorrect `[mcp.servers]`)
|
||||||
|
- **Import/export system** - Unified import from Claude/Codex/Gemini live configs
|
||||||
|
- **UX improvements**
|
||||||
|
- Default app selection in forms
|
||||||
|
- JSON formatter for config validation
|
||||||
|
- Improved layout and visual hierarchy
|
||||||
|
- Better validation error messages
|
||||||
|
|
||||||
|
#### Claude Skills Management System
|
||||||
|
|
||||||
|
- **GitHub repository integration** - Auto-scan and discover skills from GitHub repos
|
||||||
|
- **Pre-configured repositories**
|
||||||
|
- `ComposioHQ/awesome-claude-skills` (curated collection)
|
||||||
|
- `anthropics/skills` (official Anthropic skills)
|
||||||
|
- `cexll/myclaude` (community, with subdirectory scanning)
|
||||||
|
- **Lifecycle management**
|
||||||
|
- One-click install to `~/.claude/skills/`
|
||||||
|
- Safe uninstall with state tracking
|
||||||
|
- Update checking (infrastructure ready)
|
||||||
|
- **Custom repository support** - Add any GitHub repo as a skill source
|
||||||
|
- **Subdirectory scanning** - Optional `skillsPath` for repos with nested skill directories
|
||||||
|
- **Backend architecture** - `SkillService` (526 lines) with GitHub API integration
|
||||||
|
- **Frontend interface**
|
||||||
|
- SkillsPage: Browse and manage skills
|
||||||
|
- SkillCard: Visual skill presentation
|
||||||
|
- RepoManager: Repository management dialog
|
||||||
|
- **State persistence** - Installation state stored in `skills.json`
|
||||||
|
- **Full i18n support** - Complete Chinese/English translations (47+ keys)
|
||||||
|
|
||||||
|
#### Prompts (System Prompts) Management
|
||||||
|
|
||||||
|
- **Multi-preset management** - Create, edit, and switch between multiple system prompts
|
||||||
|
- **Cross-app support**
|
||||||
|
- Claude: `~/.claude/CLAUDE.md`
|
||||||
|
- Codex: `~/.codex/AGENTS.md`
|
||||||
|
- Gemini: `~/.gemini/GEMINI.md`
|
||||||
|
- **Markdown editor** - Full-featured CodeMirror 6 editor with syntax highlighting
|
||||||
|
- **Smart synchronization**
|
||||||
|
- Auto-write to live files on enable
|
||||||
|
- Content backfill protection (save current before switching)
|
||||||
|
- First-launch auto-import from live files
|
||||||
|
- **Single-active enforcement** - Only one prompt can be active at a time
|
||||||
|
- **Delete protection** - Cannot delete active prompts
|
||||||
|
- **Backend service** - `PromptService` (213 lines) with CRUD operations
|
||||||
|
- **Frontend components**
|
||||||
|
- PromptPanel: Main management interface (177 lines)
|
||||||
|
- PromptFormModal: Edit dialog with validation (160 lines)
|
||||||
|
- MarkdownEditor: CodeMirror integration (159 lines)
|
||||||
|
- usePromptActions: Business logic hook (152 lines)
|
||||||
|
- **Full i18n support** - Complete Chinese/English translations (41+ keys)
|
||||||
|
|
||||||
|
#### Deep Link Protocol (ccswitch://)
|
||||||
|
|
||||||
|
- **Protocol registration** - `ccswitch://` URL scheme for one-click imports
|
||||||
|
- **Provider import** - Import provider configurations from URLs or shared links
|
||||||
|
- **Lifecycle integration** - Deep link handling integrated into app startup
|
||||||
|
- **Cross-platform support** - Works on Windows, macOS, and Linux
|
||||||
|
|
||||||
|
#### Environment Variable Conflict Detection
|
||||||
|
|
||||||
|
- **Claude & Codex detection** - Identify conflicting environment variables
|
||||||
|
- **Gemini auto-detection** - Automatic environment variable discovery
|
||||||
|
- **Conflict management** - UI for resolving configuration conflicts
|
||||||
|
- **Prevention system** - Warn before overwriting existing configurations
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
#### Provider Management
|
||||||
|
|
||||||
|
- **DouBaoSeed preset** - Added ByteDance's DouBao provider
|
||||||
|
- **Kimi For Coding** - Moonshot AI coding assistant
|
||||||
|
- **BaiLing preset** - BaiLing AI integration
|
||||||
|
- **Removed AnyRouter preset** - Discontinued provider
|
||||||
|
- **Model configuration** - Support for custom model names in Codex and Gemini
|
||||||
|
- **Provider notes field** - Add custom notes to providers for better organization
|
||||||
|
|
||||||
|
#### Configuration Management
|
||||||
|
|
||||||
|
- **Common config migration** - Moved Claude common config snippets from localStorage to `config.json`
|
||||||
|
- **Unified persistence** - Common config snippets now shared across all apps
|
||||||
|
- **Auto-import on first launch** - Automatically import configs from live files on first run
|
||||||
|
- **Backfill priority fix** - Correct priority handling when enabling prompts
|
||||||
|
|
||||||
|
#### UI/UX Improvements
|
||||||
|
|
||||||
|
- **macOS native design** - Migrated color scheme to macOS native design system
|
||||||
|
- **Window centering** - Default window position centered on screen
|
||||||
|
- **Password input fixes** - Disabled Edge/IE reveal and clear buttons
|
||||||
|
- **URL overflow prevention** - Fixed overflow in provider cards
|
||||||
|
- **Error notification enhancement** - Copy-to-clipboard for error messages
|
||||||
|
- **Tray menu sync** - Real-time sync after drag-and-drop sorting
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
#### Architecture
|
||||||
|
|
||||||
|
- **MCP v3.7.0 cleanup** - Removed legacy code and warnings
|
||||||
|
- **Unified structure** - Default initialization with v3.7.0 unified structure
|
||||||
|
- **Backward compatibility** - Compilation fixes for older configs
|
||||||
|
- **Code formatting** - Applied consistent formatting across backend and frontend
|
||||||
|
|
||||||
|
#### Platform Compatibility
|
||||||
|
|
||||||
|
- **Windows fix** - Resolved winreg API compatibility issue (v0.52)
|
||||||
|
- **Safe pattern matching** - Replaced `unwrap()` with safe patterns in tray menu
|
||||||
|
|
||||||
|
#### Configuration
|
||||||
|
|
||||||
|
- **MCP sync on switch** - Sync MCP configs for all apps when switching providers
|
||||||
|
- **Gemini form sync** - Fixed form fields syncing with environment editor
|
||||||
|
- **Gemini config reading** - Read from both `.env` and `settings.json`
|
||||||
|
- **Validation improvements** - Enhanced input validation and boundary checks
|
||||||
|
|
||||||
|
#### Internationalization
|
||||||
|
|
||||||
|
- **JSON syntax fixes** - Resolved syntax errors in locale files
|
||||||
|
- **App name i18n** - Added internationalization support for app names
|
||||||
|
- **Deduplicated labels** - Reused providerForm keys to reduce duplication
|
||||||
|
- **Gemini MCP title** - Added missing Gemini MCP panel title
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
#### Critical Fixes
|
||||||
|
|
||||||
|
- **Usage script validation** - Added input validation and boundary checks
|
||||||
|
- **Gemini validation** - Relaxed validation when adding providers
|
||||||
|
- **TOML quote normalization** - Handle CJK quotes to prevent parsing errors
|
||||||
|
- **MCP field preservation** - Preserve custom fields in Codex TOML editor
|
||||||
|
- **Password input** - Fixed white screen crash (FormLabel → Label)
|
||||||
|
|
||||||
|
#### Stability
|
||||||
|
|
||||||
|
- **Tray menu safety** - Replaced unwrap with safe pattern matching
|
||||||
|
- **Error isolation** - Tray menu update failures don't block main operations
|
||||||
|
- **Import classification** - Set category to custom for imported default configs
|
||||||
|
|
||||||
|
#### UI Fixes
|
||||||
|
|
||||||
|
- **Model placeholders** - Removed misleading model input placeholders
|
||||||
|
- **Base URL population** - Auto-fill base URL for non-official providers
|
||||||
|
- **Drag sort sync** - Fixed tray menu order after drag-and-drop
|
||||||
|
|
||||||
|
### Technical Improvements
|
||||||
|
|
||||||
|
#### Code Quality
|
||||||
|
|
||||||
|
- **Type safety** - Complete TypeScript type coverage across codebase
|
||||||
|
- **Test improvements** - Simplified boolean assertions in tests
|
||||||
|
- **Clippy warnings** - Fixed `uninlined_format_args` warnings
|
||||||
|
- **Code refactoring** - Extracted templates, optimized logic flows
|
||||||
|
|
||||||
|
#### Dependencies
|
||||||
|
|
||||||
|
- **Tauri** - Updated to 2.8.x series
|
||||||
|
- **Rust dependencies** - Added `anyhow`, `zip`, `serde_yaml`, `tempfile` for Skills
|
||||||
|
- **Frontend dependencies** - Added CodeMirror 6 packages for Markdown editor
|
||||||
|
- **winreg** - Updated to v0.52 (Windows compatibility)
|
||||||
|
|
||||||
|
#### Performance
|
||||||
|
|
||||||
|
- **Startup optimization** - Removed legacy migration scanning
|
||||||
|
- **Lock management** - Improved RwLock usage to prevent deadlocks
|
||||||
|
- **Background query** - Enabled background mode for usage polling
|
||||||
|
|
||||||
|
### Statistics
|
||||||
|
|
||||||
|
- **Total commits**: 85 commits from v3.6.0 to v3.7.0
|
||||||
|
- **Code changes**: 152 files changed, 18,104 insertions(+), 3,732 deletions(-)
|
||||||
|
- **New modules**:
|
||||||
|
- Skills: 2,034 lines (21 files)
|
||||||
|
- Prompts: 1,302 lines (20 files)
|
||||||
|
- Gemini: ~1,000 lines (multiple files)
|
||||||
|
- MCP refactor: ~3,000 lines (refactored)
|
||||||
|
|
||||||
|
### Strategic Positioning
|
||||||
|
|
||||||
|
v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI CLI Management Platform"**:
|
||||||
|
|
||||||
|
1. **Capability Extension** - Skills provide external ability integration
|
||||||
|
2. **Behavior Customization** - Prompts enable AI personality presets
|
||||||
|
3. **Configuration Unification** - MCP v3.7.0 eliminates app silos
|
||||||
|
4. **Ecosystem Openness** - Deep links enable community sharing
|
||||||
|
5. **Multi-AI Support** - Claude/Codex/Gemini trinity
|
||||||
|
6. **Intelligent Detection** - Auto-discovery of environment conflicts
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x for one-time migration
|
||||||
|
- Skills and Prompts management are new features requiring no migration
|
||||||
|
- Gemini CLI support requires Gemini CLI to be installed separately
|
||||||
|
- MCP v3.7.0 unified structure is backward compatible with previous configs
|
||||||
|
|
||||||
## [3.6.0] - 2025-11-07
|
## [3.6.0] - 2025-11-07
|
||||||
|
|
||||||
### ✨ New Features
|
### ✨ New Features
|
||||||
@@ -73,6 +506,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### 🏗️ Technical Improvements (For Developers)
|
### 🏗️ Technical Improvements (For Developers)
|
||||||
|
|
||||||
**Backend Refactoring (Rust)** - Completed 5-phase refactoring:
|
**Backend Refactoring (Rust)** - Completed 5-phase refactoring:
|
||||||
|
|
||||||
- **Phase 1**: Unified error handling (`AppError` + i18n error messages)
|
- **Phase 1**: Unified error handling (`AppError` + i18n error messages)
|
||||||
- **Phase 2**: Command layer split by domain (`commands/{provider,mcp,config,settings,plugin,misc}.rs`)
|
- **Phase 2**: Command layer split by domain (`commands/{provider,mcp,config,settings,plugin,misc}.rs`)
|
||||||
- **Phase 3**: Integration tests and transaction mechanism (config snapshot + failure rollback)
|
- **Phase 3**: Integration tests and transaction mechanism (config snapshot + failure rollback)
|
||||||
@@ -80,17 +514,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **Phase 5**: Concurrency optimization (`RwLock` instead of `Mutex`, scoped guard to avoid deadlock)
|
- **Phase 5**: Concurrency optimization (`RwLock` instead of `Mutex`, scoped guard to avoid deadlock)
|
||||||
|
|
||||||
**Frontend Refactoring (React + TypeScript)** - Completed 4-stage refactoring:
|
**Frontend Refactoring (React + TypeScript)** - Completed 4-stage refactoring:
|
||||||
|
|
||||||
- **Stage 1**: Test infrastructure (vitest + MSW + @testing-library/react)
|
- **Stage 1**: Test infrastructure (vitest + MSW + @testing-library/react)
|
||||||
- **Stage 2**: Extracted custom hooks (`useProviderActions`, `useMcpActions`, `useSettings`, `useImportExport`, etc.)
|
- **Stage 2**: Extracted custom hooks (`useProviderActions`, `useMcpActions`, `useSettings`, `useImportExport`, etc.)
|
||||||
- **Stage 3**: Component splitting and business logic extraction
|
- **Stage 3**: Component splitting and business logic extraction
|
||||||
- **Stage 4**: Code cleanup and formatting unification
|
- **Stage 4**: Code cleanup and formatting unification
|
||||||
|
|
||||||
**Testing System**:
|
**Testing System**:
|
||||||
|
|
||||||
- Hooks unit tests 100% coverage
|
- Hooks unit tests 100% coverage
|
||||||
- Integration tests covering key processes (App, SettingsDialog, MCP Panel)
|
- Integration tests covering key processes (App, SettingsDialog, MCP Panel)
|
||||||
- MSW mocking backend API to ensure test independence
|
- MSW mocking backend API to ensure test independence
|
||||||
|
|
||||||
**Code Quality**:
|
**Code Quality**:
|
||||||
|
|
||||||
- Unified parameter format: All Tauri commands migrated to camelCase (Tauri 2 specification)
|
- Unified parameter format: All Tauri commands migrated to camelCase (Tauri 2 specification)
|
||||||
- `AppType` renamed to `AppId`: Semantically clearer
|
- `AppType` renamed to `AppId`: Semantically clearer
|
||||||
- Unified parsing with `FromStr` trait: Centralized `app` parameter parsing
|
- Unified parsing with `FromStr` trait: Centralized `app` parameter parsing
|
||||||
@@ -98,6 +535,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Remove unused code: `missing_param` helper function, deprecated `tauri-api.ts`, redundant `KimiModelSelector` component
|
- Remove unused code: `missing_param` helper function, deprecated `tauri-api.ts`, redundant `KimiModelSelector` component
|
||||||
|
|
||||||
**Internal Optimizations**:
|
**Internal Optimizations**:
|
||||||
|
|
||||||
- **Removed Legacy Migration Logic**: v3.6 removed v1 config auto-migration and copy file scanning logic
|
- **Removed Legacy Migration Logic**: v3.6 removed v1 config auto-migration and copy file scanning logic
|
||||||
- ✅ **Impact**: Improved startup performance, cleaner code
|
- ✅ **Impact**: Improved startup performance, cleaner code
|
||||||
- ✅ **Compatibility**: v2 format configs fully compatible, no action required
|
- ✅ **Compatibility**: v2 format configs fully compatible, no action required
|
||||||
@@ -361,6 +799,7 @@ For users upgrading from v2.x (Electron version):
|
|||||||
- Basic provider management
|
- Basic provider management
|
||||||
- Claude Code integration
|
- Claude Code integration
|
||||||
- Configuration file handling
|
- Configuration file handling
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### ⚠️ Breaking Changes
|
### ⚠️ Breaking Changes
|
||||||
|
|||||||
@@ -1,35 +1,42 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
# Claude Code & Codex Provider Switcher
|
# 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/trending/typescript)
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
|
|
||||||
English | [中文](README_ZH.md) | [Changelog](CHANGELOG.md)
|
<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>
|
||||||
|
|
||||||
A desktop application for managing and switching between different provider configurations & MCP for Claude Code and Codex.
|
English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANGELOG.md)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## ❤️Sponsor
|
## ❤️Sponsor
|
||||||
|
|
||||||

|
[](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
||||||
|
|
||||||
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.
|
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)!
|
||||||
|
|
||||||
GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.
|
|
||||||
|
|
||||||
Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)!
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
|
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||||
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during recharge to get 10% off.</td>
|
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during recharge to get 10% off.</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||||
|
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
|
||||||
|
<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>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
@@ -40,12 +47,82 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Current Version: v3.6.1 | [Full Changelog](CHANGELOG.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)**
|
||||||
|
|
||||||
|
**Persistence Architecture Upgrade & Brand New UI**
|
||||||
|
|
||||||
|
- **SQLite + JSON Dual-layer Architecture**
|
||||||
|
- Migrated from JSON file storage to SQLite + JSON dual-layer structure
|
||||||
|
- Syncable data (providers, MCP, Prompts, Skills) stored in SQLite
|
||||||
|
- Device-level data (window state, local paths) stored in JSON
|
||||||
|
- Lays the foundation for future cloud sync functionality
|
||||||
|
- Schema version management for database migrations
|
||||||
|
|
||||||
|
- **Brand New User Interface**
|
||||||
|
- Completely redesigned interface layout
|
||||||
|
- Unified component styles and smoother animations
|
||||||
|
- Optimized visual hierarchy
|
||||||
|
- Tailwind CSS downgraded from v4 to v3.4 for better browser compatibility
|
||||||
|
|
||||||
|
- **Japanese Language Support**
|
||||||
|
- Added Japanese interface support (now supports Chinese/English/Japanese)
|
||||||
|
|
||||||
|
- **Auto Launch on Startup**
|
||||||
|
- One-click enable/disable in settings
|
||||||
|
- Platform-native APIs (Registry/LaunchAgent/XDG autostart)
|
||||||
|
|
||||||
|
- **Skills Recursive Scanning**
|
||||||
|
- Support for multi-level directory structures
|
||||||
|
- Allow same-named skills from different repositories
|
||||||
|
|
||||||
|
- **Critical Bug Fixes**
|
||||||
|
- Fixed custom endpoints lost when updating providers
|
||||||
|
- Fixed Gemini configuration write issues
|
||||||
|
- Fixed Linux WebKitGTK rendering issues
|
||||||
|
|
||||||
|
**v3.7.0 Highlights**
|
||||||
|
|
||||||
|
**Six Core Features, 18,000+ Lines of New Code**
|
||||||
|
|
||||||
|
- **Gemini CLI Integration**
|
||||||
|
- Third supported AI CLI (Claude Code / Codex / Gemini)
|
||||||
|
- Dual-file configuration support (`.env` + `settings.json`)
|
||||||
|
- Complete MCP server management
|
||||||
|
- Presets: Google Official (OAuth) / PackyCode / Custom
|
||||||
|
|
||||||
|
- **Claude Skills Management System**
|
||||||
|
- Auto-scan skills from GitHub repositories (3 pre-configured curated repos)
|
||||||
|
- One-click install/uninstall to `~/.claude/skills/`
|
||||||
|
- Custom repository support + subdirectory scanning
|
||||||
|
- Complete lifecycle management (discover/install/update)
|
||||||
|
|
||||||
|
- **Prompts Management System**
|
||||||
|
- Multi-preset system prompt management (unlimited presets, quick switching)
|
||||||
|
- Cross-app support (Claude: `CLAUDE.md` / Codex: `AGENTS.md` / Gemini: `GEMINI.md`)
|
||||||
|
- Markdown editor (CodeMirror 6 + real-time preview)
|
||||||
|
- Smart backfill protection, preserves manual modifications
|
||||||
|
|
||||||
|
- **MCP v3.7.0 Unified Architecture**
|
||||||
|
- Single panel manages MCP servers across three applications
|
||||||
|
- New SSE (Server-Sent Events) transport type
|
||||||
|
- Smart JSON parser + Codex TOML format auto-correction
|
||||||
|
- Unified import/export + bidirectional sync
|
||||||
|
|
||||||
|
- **Deep Link Protocol**
|
||||||
|
- `ccswitch://` protocol registration (all platforms)
|
||||||
|
- One-click import provider configs via shared links
|
||||||
|
- Security validation + lifecycle integration
|
||||||
|
|
||||||
|
- **Environment Variable Conflict Detection**
|
||||||
|
- Auto-detect cross-app configuration conflicts (Claude/Codex/Gemini/MCP)
|
||||||
|
- Visual conflict indicators + resolution suggestions
|
||||||
|
- Override warnings + backup before changes
|
||||||
|
|
||||||
**Core Capabilities**
|
**Core Capabilities**
|
||||||
|
|
||||||
- **Provider Management**: One-click switching between Claude Code & Codex API configurations
|
- **Provider Management**: One-click switching between Claude Code, Codex, and Gemini API configurations
|
||||||
- **MCP Integration**: Centralized MCP server management with stdio/http support and real-time sync
|
|
||||||
- **Speed Testing**: Measure API endpoint latency with visual quality indicators
|
- **Speed Testing**: Measure API endpoint latency with visual quality indicators
|
||||||
- **Import/Export**: Backup and restore configs with auto-rotation (keep 10 most recent)
|
- **Import/Export**: Backup and restore configs with auto-rotation (keep 10 most recent)
|
||||||
- **i18n Support**: Complete Chinese/English localization (UI, errors, tray)
|
- **i18n Support**: Complete Chinese/English localization (UI, errors, tray)
|
||||||
@@ -58,7 +135,6 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
|
|||||||
- Granular model configuration (4-tier: Haiku/Sonnet/Opus/Custom)
|
- Granular model configuration (4-tier: Haiku/Sonnet/Opus/Custom)
|
||||||
- WSL environment support with auto-sync on directory change
|
- WSL environment support with auto-sync on directory change
|
||||||
- 100% hooks test coverage & complete architecture refactoring
|
- 100% hooks test coverage & complete architecture refactoring
|
||||||
- New presets: DMXAPI, Azure Codex, AnyRouter, AiHubMix, MiniMax
|
|
||||||
|
|
||||||
**System Features**
|
**System Features**
|
||||||
|
|
||||||
@@ -100,9 +176,29 @@ Download `CC-Switch-v{version}-macOS.zip` from the [Releases](../../releases) pa
|
|||||||
|
|
||||||
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it first, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and you'll be able to open it normally afterwards.
|
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it first, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and you'll be able to open it normally afterwards.
|
||||||
|
|
||||||
|
### ArchLinux 用户
|
||||||
|
|
||||||
|
**Install via paru (Recommended)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
paru -S cc-switch-bin
|
||||||
|
```
|
||||||
|
|
||||||
### Linux Users
|
### Linux Users
|
||||||
|
|
||||||
Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{version}-Linux.AppImage` from the [Releases](../../releases) page.
|
Download the latest Linux build from the [Releases](../../releases) page:
|
||||||
|
|
||||||
|
- `CC-Switch-v{version}-Linux.deb` (Debian/Ubuntu)
|
||||||
|
- `CC-Switch-v{version}-Linux.rpm` (Fedora/RHEL/openSUSE)
|
||||||
|
- `CC-Switch-v{version}-Linux.AppImage` (Universal)
|
||||||
|
- `CC-Switch-v{version}-Linux.flatpak` (Flatpak)
|
||||||
|
|
||||||
|
Flatpak install & run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak install --user ./CC-Switch-v{version}-Linux.flatpak
|
||||||
|
flatpak run com.ccswitch.desktop
|
||||||
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -112,15 +208,42 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
|
|||||||
2. **Switch Provider**:
|
2. **Switch Provider**:
|
||||||
- Main UI: Select provider → Click "Enable"
|
- Main UI: Select provider → Click "Enable"
|
||||||
- System Tray: Click provider name directly (instant effect)
|
- System Tray: Click provider name directly (instant effect)
|
||||||
3. **Takes Effect**: Restart terminal or Claude Code/Codex to apply changes
|
3. **Takes Effect**: Restart your terminal or Claude Code / Codex / Gemini clients to apply changes
|
||||||
4. **Back to Official**: Select "Official Login" preset, restart terminal, then use `/login` (Claude) or official login flow (Codex)
|
4. **Back to Official**: Select the "Official Login" preset (Claude/Codex) or "Google Official" preset (Gemini), restart the corresponding client, then follow its login/OAuth flow
|
||||||
|
|
||||||
### MCP Management
|
### MCP Management
|
||||||
|
|
||||||
- **Location**: Click "MCP" button in top-right corner
|
- **Location**: Click "MCP" button in top-right corner
|
||||||
- **Add Server**: Use built-in templates (mcp-fetch, mcp-filesystem) or custom config
|
- **Add Server**:
|
||||||
|
- Use built-in templates (mcp-fetch, mcp-filesystem, etc.)
|
||||||
|
- Support stdio / http / sse transport types
|
||||||
|
- Configure independent MCP servers for different apps
|
||||||
- **Enable/Disable**: Toggle switches to control which servers sync to live config
|
- **Enable/Disable**: Toggle switches to control which servers sync to live config
|
||||||
- **Sync**: Enabled servers auto-sync to `~/.claude.json` (Claude) or `~/.codex/config.toml` (Codex)
|
- **Sync**: Enabled servers auto-sync to each app's live files
|
||||||
|
- **Import/Export**: Import existing MCP servers from Claude/Codex/Gemini config files
|
||||||
|
|
||||||
|
### Skills Management (v3.7.0 New)
|
||||||
|
|
||||||
|
- **Location**: Click "Skills" button in top-right corner
|
||||||
|
- **Discover Skills**:
|
||||||
|
- Auto-scan pre-configured GitHub repositories (Anthropic official, ComposioHQ, community, etc.)
|
||||||
|
- Add custom repositories (supports subdirectory scanning)
|
||||||
|
- **Install Skills**: Click "Install" to one-click install to `~/.claude/skills/`
|
||||||
|
- **Uninstall Skills**: Click "Uninstall" to safely remove and clean up state
|
||||||
|
- **Manage Repositories**: Add/remove custom GitHub repositories
|
||||||
|
|
||||||
|
### Prompts Management (v3.7.0 New)
|
||||||
|
|
||||||
|
- **Location**: Click "Prompts" button in top-right corner
|
||||||
|
- **Create Presets**:
|
||||||
|
- Create unlimited system prompt presets
|
||||||
|
- Use Markdown editor to write prompts (syntax highlighting + real-time preview)
|
||||||
|
- **Switch Presets**: Select preset → Click "Activate" to apply immediately
|
||||||
|
- **Sync Mechanism**:
|
||||||
|
- Claude: `~/.claude/CLAUDE.md`
|
||||||
|
- Codex: `~/.codex/AGENTS.md`
|
||||||
|
- Gemini: `~/.gemini/GEMINI.md`
|
||||||
|
- **Protection Mechanism**: Auto-save current prompt content before switching, preserves manual modifications
|
||||||
|
|
||||||
### Configuration Files
|
### Configuration Files
|
||||||
|
|
||||||
@@ -134,12 +257,20 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
|
|||||||
|
|
||||||
- Live config: `~/.codex/auth.json` (required) + `config.toml` (optional)
|
- Live config: `~/.codex/auth.json` (required) + `config.toml` (optional)
|
||||||
- API key field: `OPENAI_API_KEY` in `auth.json`
|
- API key field: `OPENAI_API_KEY` in `auth.json`
|
||||||
- MCP servers: `~/.codex/config.toml` → `[mcp.servers]`
|
- MCP servers: `~/.codex/config.toml` → `[mcp_servers]` tables
|
||||||
|
|
||||||
**CC Switch Storage**
|
**Gemini**
|
||||||
|
|
||||||
- Main config (SSOT): `~/.cc-switch/config.json`
|
- Live config: `~/.gemini/.env` (API key) + `~/.gemini/settings.json` (auth mode)
|
||||||
- Settings: `~/.cc-switch/settings.json`
|
- API key field: `GEMINI_API_KEY` or `GOOGLE_GEMINI_API_KEY` in `.env`
|
||||||
|
- Environment variables: Support `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.
|
||||||
|
- MCP servers: `~/.gemini/settings.json` → `mcpServers`
|
||||||
|
- Tray quick switch: Each provider switch rewrites `~/.gemini/.env`, no need to restart Gemini CLI
|
||||||
|
|
||||||
|
**CC Switch Storage (v3.8.0 New Architecture)**
|
||||||
|
|
||||||
|
- Database (SSOT): `~/.cc-switch/cc-switch.db` (SQLite, stores providers, MCP, Prompts, Skills)
|
||||||
|
- Local settings: `~/.cc-switch/settings.json` (device-level settings)
|
||||||
- Backups: `~/.cc-switch/backups/` (auto-rotate, keep 10)
|
- Backups: `~/.cc-switch/backups/` (auto-rotate, keep 10)
|
||||||
|
|
||||||
### Cloud Sync Setup
|
### Cloud Sync Setup
|
||||||
@@ -158,28 +289,29 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
|
|||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ Frontend (React + TS) │
|
│ Frontend (React + TS) │
|
||||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||||
│ │ Components │ │ Hooks │ │ TanStack Query │ │
|
│ │ Components │ │ Hooks │ │ TanStack Query │ │
|
||||||
│ │ (UI) │──│ (Bus. Logic) │──│ (Cache/Sync) │ │
|
│ │ (UI) │──│ (Bus. Logic) │──│ (Cache/Sync) │ │
|
||||||
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
||||||
└────────────────────────┬────────────────────────────────────┘
|
└────────────────────────┬────────────────────────────────────┘
|
||||||
│ Tauri IPC
|
│ Tauri IPC
|
||||||
┌────────────────────────▼────────────────────────────────────┐
|
┌────────────────────────▼────────────────────────────────────┐
|
||||||
│ Backend (Tauri + Rust) │
|
│ Backend (Tauri + Rust) │
|
||||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||||
│ │ Commands │ │ Services │ │ Models/Config │ │
|
│ │ Commands │ │ Services │ │ Models/Config │ │
|
||||||
│ │ (API Layer) │──│ (Bus. Layer) │──│ (Data) │ │
|
│ │ (API Layer) │──│ (Bus. Layer) │──│ (Data) │ │
|
||||||
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**Core Design Patterns**
|
**Core Design Patterns**
|
||||||
|
|
||||||
- **SSOT** (Single Source of Truth): All provider configs stored in `~/.cc-switch/config.json`
|
- **SSOT** (Single Source of Truth): All data stored in `~/.cc-switch/cc-switch.db` (SQLite)
|
||||||
|
- **Dual-layer Storage**: SQLite for syncable data, JSON for device-level settings
|
||||||
- **Dual-way Sync**: Write to live files on switch, backfill from live when editing active provider
|
- **Dual-way Sync**: Write to live files on switch, backfill from live when editing active provider
|
||||||
- **Atomic Writes**: Temp file + rename pattern prevents config corruption
|
- **Atomic Writes**: Temp file + rename pattern prevents config corruption
|
||||||
- **Concurrency Safe**: RwLock with scoped guards avoids deadlocks
|
- **Concurrency Safe**: Mutex-protected database connection avoids race conditions
|
||||||
- **Layered Architecture**: Clear separation (Commands → Services → Models)
|
- **Layered Architecture**: Clear separation (Commands → Services → DAO → Database)
|
||||||
|
|
||||||
**Key Components**
|
**Key Components**
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
|
||||||
|
|
||||||
|
[](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.8.0 リリースノート](docs/release-note-v3.8.0-en.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## ❤️スポンサー
|
||||||
|
|
||||||
|
[](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
||||||
|
|
||||||
|
本プロジェクトは Z.ai の GLM CODING PLAN による支援を受けています。GLM CODING PLAN は AI コーディング向けのサブスクリプションで、月額わずか 3 ドルから。Claude Code、Cline、Roo Code など 10 以上の人気 AI コーディングツールでフラッグシップモデル GLM-4.6 を利用でき、速く安定した開発体験を提供します。[このリンク](https://z.ai/subscribe?ic=8JVLJQFSKB) から申し込むと 10% オフになります!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<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 は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります:<a href="https://www.packyapi.com/register?aff=cc-switch">このリンク</a>で登録し、チャージ時に「cc-switch」クーポンを入力すると 10% オフになります。</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||||
|
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## スクリーンショット
|
||||||
|
|
||||||
|
| メイン画面 | プロバイダ追加 |
|
||||||
|
| :-------------------------------------------: | :----------------------------------------------: |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
## 特長
|
||||||
|
|
||||||
|
### 現在のバージョン:v3.8.3 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md)
|
||||||
|
|
||||||
|
**v3.8.0 メジャーアップデート (2025-11-28)**
|
||||||
|
|
||||||
|
**永続化アーキテクチャ刷新 & 新 UI**
|
||||||
|
|
||||||
|
- **SQLite + JSON 二層構造**
|
||||||
|
- JSON 単独保存から SQLite + JSON の二層構造へ移行
|
||||||
|
- 同期対象データ(プロバイダ、MCP、Prompts、Skills)は SQLite に保存
|
||||||
|
- デバイス固有データ(ウィンドウ状態、ローカルパス)は JSON に保存
|
||||||
|
- 将来のクラウド同期の土台を用意
|
||||||
|
- DB マイグレーション用にスキーマバージョンを管理
|
||||||
|
|
||||||
|
- **新しいユーザーインターフェース**
|
||||||
|
- レイアウトを全面再設計
|
||||||
|
- コンポーネントスタイルとアニメーションを統一
|
||||||
|
- 視覚的な階層を最適化
|
||||||
|
- ブラウザ互換性向上のため Tailwind CSS を v4 から v3.4 にダウングレード
|
||||||
|
|
||||||
|
- **日本語対応**
|
||||||
|
- UI が中国語/英語/日本語の 3 言語対応に
|
||||||
|
|
||||||
|
- **自動起動**
|
||||||
|
- 設定画面でワンクリック ON/OFF
|
||||||
|
- プラットフォームネイティブ API(Registry/LaunchAgent/XDG autostart)を使用
|
||||||
|
|
||||||
|
- **Skills 再帰スキャン**
|
||||||
|
- 多階層ディレクトリをサポート
|
||||||
|
- リポジトリが異なる同名スキルを許可
|
||||||
|
|
||||||
|
- **重要なバグ修正**
|
||||||
|
- プロバイダ更新時にカスタムエンドポイントが失われる問題を修正
|
||||||
|
- Gemini 設定の書き込み問題を修正
|
||||||
|
- Linux WebKitGTK の描画問題を修正
|
||||||
|
|
||||||
|
**v3.7.0 ハイライト**
|
||||||
|
|
||||||
|
**6 つのコア機能、18,000 行超の新コード**
|
||||||
|
|
||||||
|
- **Gemini CLI 統合**
|
||||||
|
- Claude Code / Codex / Gemini の 3 番目のサポート AI CLI
|
||||||
|
- 2 つの設定ファイル(`.env` + `settings.json`)に対応
|
||||||
|
- MCP サーバー管理を完備
|
||||||
|
- プリセット:Google 公式(OAuth)/ PackyCode / カスタム
|
||||||
|
|
||||||
|
- **Claude Skills 管理システム**
|
||||||
|
- GitHub リポジトリを自動スキャン(3 つのキュレーション済みリポジトリを同梱)
|
||||||
|
- `~/.claude/skills/` へワンクリックでインストール/アンインストール
|
||||||
|
- カスタムリポジトリ + サブディレクトリスキャンをサポート
|
||||||
|
- ライフサイクル管理(検出/インストール/更新)を完備
|
||||||
|
|
||||||
|
- **Prompts 管理システム**
|
||||||
|
- 無制限のシステムプロンプトプリセットを作成
|
||||||
|
- Markdown エディタ(CodeMirror 6 + リアルタイムプレビュー)付き
|
||||||
|
- スマートなバックフィル保護で手動変更を保持
|
||||||
|
- 複数アプリに同時対応(Claude: `CLAUDE.md` / Codex: `AGENTS.md` / Gemini: `GEMINI.md`)
|
||||||
|
|
||||||
|
- **MCP v3.7.0 統合アーキテクチャ**
|
||||||
|
- 1 つのパネルで 3 アプリの MCP を管理
|
||||||
|
- 新たに SSE(Server-Sent Events)トランスポートを追加
|
||||||
|
- スマート JSON パーサー + Codex TOML 自動修正
|
||||||
|
- 双方向のインポート/エクスポート + 双方向同期
|
||||||
|
|
||||||
|
- **ディープリンクプロトコル**
|
||||||
|
- `ccswitch://` を全プラットフォームで登録
|
||||||
|
- 共有リンクからプロバイダ設定をワンクリックでインポート
|
||||||
|
- セキュリティ検証 + ライフサイクル統合
|
||||||
|
|
||||||
|
- **環境変数の競合検知**
|
||||||
|
- Claude/Codex/Gemini/MCP 間の設定競合を自動検出
|
||||||
|
- 競合表示 + 解決ガイド
|
||||||
|
- 上書き前の警告 + バックアップ
|
||||||
|
|
||||||
|
**コア機能**
|
||||||
|
|
||||||
|
- **プロバイダ管理**:Claude Code、Codex、Gemini の API 設定をワンクリックで切り替え
|
||||||
|
- **速度テスト**:エンドポイント遅延を計測し、品質を可視化
|
||||||
|
- **インポート/エクスポート**:設定をバックアップ・復元(最新 10 件を自動ローテーション)
|
||||||
|
- **多言語対応**:UI/エラー/トレイを含む中国語・英語・日本語ローカライズ
|
||||||
|
- **Claude プラグイン同期**:Claude プラグイン設定をワンクリックで適用/復元
|
||||||
|
|
||||||
|
**v3.6 ハイライト**
|
||||||
|
|
||||||
|
- プロバイダの複製とドラッグ&ドロップ並び替え
|
||||||
|
- 複数エンドポイント管理とカスタム設定ディレクトリ(クラウド同期準備済み)
|
||||||
|
- 4 階層のモデル設定(Haiku/Sonnet/Opus/Custom)
|
||||||
|
- WSL 環境をサポートし、ディレクトリ変更時に自動同期
|
||||||
|
- Hooks テスト 100% カバレッジ + アーキテクチャ全面リファクタ
|
||||||
|
|
||||||
|
**システム機能**
|
||||||
|
|
||||||
|
- クイックスイッチ付きシステムトレイ
|
||||||
|
- シングルインスタンス常駐
|
||||||
|
- ビルトイン自動アップデータ
|
||||||
|
- ロールバック保護付きのアトミック書き込み
|
||||||
|
|
||||||
|
## ダウンロード & インストール
|
||||||
|
|
||||||
|
### システム要件
|
||||||
|
|
||||||
|
- **Windows**: Windows 10 以上
|
||||||
|
- **macOS**: macOS 10.15 (Catalina) 以上
|
||||||
|
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ など主要ディストリビューション
|
||||||
|
|
||||||
|
### Windows ユーザー
|
||||||
|
|
||||||
|
[Releases](../../releases) ページから最新版の `CC-Switch-v{version}-Windows.msi` インストーラー、またはポータブル版 `CC-Switch-v{version}-Windows-Portable.zip` をダウンロード。
|
||||||
|
|
||||||
|
### macOS ユーザー
|
||||||
|
|
||||||
|
**方法 1: Homebrew でインストール(推奨)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
アップデート:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
**方法 2: 手動ダウンロード**
|
||||||
|
|
||||||
|
[Releases](../../releases) から `CC-Switch-v{version}-macOS.zip` をダウンロードして展開。
|
||||||
|
|
||||||
|
> **注意**: 開発者アカウント未登録のため、初回起動時に「開発元を確認できません」と表示される場合があります。一度閉じてから「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックしてください。以降は通常通り起動できます。
|
||||||
|
|
||||||
|
### ArchLinux ユーザー
|
||||||
|
|
||||||
|
**paru でインストール(推奨)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
paru -S cc-switch-bin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux ユーザー
|
||||||
|
|
||||||
|
[Releases](../../releases) から最新版の Linux ビルドをダウンロード:
|
||||||
|
|
||||||
|
- `CC-Switch-v{version}-Linux.deb`(Debian/Ubuntu)
|
||||||
|
- `CC-Switch-v{version}-Linux.rpm`(Fedora/RHEL/openSUSE)
|
||||||
|
- `CC-Switch-v{version}-Linux.AppImage`(汎用)
|
||||||
|
- `CC-Switch-v{version}-Linux.flatpak`(Flatpak)
|
||||||
|
|
||||||
|
Flatpak のインストールと起動:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak install --user ./CC-Switch-v{version}-Linux.flatpak
|
||||||
|
flatpak run com.ccswitch.desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
## クイックスタート
|
||||||
|
|
||||||
|
### 基本的な使い方
|
||||||
|
|
||||||
|
1. **プロバイダ追加**:「Add Provider」をクリック → プリセットを選ぶかカスタム設定を作成
|
||||||
|
2. **プロバイダ切り替え**:
|
||||||
|
- メイン UI: プロバイダを選択 → 「Enable」をクリック
|
||||||
|
- システムトレイ: プロバイダ名をクリック(即時反映)
|
||||||
|
3. **反映**: ターミナルや Claude Code / Codex / Gemini クライアントを再起動して適用
|
||||||
|
4. **公式設定に戻す**: 「Official Login」プリセット(Claude/Codex)または「Google Official」プリセット(Gemini)を選び、対応クライアントを再起動してログイン/OAuth を実行
|
||||||
|
|
||||||
|
### MCP 管理
|
||||||
|
|
||||||
|
- **入口**: 右上の「MCP」ボタンをクリック
|
||||||
|
- **サーバー追加**:
|
||||||
|
- 組み込みテンプレート(mcp-fetch、mcp-filesystem など)を使用
|
||||||
|
- stdio / http / sse の各トランスポートをサポート
|
||||||
|
- アプリごとに独立した MCP を設定可能
|
||||||
|
- **有効/無効**: トグルでライブ設定への同期を切り替え
|
||||||
|
- **同期**: 有効なサーバーは各アプリのライブファイルへ自動同期
|
||||||
|
- **インポート/エクスポート**: Claude/Codex/Gemini の設定ファイルから既存 MCP を取り込み
|
||||||
|
|
||||||
|
### Skills 管理 (v3.7.0 新機能)
|
||||||
|
|
||||||
|
- **入口**: 右上の「Skills」ボタンをクリック
|
||||||
|
- **スキル探索**:
|
||||||
|
- 事前設定済みの GitHub リポジトリを自動スキャン(Anthropic 公式、ComposioHQ、コミュニティなど)
|
||||||
|
- カスタムリポジトリを追加(サブディレクトリスキャン対応)
|
||||||
|
- **インストール**: 「Install」を押すだけで `~/.claude/skills/` に配置
|
||||||
|
- **アンインストール**: 「Uninstall」で安全に削除と状態クリーンアップ
|
||||||
|
- **リポジトリ管理**: カスタム GitHub リポジトリを追加/削除
|
||||||
|
|
||||||
|
### Prompts 管理 (v3.7.0 新機能)
|
||||||
|
|
||||||
|
- **入口**: 右上の「Prompts」ボタンをクリック
|
||||||
|
- **プリセット作成**:
|
||||||
|
- 無制限のシステムプロンプトプリセットを作成
|
||||||
|
- Markdown エディタで記述(シンタックスハイライト + リアルタイムプレビュー)
|
||||||
|
- **プリセット切り替え**: プリセットを選択 → 「Activate」で即適用
|
||||||
|
- **同期先**:
|
||||||
|
- Claude: `~/.claude/CLAUDE.md`
|
||||||
|
- Codex: `~/.codex/AGENTS.md`
|
||||||
|
- Gemini: `~/.gemini/GEMINI.md`
|
||||||
|
- **保護機構**: 切り替え前に現在の内容を自動保存し、手動変更を保持
|
||||||
|
|
||||||
|
### 設定ファイルパス
|
||||||
|
|
||||||
|
**Claude Code**
|
||||||
|
|
||||||
|
- ライブ設定: `~/.claude/settings.json`(または `claude.json`)
|
||||||
|
- API キーフィールド: `env.ANTHROPIC_AUTH_TOKEN` または `env.ANTHROPIC_API_KEY`
|
||||||
|
- MCP サーバー: `~/.claude.json` → `mcpServers`
|
||||||
|
|
||||||
|
**Codex**
|
||||||
|
|
||||||
|
- ライブ設定: `~/.codex/auth.json`(必須)+ `config.toml`(任意)
|
||||||
|
- API キーフィールド: `auth.json` 内の `OPENAI_API_KEY`
|
||||||
|
- MCP サーバー: `~/.codex/config.toml` → `[mcp_servers]` テーブル
|
||||||
|
|
||||||
|
**Gemini**
|
||||||
|
|
||||||
|
- ライブ設定: `~/.gemini/.env`(API キー)+ `~/.gemini/settings.json`(認証モード)
|
||||||
|
- API キーフィールド: `.env` 内の `GEMINI_API_KEY` または `GOOGLE_GEMINI_API_KEY`
|
||||||
|
- 環境変数: `GOOGLE_GEMINI_BASE_URL`、`GEMINI_MODEL` などをサポート
|
||||||
|
- MCP サーバー: `~/.gemini/settings.json` → `mcpServers`
|
||||||
|
- トレイでのクイックスイッチ: プロバイダ切り替えごとに `~/.gemini/.env` を書き換えるため Gemini CLI の再起動は不要
|
||||||
|
|
||||||
|
**CC Switch 保存先 (v3.8.0 新アーキテクチャ)**
|
||||||
|
|
||||||
|
- データベース (SSOT): `~/.cc-switch/cc-switch.db`(SQLite。プロバイダ、MCP、Prompts、Skills を保存)
|
||||||
|
- ローカル設定: `~/.cc-switch/settings.json`(デバイスレベル設定)
|
||||||
|
- バックアップ: `~/.cc-switch/backups/`(自動ローテーション、最新 10 件を保持)
|
||||||
|
|
||||||
|
### クラウド同期の設定
|
||||||
|
|
||||||
|
1. 設定 → 「Custom Configuration Directory」へ進む
|
||||||
|
2. クラウド同期フォルダ(Dropbox、OneDrive、iCloud など)を選択
|
||||||
|
3. アプリを再起動して反映
|
||||||
|
4. 他のデバイスでも同じフォルダを指定すればクロスデバイス同期が有効に
|
||||||
|
|
||||||
|
> **補足**: 初回起動時に既存の Claude/Codex 設定をデフォルトプロバイダとして自動インポートします。
|
||||||
|
|
||||||
|
## アーキテクチャ概要
|
||||||
|
|
||||||
|
### 設計原則
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Frontend (React + TS) │
|
||||||
|
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ Components │ │ Hooks │ │ TanStack Query │ │
|
||||||
|
│ │ (UI) │──│ (Bus. Logic) │──│ (Cache/Sync) │ │
|
||||||
|
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
||||||
|
└────────────────────────┬────────────────────────────────────┘
|
||||||
|
│ Tauri IPC
|
||||||
|
┌────────────────────────▼────────────────────────────────────┐
|
||||||
|
│ Backend (Tauri + Rust) │
|
||||||
|
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ Commands │ │ Services │ │ Models/Config │ │
|
||||||
|
│ │ (API Layer) │──│ (Bus. Layer) │──│ (Data) │ │
|
||||||
|
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**コア設計パターン**
|
||||||
|
|
||||||
|
- **SSOT** (Single Source of Truth): すべてのデータを `~/.cc-switch/cc-switch.db`(SQLite)に集約
|
||||||
|
- **二層ストレージ**: 同期データは SQLite、デバイスデータは JSON
|
||||||
|
- **双方向同期**: 切り替え時はライブファイルへ書き込み、編集時はアクティブプロバイダから逆同期
|
||||||
|
- **アトミック書き込み**: 一時ファイル + rename パターンで設定破損を防止
|
||||||
|
- **並行安全**: Mutex で保護された DB 接続でレースを防ぐ
|
||||||
|
- **レイヤードアーキテクチャ**: Commands → Services → DAO → Database を明確に分離
|
||||||
|
|
||||||
|
**主要コンポーネント**
|
||||||
|
|
||||||
|
- **ProviderService**: プロバイダの CRUD、切り替え、バックフィル、ソート
|
||||||
|
- **McpService**: MCP サーバー管理、インポート/エクスポート、ライブファイル同期
|
||||||
|
- **ConfigService**: 設定のインポート/エクスポート、バックアップローテーション
|
||||||
|
- **SpeedtestService**: API エンドポイントの遅延計測
|
||||||
|
|
||||||
|
**v3.6 リファクタリング**
|
||||||
|
|
||||||
|
- バックエンド: エラーハンドリング → コマンド分割 → テスト → サービス層 → 並行性の 5 フェーズ
|
||||||
|
- フロントエンド: テスト基盤 → hooks → コンポーネント → クリーンアップの 4 ステージ
|
||||||
|
- テスト: hooks 100% カバレッジ + 統合テスト(vitest + MSW)
|
||||||
|
|
||||||
|
## 開発
|
||||||
|
|
||||||
|
### 開発環境
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- pnpm 8+
|
||||||
|
- Rust 1.85+
|
||||||
|
- Tauri CLI 2.8+
|
||||||
|
|
||||||
|
### 開発コマンド
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 依存関係をインストール
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# ホットリロード付き開発モード
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# 型チェック
|
||||||
|
pnpm typecheck
|
||||||
|
|
||||||
|
# コード整形
|
||||||
|
pnpm format
|
||||||
|
|
||||||
|
# フォーマット検証
|
||||||
|
pnpm format:check
|
||||||
|
|
||||||
|
# フロントエンド単体テスト
|
||||||
|
pnpm test:unit
|
||||||
|
|
||||||
|
# ウォッチモード(開発に推奨)
|
||||||
|
pnpm test:unit:watch
|
||||||
|
|
||||||
|
# アプリをビルド
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# デバッグビルド
|
||||||
|
pnpm tauri build --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rust バックエンド開発
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src-tauri
|
||||||
|
|
||||||
|
# Rust コード整形
|
||||||
|
cargo fmt
|
||||||
|
|
||||||
|
# clippy チェック
|
||||||
|
cargo clippy
|
||||||
|
|
||||||
|
# バックエンドテスト
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# 特定テストのみ実行
|
||||||
|
cargo test test_name
|
||||||
|
|
||||||
|
# test-hooks フィーチャー付きでテスト
|
||||||
|
cargo test --features test-hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
### テストガイド (v3.6)
|
||||||
|
|
||||||
|
**フロントエンドテスト**:
|
||||||
|
|
||||||
|
- テストフレームワークに **vitest** を使用
|
||||||
|
- **MSW (Mock Service Worker)** で Tauri API 呼び出しをモック
|
||||||
|
- コンポーネントテストに **@testing-library/react** を採用
|
||||||
|
|
||||||
|
**テストカバレッジ**:
|
||||||
|
|
||||||
|
- Hooks の単体テスト(100% カバレッジ)
|
||||||
|
- `useProviderActions` - プロバイダ操作
|
||||||
|
- `useMcpActions` - MCP 管理
|
||||||
|
- `useSettings` 系 - 設定管理
|
||||||
|
- `useImportExport` - インポート/エクスポート
|
||||||
|
- 統合テスト
|
||||||
|
- アプリのメインフロー
|
||||||
|
- SettingsDialog の一連操作
|
||||||
|
- MCP パネルの機能
|
||||||
|
|
||||||
|
**テスト実行**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 全テストを実行
|
||||||
|
pnpm test:unit
|
||||||
|
|
||||||
|
# ウォッチモード(自動再実行)
|
||||||
|
pnpm test:unit:watch
|
||||||
|
|
||||||
|
# カバレッジレポート付き
|
||||||
|
pnpm test:unit --coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技術スタック
|
||||||
|
|
||||||
|
**フロントエンド**: React 18 · TypeScript · Vite · TailwindCSS 4 · TanStack Query v5 · react-i18next · react-hook-form · zod · shadcn/ui · @dnd-kit
|
||||||
|
|
||||||
|
**バックエンド**: Tauri 2.8 · Rust · serde · tokio · thiserror · tauri-plugin-updater/process/dialog/store/log
|
||||||
|
|
||||||
|
**テスト**: vitest · MSW · @testing-library/react
|
||||||
|
|
||||||
|
## プロジェクト構成
|
||||||
|
|
||||||
|
```
|
||||||
|
├── src/ # フロントエンド (React + TypeScript)
|
||||||
|
│ ├── components/ # UI コンポーネント (providers/settings/mcp/ui)
|
||||||
|
│ ├── hooks/ # ビジネスロジック用カスタムフック
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── api/ # Tauri API ラッパー (型安全)
|
||||||
|
│ │ └── query/ # TanStack Query 設定
|
||||||
|
│ ├── i18n/locales/ # 翻訳 (zh/en)
|
||||||
|
│ ├── config/ # プリセット (providers/mcp)
|
||||||
|
│ └── types/ # TypeScript 型定義
|
||||||
|
├── src-tauri/ # バックエンド (Rust)
|
||||||
|
│ └── src/
|
||||||
|
│ ├── commands/ # Tauri コマンド層 (ドメイン別)
|
||||||
|
│ ├── services/ # ビジネスロジック層
|
||||||
|
│ ├── app_config.rs # 設定モデル
|
||||||
|
│ ├── provider.rs # プロバイダドメインモデル
|
||||||
|
│ ├── mcp.rs # MCP 同期 & 検証
|
||||||
|
│ └── lib.rs # アプリエントリ & トレイメニュー
|
||||||
|
├── tests/ # フロントエンドテスト
|
||||||
|
│ ├── hooks/ # 単体テスト
|
||||||
|
│ └── components/ # 統合テスト
|
||||||
|
└── assets/ # スクリーンショット & スポンサーリソース
|
||||||
|
```
|
||||||
|
|
||||||
|
## 更新履歴
|
||||||
|
|
||||||
|
詳細は [CHANGELOG.md](CHANGELOG.md) をご覧ください。
|
||||||
|
|
||||||
|
## 旧 Electron 版
|
||||||
|
|
||||||
|
[Releases](../../releases) に v2.0.3 の Electron 旧版を残しています。
|
||||||
|
|
||||||
|
旧版コードが必要な場合は `electron-legacy` ブランチを取得してください。
|
||||||
|
|
||||||
|
## 貢献
|
||||||
|
|
||||||
|
Issue や提案を歓迎します!
|
||||||
|
|
||||||
|
PR を送る前に以下をご確認ください:
|
||||||
|
|
||||||
|
- 型チェック: `pnpm typecheck`
|
||||||
|
- フォーマットチェック: `pnpm format:check`
|
||||||
|
- 単体テスト: `pnpm test:unit`
|
||||||
|
- 💡 新機能の場合は、事前に Issue でディスカッションしていただけると助かります
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://www.star-history.com/#farion1231/cc-switch&Date)
|
||||||
|
|
||||||
|
## ライセンス
|
||||||
|
|
||||||
|
MIT © Jason Young
|
||||||
@@ -1,34 +1,41 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
# Claude Code & Codex 供应商管理器
|
# Claude Code / Codex / Gemini CLI 全方位辅助工具
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://github.com/trending/typescript)
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
|
|
||||||
[English](README.md) | 中文 | [更新日志](CHANGELOG.md)
|
<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>
|
||||||
|
|
||||||
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置、MCP的桌面应用。
|
[English](README.md) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md) | [v3.8.0 发布说明](docs/release-note-v3.8.0-zh.md)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## ❤️赞助商
|
## ❤️赞助商
|
||||||
|
|
||||||

|
[](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
|
||||||
|
|
||||||
感谢智谱AI的 GLM CODING PLAN 赞助了本项目!
|
感谢智谱AI的 GLM CODING PLAN 赞助了本项目!GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。
|
||||||
|
|
||||||
GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。
|
|
||||||
|
|
||||||
CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
|
<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>
|
||||||
|
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||||
|
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<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>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -40,12 +47,82 @@ CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编
|
|||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
### 当前版本:v3.6.1 | [完整更新日志](CHANGELOG.md)
|
### 当前版本:v3.8.3 | [完整更新日志](CHANGELOG.md)
|
||||||
|
|
||||||
|
**v3.8.0 重大更新(2025-11-28)**
|
||||||
|
|
||||||
|
**持久化架构升级 & 全新用户界面**
|
||||||
|
|
||||||
|
- **SQLite + JSON 双层架构**
|
||||||
|
- 从 JSON 文件存储迁移到 SQLite + JSON 双层结构
|
||||||
|
- 可同步数据(供应商、MCP、Prompts、Skills)存入 SQLite
|
||||||
|
- 设备级数据(窗口状态、本地路径)保留在 JSON
|
||||||
|
- 为未来云同步功能奠定基础
|
||||||
|
- Schema 版本管理支持数据库迁移
|
||||||
|
|
||||||
|
- **全新用户界面**
|
||||||
|
- 完全重新设计的界面布局
|
||||||
|
- 统一的组件样式和更流畅的动画
|
||||||
|
- 优化的视觉层次
|
||||||
|
- Tailwind CSS 从 v4 降级到 v3.4 以提升浏览器兼容性
|
||||||
|
|
||||||
|
- **日语支持**
|
||||||
|
- 新增日语界面支持(现支持中文/英文/日语)
|
||||||
|
|
||||||
|
- **开机自启**
|
||||||
|
- 在设置中一键开启/关闭
|
||||||
|
- 使用平台原生 API(注册表/LaunchAgent/XDG autostart)
|
||||||
|
|
||||||
|
- **Skills 递归扫描**
|
||||||
|
- 支持多层目录结构
|
||||||
|
- 允许不同仓库的同名技能
|
||||||
|
|
||||||
|
- **关键 Bug 修复**
|
||||||
|
- 修复更新供应商时自定义端点丢失问题
|
||||||
|
- 修复 Gemini 配置写入问题
|
||||||
|
- 修复 Linux WebKitGTK 渲染问题
|
||||||
|
|
||||||
|
**v3.7.0 亮点**
|
||||||
|
|
||||||
|
**六大核心功能,18,000+ 行新增代码**
|
||||||
|
|
||||||
|
- **Gemini CLI 集成**
|
||||||
|
- 第三个支持的 AI CLI(Claude Code / Codex / Gemini)
|
||||||
|
- 双文件配置支持(`.env` + `settings.json`)
|
||||||
|
- 完整 MCP 服务器管理
|
||||||
|
- 预设:Google Official (OAuth) / PackyCode / 自定义
|
||||||
|
|
||||||
|
- **Claude Skills 管理系统**
|
||||||
|
- 从 GitHub 仓库自动扫描技能(预配置 3 个精选仓库)
|
||||||
|
- 一键安装/卸载到 `~/.claude/skills/`
|
||||||
|
- 自定义仓库支持 + 子目录扫描
|
||||||
|
- 完整生命周期管理(发现/安装/更新)
|
||||||
|
|
||||||
|
- **Prompts 管理系统**
|
||||||
|
- 多预设系统提示词管理(无限数量,快速切换)
|
||||||
|
- 跨应用支持(Claude: `CLAUDE.md` / Codex: `AGENTS.md` / Gemini: `GEMINI.md`)
|
||||||
|
- Markdown 编辑器(CodeMirror 6 + 实时预览)
|
||||||
|
- 智能回填保护,保留手动修改
|
||||||
|
|
||||||
|
- **MCP v3.7.0 统一架构**
|
||||||
|
- 单一面板管理三个应用的 MCP 服务器
|
||||||
|
- 新增 SSE (Server-Sent Events) 传输类型
|
||||||
|
- 智能 JSON 解析器 + Codex TOML 格式自动修正
|
||||||
|
- 统一导入/导出 + 双向同步
|
||||||
|
|
||||||
|
- **深度链接协议**
|
||||||
|
- `ccswitch://` 协议注册(全平台)
|
||||||
|
- 通过共享链接一键导入供应商配置
|
||||||
|
- 安全验证 + 生命周期集成
|
||||||
|
|
||||||
|
- **环境变量冲突检测**
|
||||||
|
- 自动检测跨应用配置冲突(Claude/Codex/Gemini/MCP)
|
||||||
|
- 可视化冲突指示器 + 解决建议
|
||||||
|
- 覆盖警告 + 更改前备份
|
||||||
|
|
||||||
**核心功能**
|
**核心功能**
|
||||||
|
|
||||||
- **供应商管理**:一键切换 Claude Code 与 Codex 的 API 配置
|
- **供应商管理**:一键切换 Claude Code、Codex 与 Gemini 的 API 配置
|
||||||
- **MCP 集成**:集中管理 MCP 服务器,支持 stdio/http 类型和实时同步
|
|
||||||
- **速度测试**:测量 API 端点延迟,可视化连接质量指示器
|
- **速度测试**:测量 API 端点延迟,可视化连接质量指示器
|
||||||
- **导入导出**:备份和恢复配置,自动轮换(保留最近 10 个)
|
- **导入导出**:备份和恢复配置,自动轮换(保留最近 10 个)
|
||||||
- **国际化支持**:完整的中英文本地化(UI、错误、托盘)
|
- **国际化支持**:完整的中英文本地化(UI、错误、托盘)
|
||||||
@@ -58,7 +135,6 @@ CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编
|
|||||||
- 细粒度模型配置(四层:Haiku/Sonnet/Opus/自定义)
|
- 细粒度模型配置(四层:Haiku/Sonnet/Opus/自定义)
|
||||||
- WSL 环境支持,配置目录切换自动同步
|
- WSL 环境支持,配置目录切换自动同步
|
||||||
- 100% hooks 测试覆盖 & 完整架构重构
|
- 100% hooks 测试覆盖 & 完整架构重构
|
||||||
- 新增预设:DMXAPI、Azure Codex、AnyRouter、AiHubMix、MiniMax
|
|
||||||
|
|
||||||
**系统功能**
|
**系统功能**
|
||||||
|
|
||||||
@@ -100,9 +176,29 @@ brew upgrade --cask cc-switch
|
|||||||
|
|
||||||
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
||||||
|
|
||||||
|
### ArchLinux 用户
|
||||||
|
|
||||||
|
**通过 paru 安装(推荐)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
paru -S cc-switch-bin
|
||||||
|
```
|
||||||
|
|
||||||
### Linux 用户
|
### Linux 用户
|
||||||
|
|
||||||
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Linux.deb` 包或者 `CC-Switch-v{版本号}-Linux.AppImage` 安装包。
|
从 [Releases](../../releases) 页面下载最新版本的 Linux 安装包:
|
||||||
|
|
||||||
|
- `CC-Switch-v{版本号}-Linux.deb`(Debian/Ubuntu)
|
||||||
|
- `CC-Switch-v{版本号}-Linux.rpm`(Fedora/RHEL/openSUSE)
|
||||||
|
- `CC-Switch-v{版本号}-Linux.AppImage`(通用)
|
||||||
|
- `CC-Switch-v{版本号}-Linux.flatpak`(Flatpak)
|
||||||
|
|
||||||
|
Flatpak 安装与运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak install --user ./CC-Switch-v{版本号}-Linux.flatpak
|
||||||
|
flatpak run com.ccswitch.desktop
|
||||||
|
```
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -112,15 +208,42 @@ brew upgrade --cask cc-switch
|
|||||||
2. **切换供应商**:
|
2. **切换供应商**:
|
||||||
- 主界面:选择供应商 → 点击"启用"
|
- 主界面:选择供应商 → 点击"启用"
|
||||||
- 系统托盘:直接点击供应商名称(立即生效)
|
- 系统托盘:直接点击供应商名称(立即生效)
|
||||||
3. **生效方式**:重启终端或 Claude Code/Codex 以应用更改
|
3. **生效方式**:重启终端或 Claude Code / Codex / Gemini 客户端以应用更改
|
||||||
4. **恢复官方登录**:选择"官方登录"预设,重启终端后使用 `/login`(Claude)或官方登录流程(Codex)
|
4. **恢复官方登录**:选择"官方登录"预设(Claude/Codex)或"Google 官方"预设(Gemini),重启对应客户端后按照其登录/OAuth 流程操作
|
||||||
|
|
||||||
### MCP 管理
|
### MCP 管理
|
||||||
|
|
||||||
- **位置**:点击右上角"MCP"按钮
|
- **位置**:点击右上角"MCP"按钮
|
||||||
- **添加服务器**:使用内置模板(mcp-fetch、mcp-filesystem)或自定义配置
|
- **添加服务器**:
|
||||||
|
- 使用内置模板(mcp-fetch、mcp-filesystem 等)
|
||||||
|
- 支持 stdio / http / sse 三种传输类型
|
||||||
|
- 为不同应用配置独立的 MCP 服务器
|
||||||
- **启用/禁用**:切换开关以控制哪些服务器同步到 live 配置
|
- **启用/禁用**:切换开关以控制哪些服务器同步到 live 配置
|
||||||
- **同步**:启用的服务器自动同步到 `~/.claude.json`(Claude)或 `~/.codex/config.toml`(Codex)
|
- **同步**:启用的服务器自动同步到各应用的 live 文件
|
||||||
|
- **导入/导出**:支持从 Claude/Codex/Gemini 配置文件导入现有 MCP 服务器
|
||||||
|
|
||||||
|
### Skills 管理(v3.7.0 新增)
|
||||||
|
|
||||||
|
- **位置**:点击右上角"Skills"按钮
|
||||||
|
- **发现技能**:
|
||||||
|
- 自动扫描预配置的 GitHub 仓库(Anthropic 官方、ComposioHQ、社区等)
|
||||||
|
- 添加自定义仓库(支持子目录扫描)
|
||||||
|
- **安装技能**:点击"安装"一键安装到 `~/.claude/skills/`
|
||||||
|
- **卸载技能**:点击"卸载"安全移除并清理状态
|
||||||
|
- **管理仓库**:添加/删除自定义 GitHub 仓库
|
||||||
|
|
||||||
|
### Prompts 管理(v3.7.0 新增)
|
||||||
|
|
||||||
|
- **位置**:点击右上角"Prompts"按钮
|
||||||
|
- **创建预设**:
|
||||||
|
- 创建无限数量的系统提示词预设
|
||||||
|
- 使用 Markdown 编辑器编写提示词(语法高亮 + 实时预览)
|
||||||
|
- **切换预设**:选择预设 → 点击"激活"立即应用
|
||||||
|
- **同步机制**:
|
||||||
|
- Claude: `~/.claude/CLAUDE.md`
|
||||||
|
- Codex: `~/.codex/AGENTS.md`
|
||||||
|
- Gemini: `~/.gemini/GEMINI.md`
|
||||||
|
- **保护机制**:切换前自动保存当前提示词内容,保留手动修改
|
||||||
|
|
||||||
### 配置文件
|
### 配置文件
|
||||||
|
|
||||||
@@ -134,12 +257,20 @@ brew upgrade --cask cc-switch
|
|||||||
|
|
||||||
- Live 配置:`~/.codex/auth.json`(必需)+ `config.toml`(可选)
|
- Live 配置:`~/.codex/auth.json`(必需)+ `config.toml`(可选)
|
||||||
- API key 字段:`auth.json` 中的 `OPENAI_API_KEY`
|
- API key 字段:`auth.json` 中的 `OPENAI_API_KEY`
|
||||||
- MCP 服务器:`~/.codex/config.toml` → `[mcp.servers]`
|
- MCP 服务器:`~/.codex/config.toml` → `[mcp_servers]` 表
|
||||||
|
|
||||||
**CC Switch 存储**
|
**Gemini**
|
||||||
|
|
||||||
- 主配置(SSOT):`~/.cc-switch/config.json`
|
- Live 配置:`~/.gemini/.env`(API Key)+ `~/.gemini/settings.json`(保存认证模式)
|
||||||
- 设置:`~/.cc-switch/settings.json`
|
- API key 字段:`.env` 文件中的 `GEMINI_API_KEY` 或 `GOOGLE_GEMINI_API_KEY`
|
||||||
|
- 环境变量:支持 `GOOGLE_GEMINI_BASE_URL`、`GEMINI_MODEL` 等自定义变量
|
||||||
|
- MCP 服务器:`~/.gemini/settings.json` → `mcpServers`
|
||||||
|
- 托盘快速切换:每次切换供应商都会重写 `~/.gemini/.env`,无需重启 Gemini CLI 即可生效
|
||||||
|
|
||||||
|
**CC Switch 存储(v3.8.0 新架构)**
|
||||||
|
|
||||||
|
- 数据库(SSOT):`~/.cc-switch/cc-switch.db`(SQLite,存储供应商、MCP、Prompts、Skills)
|
||||||
|
- 本地设置:`~/.cc-switch/settings.json`(设备级设置)
|
||||||
- 备份:`~/.cc-switch/backups/`(自动轮换,保留 10 个)
|
- 备份:`~/.cc-switch/backups/`(自动轮换,保留 10 个)
|
||||||
|
|
||||||
### 云同步设置
|
### 云同步设置
|
||||||
@@ -157,29 +288,30 @@ brew upgrade --cask cc-switch
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ 前端 (React + TS) │
|
│ 前端 (React + TS) │
|
||||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||||
│ │ Components │ │ Hooks │ │ TanStack Query │ │
|
│ │ Components │ │ Hooks │ │ TanStack Query │ │
|
||||||
│ │ (UI) │──│ (业务逻辑) │──│ (缓存/同步) │ │
|
│ │ (UI) │──│ (业务逻辑) │──│ (缓存/同步) │ │
|
||||||
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
||||||
└────────────────────────┬────────────────────────────────────┘
|
└────────────────────────┬────────────────────────────────────┘
|
||||||
│ Tauri IPC
|
│ Tauri IPC
|
||||||
┌────────────────────────▼────────────────────────────────────┐
|
┌────────────────────────▼────────────────────────────────────┐
|
||||||
│ 后端 (Tauri + Rust) │
|
│ 后端 (Tauri + Rust) │
|
||||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||||
│ │ Commands │ │ Services │ │ Models/Config │ │
|
│ │ Commands │ │ Services │ │ Models/Config │ │
|
||||||
│ │ (API 层) │──│ (业务层) │──│ (数据) │ │
|
│ │ (API 层) │──│ (业务层) │──│ (数据) │ │
|
||||||
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**核心设计模式**
|
**核心设计模式**
|
||||||
|
|
||||||
- **SSOT**(单一事实源):所有供应商配置存储在 `~/.cc-switch/config.json`
|
- **SSOT**(单一事实源):所有数据存储在 `~/.cc-switch/cc-switch.db`(SQLite)
|
||||||
|
- **双层存储**:SQLite 存储可同步数据,JSON 存储设备级设置
|
||||||
- **双向同步**:切换时写入 live 文件,编辑当前供应商时从 live 回填
|
- **双向同步**:切换时写入 live 文件,编辑当前供应商时从 live 回填
|
||||||
- **原子写入**:临时文件 + 重命名模式防止配置损坏
|
- **原子写入**:临时文件 + 重命名模式防止配置损坏
|
||||||
- **并发安全**:RwLock 与作用域守卫避免死锁
|
- **并发安全**:Mutex 保护的数据库连接避免竞态条件
|
||||||
- **分层架构**:清晰分离(Commands → Services → Models)
|
- **分层架构**:清晰分离(Commands → Services → DAO → Database)
|
||||||
|
|
||||||
**核心组件**
|
**核心组件**
|
||||||
|
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
# CC Switch 国际化功能说明
|
|
||||||
|
|
||||||
## 已完成的工作
|
|
||||||
|
|
||||||
1. **安装依赖**:添加了 `react-i18next` 和 `i18next` 包
|
|
||||||
2. **配置国际化**:在 `src/i18n/` 目录下创建了配置文件
|
|
||||||
3. **翻译文件**:创建了英文和中文翻译文件
|
|
||||||
4. **组件更新**:替换了主要组件中的硬编码文案
|
|
||||||
5. **语言切换器**:添加了语言切换按钮
|
|
||||||
|
|
||||||
## 文件结构
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── i18n/
|
|
||||||
│ ├── index.ts # 国际化配置文件
|
|
||||||
│ └── locales/
|
|
||||||
│ ├── en.json # 英文翻译
|
|
||||||
│ └── zh.json # 中文翻译
|
|
||||||
├── components/
|
|
||||||
│ └── LanguageSwitcher.tsx # 语言切换组件
|
|
||||||
└── main.tsx # 导入国际化配置
|
|
||||||
```
|
|
||||||
|
|
||||||
## 默认语言设置
|
|
||||||
|
|
||||||
- **默认语言**:英文 (en)
|
|
||||||
- **回退语言**:英文 (en)
|
|
||||||
|
|
||||||
## 使用方式
|
|
||||||
|
|
||||||
1. 在组件中导入 `useTranslation`:
|
|
||||||
```tsx
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return <div>{t('common.save')}</div>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 切换语言:
|
|
||||||
```tsx
|
|
||||||
const { i18n } = useTranslation();
|
|
||||||
i18n.changeLanguage('zh'); // 切换到中文
|
|
||||||
```
|
|
||||||
|
|
||||||
## 翻译键结构
|
|
||||||
|
|
||||||
- `common.*` - 通用文案(保存、取消、设置等)
|
|
||||||
- `header.*` - 头部相关文案
|
|
||||||
- `provider.*` - 供应商相关文案
|
|
||||||
- `notifications.*` - 通知消息
|
|
||||||
- `settings.*` - 设置页面文案
|
|
||||||
- `apps.*` - 应用名称
|
|
||||||
- `console.*` - 控制台日志信息
|
|
||||||
|
|
||||||
## 测试功能
|
|
||||||
|
|
||||||
应用已添加了语言切换按钮(地球图标),点击可以在中英文之间切换,验证国际化功能是否正常工作。
|
|
||||||
|
|
||||||
## 已更新的组件
|
|
||||||
|
|
||||||
- ✅ App.tsx - 主应用组件
|
|
||||||
- ✅ ConfirmDialog.tsx - 确认对话框
|
|
||||||
- ✅ AddProviderModal.tsx - 添加供应商弹窗
|
|
||||||
- ✅ EditProviderModal.tsx - 编辑供应商弹窗
|
|
||||||
- ✅ ProviderList.tsx - 供应商列表
|
|
||||||
- ✅ LanguageSwitcher.tsx - 语言切换器
|
|
||||||
- ✅ settings/SettingsDialog.tsx - 设置对话框
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
1. 所有新的文案都应该添加到翻译文件中,而不是硬编码
|
|
||||||
2. 翻译键名应该有意义且结构化
|
|
||||||
3. 可以通过修改 `src/i18n/index.ts` 中的 `lng` 配置来更改默认语言
|
|
||||||
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 299 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 204 KiB After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 205 KiB After Width: | Height: | Size: 225 KiB |
@@ -4,7 +4,7 @@
|
|||||||
"rsc": false,
|
"rsc": false,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "tailwind.config.js",
|
"config": "tailwind.config.cjs",
|
||||||
"css": "src/index.css",
|
"css": "src/index.css",
|
||||||
"baseColor": "neutral",
|
"baseColor": "neutral",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# CC Switch 代理功能使用指南
|
||||||
|
|
||||||
|
## 功能介绍
|
||||||
|
|
||||||
|
CC Switch 的代理功能是一个本地 HTTP 代理服务器,可以统一管理 Claude Code、Codex 和 Gemini CLI 的 API 请求。主要特性包括:
|
||||||
|
|
||||||
|
- **统一代理入口** - 所有 CLI 应用的请求通过本地代理转发
|
||||||
|
- **自动故障转移** - 当前供应商故障时自动切换到备用供应商
|
||||||
|
- **按应用控制** - 可独立控制每个应用是否启用代理
|
||||||
|
- **配置保护** - 自动备份原始配置,停止代理时安全恢复
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 启动代理
|
||||||
|
|
||||||
|
在 CC Switch 主界面,点击右上角的 **Proxy** 按钮,可以看到代理控制面板。
|
||||||
|
|
||||||
|
点击 **启动代理** 按钮启动本地代理服务器。代理默认监听 `127.0.0.1:15721`。
|
||||||
|
|
||||||
|
### 2. 启用应用接管
|
||||||
|
|
||||||
|
代理启动后,你可以选择让哪些应用的请求通过代理:
|
||||||
|
|
||||||
|
- **Claude** - 接管 Claude Code 的 API 请求
|
||||||
|
- **Codex** - 接管 Codex CLI 的 API 请求
|
||||||
|
- **Gemini** - 接管 Gemini CLI 的 API 请求
|
||||||
|
|
||||||
|
点击对应应用的开关即可启用/禁用接管。
|
||||||
|
|
||||||
|
> **注意**:启用接管后,CC Switch 会自动修改对应应用的配置文件,将 API 端点指向本地代理。原始配置会被安全备份。
|
||||||
|
|
||||||
|
### 3. 正常使用 CLI
|
||||||
|
|
||||||
|
启用接管后,你可以正常使用各个 CLI 工具。所有请求都会经过 CC Switch 代理转发到配置的供应商。
|
||||||
|
|
||||||
|
### 4. 停止代理
|
||||||
|
|
||||||
|
当你不再需要代理时,点击 **停止代理** 按钮。CC Switch 会:
|
||||||
|
|
||||||
|
1. 安全关闭代理服务器
|
||||||
|
2. 自动恢复所有应用的原始配置
|
||||||
|
3. 清除代理状态
|
||||||
|
|
||||||
|
## 自动故障转移
|
||||||
|
|
||||||
|
### 工作原理
|
||||||
|
|
||||||
|
代理功能内置了智能故障转移机制:
|
||||||
|
|
||||||
|
1. **健康监控** - 实时监控每个供应商的响应状态
|
||||||
|
2. **熔断器** - 连续失败 5 次后触发熔断,暂停使用该供应商
|
||||||
|
3. **自动切换** - 熔断后自动切换到列表中的下一个供应商
|
||||||
|
4. **自动恢复** - 30 秒后尝试恢复熔断的供应商
|
||||||
|
|
||||||
|
### 配置故障转移
|
||||||
|
|
||||||
|
要使用故障转移功能,你需要:
|
||||||
|
|
||||||
|
1. 在对应应用下添加多个供应商(至少 2 个)
|
||||||
|
2. 启动代理并启用接管
|
||||||
|
3. 当主供应商故障时,代理会自动切换到备用供应商
|
||||||
|
|
||||||
|
### 健康状态指示
|
||||||
|
|
||||||
|
在供应商卡片上可以看到健康状态指示:
|
||||||
|
|
||||||
|
- **绿色** - 供应商正常
|
||||||
|
- **红色** - 供应商故障/熔断中
|
||||||
|
- **灰色** - 未使用代理或未检测
|
||||||
|
|
||||||
|
## 按应用接管
|
||||||
|
|
||||||
|
v3.9.0 新增了按应用分粒度控制功能:
|
||||||
|
|
||||||
|
- 你可以只接管 Claude,而让 Codex 使用原始配置
|
||||||
|
- 每个应用的接管状态独立管理
|
||||||
|
- 启用/禁用不会影响其他应用
|
||||||
|
|
||||||
|
### 接管状态检测
|
||||||
|
|
||||||
|
CC Switch 通过检测配置备份来判断接管状态:
|
||||||
|
|
||||||
|
- 存在备份 = 已接管
|
||||||
|
- 无备份 = 未接管
|
||||||
|
|
||||||
|
这确保了即使 CC Switch 异常退出,重新启动后也能正确识别状态。
|
||||||
|
|
||||||
|
## 代理配置
|
||||||
|
|
||||||
|
在代理面板中,你可以配置以下参数:
|
||||||
|
|
||||||
|
| 参数 | 默认值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 监听地址 | 127.0.0.1 | 代理服务器绑定地址 |
|
||||||
|
| 监听端口 | 15721 | 代理服务器端口 |
|
||||||
|
| 最大重试 | 3 | 请求失败时的最大重试次数 |
|
||||||
|
| 请求超时 | 120 秒 | 单个请求的超时时间 |
|
||||||
|
| 启用日志 | 是 | 是否记录请求日志 |
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 代理启动失败,提示端口被占用?
|
||||||
|
|
||||||
|
A: 默认端口 15721 可能被其他程序占用。你可以:
|
||||||
|
- 关闭占用该端口的程序
|
||||||
|
- 在代理配置中修改端口号
|
||||||
|
|
||||||
|
### Q: 启用接管后 CLI 无法使用?
|
||||||
|
|
||||||
|
A: 请检查:
|
||||||
|
1. 代理服务器是否正常运行(查看代理面板状态)
|
||||||
|
2. 供应商配置是否正确(API Key 等)
|
||||||
|
3. 网络连接是否正常
|
||||||
|
|
||||||
|
### Q: 如何恢复原始配置?
|
||||||
|
|
||||||
|
A: 点击 **停止代理** 按钮,CC Switch 会自动恢复所有应用的原始配置。
|
||||||
|
|
||||||
|
如果 CC Switch 异常退出,重新启动后会检测到之前的备份,你可以:
|
||||||
|
- 点击停止代理来恢复配置
|
||||||
|
- 或继续使用代理功能
|
||||||
|
|
||||||
|
### Q: 故障转移没有生效?
|
||||||
|
|
||||||
|
A: 请确保:
|
||||||
|
1. 配置了至少 2 个供应商
|
||||||
|
2. 代理已启动且接管已启用
|
||||||
|
3. 故障转移只在代理模式下工作
|
||||||
|
|
||||||
|
### Q: 代理会影响性能吗?
|
||||||
|
|
||||||
|
A: 本地代理的延迟开销非常小(通常 < 1ms)。但如果启用了请求日志,在高频请求场景下可能会有少量性能影响。
|
||||||
|
|
||||||
|
## 技术细节
|
||||||
|
|
||||||
|
### 配置文件位置
|
||||||
|
|
||||||
|
启用接管后,CC Switch 会修改以下配置文件:
|
||||||
|
|
||||||
|
| 应用 | 配置文件 | 修改内容 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| Claude | `~/.claude/settings.json` | `apiBaseUrl` 指向代理 |
|
||||||
|
| Codex | `~/.codex/config.toml` | `[api] baseUrl` 指向代理 |
|
||||||
|
| Gemini | `~/.gemini/.env` | `GEMINI_BASE_URL` 指向代理 |
|
||||||
|
|
||||||
|
原始配置备份在 CC Switch 数据库中,停止代理时自动恢复。
|
||||||
|
|
||||||
|
### 代理模式
|
||||||
|
|
||||||
|
代理服务器运行在接管模式下,会:
|
||||||
|
|
||||||
|
1. 接收来自 CLI 的 HTTPS 请求
|
||||||
|
2. 根据当前供应商配置转发到真实 API 端点
|
||||||
|
3. 返回响应给 CLI
|
||||||
|
4. 记录请求日志和健康状态
|
||||||
|
|
||||||
|
### 数据库表
|
||||||
|
|
||||||
|
代理功能使用以下数据库表:
|
||||||
|
|
||||||
|
- `proxy_config` - 代理配置
|
||||||
|
- `provider_health` - 供应商健康状态
|
||||||
|
- `proxy_request_logs` - 请求日志
|
||||||
|
- `circuit_breaker_config` - 熔断器配置
|
||||||
|
- `proxy_live_backup` - Live 配置备份
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
# CC Switch v3.7.0
|
||||||
|
|
||||||
|
> From Provider Switcher to All-in-One AI CLI Management Platform
|
||||||
|
|
||||||
|
**[中文更新说明 Chinese Documentation →](release-note-v3.7.0-zh.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
CC Switch v3.7.0 introduces six major features with over 18,000 lines of new code.
|
||||||
|
|
||||||
|
**Release Date**: 2025-11-19
|
||||||
|
**Commits**: 85 from v3.6.0
|
||||||
|
**Code Changes**: 152 files, +18,104 / -3,732 lines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
### Gemini CLI Integration
|
||||||
|
|
||||||
|
Complete support for Google Gemini CLI, becoming the third supported application (Claude Code, Codex, Gemini).
|
||||||
|
|
||||||
|
**Core Capabilities**:
|
||||||
|
|
||||||
|
- **Dual-file configuration** - Support for both `.env` and `settings.json` formats
|
||||||
|
- **Auto-detection** - Automatically detect `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.
|
||||||
|
- **Full MCP support** - Complete MCP server management for Gemini
|
||||||
|
- **Deep link integration** - Import via `ccswitch://` protocol
|
||||||
|
- **System tray** - Quick-switch from tray menu
|
||||||
|
|
||||||
|
**Provider Presets**:
|
||||||
|
|
||||||
|
- **Google Official** - OAuth authentication support
|
||||||
|
- **PackyCode** - Partner integration
|
||||||
|
- **Custom** - Full customization support
|
||||||
|
|
||||||
|
**Technical Implementation**:
|
||||||
|
|
||||||
|
- New backend modules: `gemini_config.rs` (20KB), `gemini_mcp.rs`
|
||||||
|
- Form synchronization with environment editor
|
||||||
|
- Dual-file atomic writes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### MCP v3.7.0 Unified Architecture
|
||||||
|
|
||||||
|
Complete refactoring of MCP management system for cross-application unification.
|
||||||
|
|
||||||
|
**Architecture Improvements**:
|
||||||
|
|
||||||
|
- **Unified panel** - Single interface for Claude/Codex/Gemini MCP servers
|
||||||
|
- **SSE transport** - New Server-Sent Events support
|
||||||
|
- **Smart parser** - Fault-tolerant JSON parsing
|
||||||
|
- **Format correction** - Auto-fix Codex `[mcp_servers]` format
|
||||||
|
- **Extended fields** - Preserve custom TOML fields
|
||||||
|
|
||||||
|
**User Experience**:
|
||||||
|
|
||||||
|
- Default app selection in forms
|
||||||
|
- JSON formatter for validation
|
||||||
|
- Improved visual hierarchy
|
||||||
|
- Better error messages
|
||||||
|
|
||||||
|
**Import/Export**:
|
||||||
|
|
||||||
|
- Unified import from all three apps
|
||||||
|
- Bidirectional synchronization
|
||||||
|
- State preservation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Claude Skills Management System
|
||||||
|
|
||||||
|
**Approximately 2,000 lines of code** - A complete skill ecosystem platform.
|
||||||
|
|
||||||
|
**GitHub Integration**:
|
||||||
|
|
||||||
|
- Auto-scan skills from GitHub repositories
|
||||||
|
- Pre-configured repos:
|
||||||
|
- `ComposioHQ/awesome-claude-skills` - Curated collection
|
||||||
|
- `anthropics/skills` - Official Anthropic skills
|
||||||
|
- `cexll/myclaude` - Community contributions
|
||||||
|
- Add custom repositories
|
||||||
|
- Subdirectory scanning support (`skillsPath`)
|
||||||
|
|
||||||
|
**Lifecycle Management**:
|
||||||
|
|
||||||
|
- **Discover** - Auto-detect `SKILL.md` files
|
||||||
|
- **Install** - One-click to `~/.claude/skills/`
|
||||||
|
- **Uninstall** - Safe removal with tracking
|
||||||
|
- **Update** - Check for updates (infrastructure ready)
|
||||||
|
|
||||||
|
**Technical Architecture**:
|
||||||
|
|
||||||
|
- **Backend**: `SkillService` (526 lines) with GitHub API integration
|
||||||
|
- **Frontend**: SkillsPage, SkillCard, RepoManager
|
||||||
|
- **UI Components**: Badge, Card, Table (shadcn/ui)
|
||||||
|
- **State**: Persistent storage in `skills.json`
|
||||||
|
- **i18n**: 47+ translation keys
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Prompts Management System
|
||||||
|
|
||||||
|
**Approximately 1,300 lines of code** - Complete system prompt management.
|
||||||
|
|
||||||
|
**Multi-Preset Management**:
|
||||||
|
|
||||||
|
- Create unlimited prompt presets
|
||||||
|
- Quick switch between presets
|
||||||
|
- One active prompt at a time
|
||||||
|
- Delete protection for active prompts
|
||||||
|
|
||||||
|
**Cross-App Support**:
|
||||||
|
|
||||||
|
- **Claude**: `~/.claude/CLAUDE.md`
|
||||||
|
- **Codex**: `~/.codex/AGENTS.md`
|
||||||
|
- **Gemini**: `~/.gemini/GEMINI.md`
|
||||||
|
|
||||||
|
**Markdown Editor**:
|
||||||
|
|
||||||
|
- Full-featured CodeMirror 6 integration
|
||||||
|
- Syntax highlighting
|
||||||
|
- Dark theme (One Dark)
|
||||||
|
- Real-time preview
|
||||||
|
|
||||||
|
**Smart Synchronization**:
|
||||||
|
|
||||||
|
- **Auto-write** - Immediately write to live files
|
||||||
|
- **Backfill protection** - Save current content before switching
|
||||||
|
- **Auto-import** - Import from live files on first launch
|
||||||
|
- **Modification protection** - Preserve manual modifications
|
||||||
|
|
||||||
|
**Technical Implementation**:
|
||||||
|
|
||||||
|
- **Backend**: `PromptService` (213 lines)
|
||||||
|
- **Frontend**: PromptPanel (177), PromptFormModal (160), MarkdownEditor (159)
|
||||||
|
- **Hooks**: usePromptActions (152 lines)
|
||||||
|
- **i18n**: 41+ translation keys
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Deep Link Protocol (ccswitch://)
|
||||||
|
|
||||||
|
One-click provider configuration import via URL scheme.
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
|
||||||
|
- Protocol registration on all platforms
|
||||||
|
- Import from shared links
|
||||||
|
- Lifecycle integration
|
||||||
|
- Security validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Environment Variable Conflict Detection
|
||||||
|
|
||||||
|
Intelligent detection and management of configuration conflicts.
|
||||||
|
|
||||||
|
**Detection Scope**:
|
||||||
|
|
||||||
|
- **Claude & Codex** - Cross-app conflicts
|
||||||
|
- **Gemini** - Auto-discovery
|
||||||
|
- **MCP** - Server configuration conflicts
|
||||||
|
|
||||||
|
**Management Features**:
|
||||||
|
|
||||||
|
- Visual conflict indicators
|
||||||
|
- Resolution suggestions
|
||||||
|
- Override warnings
|
||||||
|
- Backup before changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Improvements
|
||||||
|
|
||||||
|
### Provider Management
|
||||||
|
|
||||||
|
**New Presets**:
|
||||||
|
|
||||||
|
- **DouBaoSeed** - ByteDance's DouBao
|
||||||
|
- **Kimi For Coding** - Moonshot AI
|
||||||
|
- **BaiLing** - BaiLing AI
|
||||||
|
- **Removed AnyRouter** - To avoid confusion
|
||||||
|
|
||||||
|
**Enhancements**:
|
||||||
|
|
||||||
|
- Model name configuration for Codex and Gemini
|
||||||
|
- Provider notes field for organization
|
||||||
|
- Enhanced preset metadata
|
||||||
|
|
||||||
|
### Configuration Management
|
||||||
|
|
||||||
|
- **Common config migration** - From localStorage to `config.json`
|
||||||
|
- **Unified persistence** - Shared across all apps
|
||||||
|
- **Auto-import** - First launch configuration import
|
||||||
|
- **Backfill priority** - Correct handling of live files
|
||||||
|
|
||||||
|
### UI/UX Improvements
|
||||||
|
|
||||||
|
**Design System**:
|
||||||
|
|
||||||
|
- **macOS native** - System-aligned color scheme
|
||||||
|
- **Window centering** - Default centered position
|
||||||
|
- **Visual polish** - Improved spacing and hierarchy
|
||||||
|
|
||||||
|
**Interactions**:
|
||||||
|
|
||||||
|
- **Password input** - Fixed Edge/IE reveal buttons
|
||||||
|
- **URL overflow** - Fixed card overflow
|
||||||
|
- **Error copying** - Copy-to-clipboard errors
|
||||||
|
- **Tray sync** - Real-time drag-and-drop sync
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
### Critical Fixes
|
||||||
|
|
||||||
|
- **Usage script validation** - Boundary checks
|
||||||
|
- **Gemini validation** - Relaxed constraints
|
||||||
|
- **TOML parsing** - CJK quote handling
|
||||||
|
- **MCP fields** - Custom field preservation
|
||||||
|
- **White screen** - FormLabel crash fix
|
||||||
|
|
||||||
|
### Stability
|
||||||
|
|
||||||
|
- **Tray safety** - Pattern matching instead of unwrap
|
||||||
|
- **Error isolation** - Tray failures don't block operations
|
||||||
|
- **Import classification** - Correct category assignment
|
||||||
|
|
||||||
|
### UI Fixes
|
||||||
|
|
||||||
|
- **Model placeholders** - Removed misleading hints
|
||||||
|
- **Base URL** - Auto-fill for third-party providers
|
||||||
|
- **Drag sort** - Tray menu synchronization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Improvements
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
**MCP v3.7.0**:
|
||||||
|
|
||||||
|
- Removed legacy code (~1,000 lines)
|
||||||
|
- Unified initialization structure
|
||||||
|
- Backward compatibility maintained
|
||||||
|
- Comprehensive code formatting
|
||||||
|
|
||||||
|
**Platform Compatibility**:
|
||||||
|
|
||||||
|
- Windows winreg API fix (v0.52)
|
||||||
|
- Safe pattern matching (no `unwrap()`)
|
||||||
|
- Cross-platform tray handling
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
**Synchronization**:
|
||||||
|
|
||||||
|
- MCP sync across all apps
|
||||||
|
- Gemini form-editor sync
|
||||||
|
- Dual-file reading (.env + settings.json)
|
||||||
|
|
||||||
|
**Validation**:
|
||||||
|
|
||||||
|
- Input boundary checks
|
||||||
|
- TOML quote normalization (CJK)
|
||||||
|
- Custom field preservation
|
||||||
|
- Enhanced error messages
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
**Type Safety**:
|
||||||
|
|
||||||
|
- Complete TypeScript coverage
|
||||||
|
- Rust type refinements
|
||||||
|
- API contract validation
|
||||||
|
|
||||||
|
**Testing**:
|
||||||
|
|
||||||
|
- Simplified assertions
|
||||||
|
- Better test coverage
|
||||||
|
- Integration test updates
|
||||||
|
|
||||||
|
**Dependencies**:
|
||||||
|
|
||||||
|
- Tauri 2.8.x
|
||||||
|
- Rust: `anyhow`, `zip`, `serde_yaml`, `tempfile`
|
||||||
|
- Frontend: CodeMirror 6 packages
|
||||||
|
- winreg 0.52 (Windows)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Statistics
|
||||||
|
|
||||||
|
```
|
||||||
|
Total Changes:
|
||||||
|
- Commits: 85
|
||||||
|
- Files: 152 changed
|
||||||
|
- Additions: +18,104 lines
|
||||||
|
- Deletions: -3,732 lines
|
||||||
|
|
||||||
|
New Modules:
|
||||||
|
- Skills Management: 2,034 lines (21 files)
|
||||||
|
- Prompts Management: 1,302 lines (20 files)
|
||||||
|
- Gemini Integration: ~1,000 lines
|
||||||
|
- MCP Refactor: ~3,000 lines refactored
|
||||||
|
|
||||||
|
Code Distribution:
|
||||||
|
- Backend (Rust): ~4,500 lines new
|
||||||
|
- Frontend (React): ~3,000 lines new
|
||||||
|
- Configuration: ~1,500 lines refactored
|
||||||
|
- Tests: ~500 lines
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Strategic Positioning
|
||||||
|
|
||||||
|
### From Tool to Platform
|
||||||
|
|
||||||
|
v3.7.0 represents a shift in CC Switch's positioning:
|
||||||
|
|
||||||
|
| Aspect | v3.6 | v3.7.0 |
|
||||||
|
| ----------------- | ------------------------ | ---------------------------- |
|
||||||
|
| **Identity** | Provider Switcher | AI CLI Management Platform |
|
||||||
|
| **Scope** | Configuration Management | Ecosystem Management |
|
||||||
|
| **Applications** | Claude + Codex | Claude + Codex + Gemini |
|
||||||
|
| **Capabilities** | Switch configs | Extend capabilities (Skills) |
|
||||||
|
| **Customization** | Manual editing | Visual management (Prompts) |
|
||||||
|
| **Integration** | Isolated apps | Unified management (MCP) |
|
||||||
|
|
||||||
|
### Six Pillars of AI CLI Management
|
||||||
|
|
||||||
|
1. **Configuration Management** - Provider switching and management
|
||||||
|
2. **Capability Extension** - Skills installation and lifecycle
|
||||||
|
3. **Behavior Customization** - System prompt presets
|
||||||
|
4. **Ecosystem Integration** - Deep links and sharing
|
||||||
|
5. **Multi-AI Support** - Claude/Codex/Gemini
|
||||||
|
6. **Intelligent Detection** - Conflict prevention
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Download & Installation
|
||||||
|
|
||||||
|
### System Requirements
|
||||||
|
|
||||||
|
- **Windows**: Windows 10+
|
||||||
|
- **macOS**: macOS 10.15 (Catalina)+
|
||||||
|
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+
|
||||||
|
|
||||||
|
### Download Links
|
||||||
|
|
||||||
|
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download:
|
||||||
|
|
||||||
|
- **Windows**: `CC-Switch-v3.7.0-Windows.msi` or `-Portable.zip`
|
||||||
|
- **macOS**: `CC-Switch-v3.7.0-macOS.tar.gz` or `.zip`
|
||||||
|
- **Linux**: `CC-Switch-v3.7.0-Linux.AppImage` or `.deb`
|
||||||
|
|
||||||
|
### Homebrew (macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
### From v3.6.x
|
||||||
|
|
||||||
|
**Automatic migration** - No action required, configs are fully compatible
|
||||||
|
|
||||||
|
### From v3.1.x or Earlier
|
||||||
|
|
||||||
|
**Two-step migration required**:
|
||||||
|
|
||||||
|
1. First upgrade to v3.2.x (performs one-time migration)
|
||||||
|
2. Then upgrade to v3.7.0
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- **Skills**: No migration needed, start fresh
|
||||||
|
- **Prompts**: Auto-import from live files on first launch
|
||||||
|
- **Gemini**: Install Gemini CLI separately if needed
|
||||||
|
- **MCP v3.7.0**: Backward compatible with previous configs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
### Contributors
|
||||||
|
|
||||||
|
Thanks to all contributors who made this release possible:
|
||||||
|
|
||||||
|
- [@YoVinchen](https://github.com/YoVinchen) - Skills & Prompts & Gemini integration implementation
|
||||||
|
- [@farion1231](https://github.com/farion1231) - From developer to issue responder
|
||||||
|
- Community members for testing and feedback
|
||||||
|
|
||||||
|
### Sponsors
|
||||||
|
|
||||||
|
**Z.ai** - GLM CODING PLAN sponsor
|
||||||
|
[Get 10% OFF with this link](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
||||||
|
|
||||||
|
**PackyCode** - API relay service partner
|
||||||
|
[Register with "cc-switch" code for 10% discount](https://www.packyapi.com/register?aff=cc-switch)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feedback & Support
|
||||||
|
|
||||||
|
- **Issues**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
|
||||||
|
- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
||||||
|
- **Documentation**: [README](../README.md)
|
||||||
|
- **Changelog**: [CHANGELOG.md](../CHANGELOG.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Next
|
||||||
|
|
||||||
|
**v3.8.0 Preview** (Tentative):
|
||||||
|
|
||||||
|
- Local proxy functionality
|
||||||
|
|
||||||
|
Stay tuned for more updates!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Happy Coding!**
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
# CC Switch v3.7.0
|
||||||
|
|
||||||
|
> 从供应商切换器到 AI CLI 一体化管理平台
|
||||||
|
|
||||||
|
**[English Version →](release-note-v3.7.0-en.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概览
|
||||||
|
|
||||||
|
CC Switch v3.7.0 新增六大核心功能,新增超过 18,000 行代码。
|
||||||
|
|
||||||
|
**发布日期**:2025-11-19
|
||||||
|
**提交数量**:从 v3.6.0 开始 85 个提交
|
||||||
|
**代码变更**:152 个文件,+18,104 / -3,732 行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 新增功能
|
||||||
|
|
||||||
|
### Gemini CLI 集成
|
||||||
|
|
||||||
|
完整支持 Google Gemini CLI,成为第三个支持的应用(Claude Code、Codex、Gemini)。
|
||||||
|
|
||||||
|
**核心能力**:
|
||||||
|
|
||||||
|
- **双文件配置** - 同时支持 `.env` 和 `settings.json` 格式
|
||||||
|
- **自动检测** - 自动检测 `GOOGLE_GEMINI_BASE_URL`、`GEMINI_MODEL` 等环境变量
|
||||||
|
- **完整 MCP 支持** - 为 Gemini 提供完整的 MCP 服务器管理
|
||||||
|
- **深度链接集成** - 通过 `ccswitch://` 协议导入配置
|
||||||
|
- **系统托盘** - 从托盘菜单快速切换
|
||||||
|
|
||||||
|
**供应商预设**:
|
||||||
|
|
||||||
|
- **Google Official** - 支持 OAuth 认证
|
||||||
|
- **PackyCode** - 合作伙伴集成
|
||||||
|
- **自定义** - 完全自定义支持
|
||||||
|
|
||||||
|
**技术实现**:
|
||||||
|
|
||||||
|
- 新增后端模块:`gemini_config.rs`(20KB)、`gemini_mcp.rs`
|
||||||
|
- 表单与环境编辑器同步
|
||||||
|
- 双文件原子写入
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### MCP v3.7.0 统一架构
|
||||||
|
|
||||||
|
MCP 管理系统完整重构,实现跨应用统一管理。
|
||||||
|
|
||||||
|
**架构改进**:
|
||||||
|
|
||||||
|
- **统一管理面板** - 单一界面管理 Claude/Codex/Gemini MCP 服务器
|
||||||
|
- **SSE 传输类型** - 新增 Server-Sent Events 支持
|
||||||
|
- **智能解析器** - 容错性 JSON 解析
|
||||||
|
- **格式修正** - 自动修复 Codex `[mcp_servers]` 格式
|
||||||
|
- **扩展字段** - 保留自定义 TOML 字段
|
||||||
|
|
||||||
|
**用户体验**:
|
||||||
|
|
||||||
|
- 表单中的默认应用选择
|
||||||
|
- JSON 格式化器用于验证
|
||||||
|
- 改进的视觉层次
|
||||||
|
- 更好的错误消息
|
||||||
|
|
||||||
|
**导入/导出**:
|
||||||
|
|
||||||
|
- 统一从三个应用导入
|
||||||
|
- 双向同步
|
||||||
|
- 状态保持
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Claude Skills 管理系统
|
||||||
|
|
||||||
|
**约 2,000 行代码** - 完整的技能生态平台。
|
||||||
|
|
||||||
|
**GitHub 集成**:
|
||||||
|
|
||||||
|
- 从 GitHub 仓库自动扫描技能
|
||||||
|
- 预配置仓库:
|
||||||
|
- `ComposioHQ/awesome-claude-skills` - 精选集合
|
||||||
|
- `anthropics/skills` - Anthropic 官方技能
|
||||||
|
- `cexll/myclaude` - 社区贡献
|
||||||
|
- 添加自定义仓库
|
||||||
|
- 子目录扫描支持(`skillsPath`)
|
||||||
|
|
||||||
|
**生命周期管理**:
|
||||||
|
|
||||||
|
- **发现** - 自动检测 `SKILL.md` 文件
|
||||||
|
- **安装** - 一键安装到 `~/.claude/skills/`
|
||||||
|
- **卸载** - 安全移除并跟踪状态
|
||||||
|
- **更新** - 检查更新(基础设施已就绪)
|
||||||
|
|
||||||
|
**技术架构**:
|
||||||
|
|
||||||
|
- **后端**:`SkillService`(526 行)集成 GitHub API
|
||||||
|
- **前端**:SkillsPage、SkillCard、RepoManager
|
||||||
|
- **UI 组件**:Badge、Card、Table(shadcn/ui)
|
||||||
|
- **状态**:持久化存储在 `skills.json`
|
||||||
|
- **国际化**:47+ 个翻译键
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Prompts 管理系统
|
||||||
|
|
||||||
|
**约 1,300 行代码** - 完整的系统提示词管理。
|
||||||
|
|
||||||
|
**多预设管理**:
|
||||||
|
|
||||||
|
- 创建无限数量的提示词预设
|
||||||
|
- 快速在预设间切换
|
||||||
|
- 同时只能激活一个提示词
|
||||||
|
- 活动提示词删除保护
|
||||||
|
|
||||||
|
**跨应用支持**:
|
||||||
|
|
||||||
|
- **Claude**:`~/.claude/CLAUDE.md`
|
||||||
|
- **Codex**:`~/.codex/AGENTS.md`
|
||||||
|
- **Gemini**:`~/.gemini/GEMINI.md`
|
||||||
|
|
||||||
|
**Markdown 编辑器**:
|
||||||
|
|
||||||
|
- 完整的 CodeMirror 6 集成
|
||||||
|
- 语法高亮
|
||||||
|
- 暗色主题(One Dark)
|
||||||
|
- 实时预览
|
||||||
|
|
||||||
|
**智能同步**:
|
||||||
|
|
||||||
|
- **自动写入** - 立即写入 live 文件
|
||||||
|
- **回填保护** - 切换前保存当前内容
|
||||||
|
- **自动导入** - 首次启动从 live 文件导入
|
||||||
|
- **修改保护** - 保留手动修改
|
||||||
|
|
||||||
|
**技术实现**:
|
||||||
|
|
||||||
|
- **后端**:`PromptService`(213 行)
|
||||||
|
- **前端**:PromptPanel(177)、PromptFormModal(160)、MarkdownEditor(159)
|
||||||
|
- **Hooks**:usePromptActions(152 行)
|
||||||
|
- **国际化**:41+ 个翻译键
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 深度链接协议(ccswitch://)
|
||||||
|
|
||||||
|
通过 URL 方案一键导入供应商配置。
|
||||||
|
|
||||||
|
**功能特性**:
|
||||||
|
|
||||||
|
- 所有平台的协议注册
|
||||||
|
- 从共享链接导入
|
||||||
|
- 生命周期集成
|
||||||
|
- 安全验证
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 环境变量冲突检测
|
||||||
|
|
||||||
|
智能检测和管理配置冲突。
|
||||||
|
|
||||||
|
**检测范围**:
|
||||||
|
|
||||||
|
- **Claude & Codex** - 跨应用冲突
|
||||||
|
- **Gemini** - 自动发现
|
||||||
|
- **MCP** - 服务器配置冲突
|
||||||
|
|
||||||
|
**管理功能**:
|
||||||
|
|
||||||
|
- 可视化冲突指示器
|
||||||
|
- 解决建议
|
||||||
|
- 覆盖警告
|
||||||
|
- 更改前备份
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 改进优化
|
||||||
|
|
||||||
|
### 供应商管理
|
||||||
|
|
||||||
|
**新增预设**:
|
||||||
|
|
||||||
|
- **DouBaoSeed** - 字节跳动的豆包
|
||||||
|
- **Kimi For Coding** - 月之暗面
|
||||||
|
- **BaiLing** - 百灵 AI
|
||||||
|
- **移除 AnyRouter** - 避免误导
|
||||||
|
|
||||||
|
**增强功能**:
|
||||||
|
|
||||||
|
- Codex 和 Gemini 的模型名称配置
|
||||||
|
- 供应商备注字段用于组织
|
||||||
|
- 增强的预设元数据
|
||||||
|
|
||||||
|
### 配置管理
|
||||||
|
|
||||||
|
- **通用配置迁移** - 从 localStorage 迁移到 `config.json`
|
||||||
|
- **统一持久化** - 跨所有应用共享
|
||||||
|
- **自动导入** - 首次启动配置导入
|
||||||
|
- **回填优先级** - 正确处理 live 文件
|
||||||
|
|
||||||
|
### UI/UX 改进
|
||||||
|
|
||||||
|
**设计系统**:
|
||||||
|
|
||||||
|
- **macOS 原生** - 与系统对齐的配色方案
|
||||||
|
- **窗口居中** - 默认居中位置
|
||||||
|
- **视觉优化** - 改进的间距和层次
|
||||||
|
|
||||||
|
**交互优化**:
|
||||||
|
|
||||||
|
- **密码输入** - 修复 Edge/IE 显示按钮
|
||||||
|
- **URL 溢出** - 修复卡片溢出
|
||||||
|
- **错误复制** - 可复制到剪贴板的错误
|
||||||
|
- **托盘同步** - 实时拖放同步
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug 修复
|
||||||
|
|
||||||
|
### 关键修复
|
||||||
|
|
||||||
|
- **用量脚本验证** - 边界检查
|
||||||
|
- **Gemini 验证** - 放宽约束
|
||||||
|
- **TOML 解析** - CJK 引号处理
|
||||||
|
- **MCP 字段** - 自定义字段保留
|
||||||
|
- **白屏** - FormLabel 崩溃修复
|
||||||
|
|
||||||
|
### 稳定性
|
||||||
|
|
||||||
|
- **托盘安全** - 模式匹配替代 unwrap
|
||||||
|
- **错误隔离** - 托盘失败不阻塞操作
|
||||||
|
- **导入分类** - 正确的类别分配
|
||||||
|
|
||||||
|
### UI 修复
|
||||||
|
|
||||||
|
- **模型占位符** - 移除误导性提示
|
||||||
|
- **Base URL** - 第三方供应商自动填充
|
||||||
|
- **拖拽排序** - 托盘菜单同步
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术改进
|
||||||
|
|
||||||
|
### 架构
|
||||||
|
|
||||||
|
**MCP v3.7.0**:
|
||||||
|
|
||||||
|
- 移除遗留代码(约 1,000 行)
|
||||||
|
- 统一初始化结构
|
||||||
|
- 保持向后兼容性
|
||||||
|
- 全面的代码格式化
|
||||||
|
|
||||||
|
**平台兼容性**:
|
||||||
|
|
||||||
|
- Windows winreg API 修复(v0.52)
|
||||||
|
- 安全模式匹配(无 `unwrap()`)
|
||||||
|
- 跨平台托盘处理
|
||||||
|
|
||||||
|
### 配置
|
||||||
|
|
||||||
|
**同步机制**:
|
||||||
|
|
||||||
|
- 跨所有应用的 MCP 同步
|
||||||
|
- Gemini 表单-编辑器同步
|
||||||
|
- 双文件读取(.env + settings.json)
|
||||||
|
|
||||||
|
**验证增强**:
|
||||||
|
|
||||||
|
- 输入边界检查
|
||||||
|
- TOML 引号规范化(CJK)
|
||||||
|
- 自定义字段保留
|
||||||
|
- 增强的错误消息
|
||||||
|
|
||||||
|
### 代码质量
|
||||||
|
|
||||||
|
**类型安全**:
|
||||||
|
|
||||||
|
- 完整的 TypeScript 覆盖
|
||||||
|
- Rust 类型改进
|
||||||
|
- API 契约验证
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
|
||||||
|
- 简化的断言
|
||||||
|
- 更好的测试覆盖
|
||||||
|
- 集成测试更新
|
||||||
|
|
||||||
|
**依赖项**:
|
||||||
|
|
||||||
|
- Tauri 2.8.x
|
||||||
|
- Rust:`anyhow`、`zip`、`serde_yaml`、`tempfile`
|
||||||
|
- 前端:CodeMirror 6 包
|
||||||
|
- winreg 0.52(Windows)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术统计
|
||||||
|
|
||||||
|
```
|
||||||
|
总体变更:
|
||||||
|
- 提交数:85
|
||||||
|
- 文件数:152 个文件变更
|
||||||
|
- 新增:+18,104 行
|
||||||
|
- 删除:-3,732 行
|
||||||
|
|
||||||
|
新增模块:
|
||||||
|
- Skills 管理:2,034 行(21 个文件)
|
||||||
|
- Prompts 管理:1,302 行(20 个文件)
|
||||||
|
- Gemini 集成:约 1,000 行
|
||||||
|
- MCP 重构:约 3,000 行重构
|
||||||
|
|
||||||
|
代码分布:
|
||||||
|
- 后端(Rust):约 4,500 行新增
|
||||||
|
- 前端(React):约 3,000 行新增
|
||||||
|
- 配置:约 1,500 行重构
|
||||||
|
- 测试:约 500 行
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 战略定位
|
||||||
|
|
||||||
|
### 从工具到平台
|
||||||
|
|
||||||
|
v3.7.0 代表了 CC Switch 定位的转变:
|
||||||
|
|
||||||
|
| 方面 | v3.6 | v3.7.0 |
|
||||||
|
| -------- | -------------- | ----------------------- |
|
||||||
|
| **身份** | 供应商切换器 | AI CLI 管理平台 |
|
||||||
|
| **范围** | 配置管理 | 生态系统管理 |
|
||||||
|
| **应用** | Claude + Codex | Claude + Codex + Gemini |
|
||||||
|
| **能力** | 切换配置 | 扩展能力(Skills) |
|
||||||
|
| **定制** | 手动编辑 | 可视化管理(Prompts) |
|
||||||
|
| **集成** | 孤立应用 | 统一管理(MCP) |
|
||||||
|
|
||||||
|
### AI CLI 管理六大支柱
|
||||||
|
|
||||||
|
1. **配置管理** - 供应商切换和管理
|
||||||
|
2. **能力扩展** - Skills 安装和生命周期
|
||||||
|
3. **行为定制** - 系统提示词预设
|
||||||
|
4. **生态集成** - 深度链接和共享
|
||||||
|
5. **多 AI 支持** - Claude/Codex/Gemini
|
||||||
|
6. **智能检测** - 冲突预防
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下载与安装
|
||||||
|
|
||||||
|
### 系统要求
|
||||||
|
|
||||||
|
- **Windows**:Windows 10+
|
||||||
|
- **macOS**:macOS 10.15(Catalina)+
|
||||||
|
- **Linux**:Ubuntu 22.04+ / Debian 11+ / Fedora 34+
|
||||||
|
|
||||||
|
### 下载链接
|
||||||
|
|
||||||
|
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载:
|
||||||
|
|
||||||
|
- **Windows**:`CC-Switch-v3.7.0-Windows.msi` 或 `-Portable.zip`
|
||||||
|
- **macOS**:`CC-Switch-v3.7.0-macOS.tar.gz` 或 `.zip`
|
||||||
|
- **Linux**:`CC-Switch-v3.7.0-Linux.AppImage` 或 `.deb`
|
||||||
|
|
||||||
|
### Homebrew(macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
更新:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 迁移说明
|
||||||
|
|
||||||
|
### 从 v3.6.x 升级
|
||||||
|
|
||||||
|
**自动迁移** - 无需任何操作,配置完全兼容
|
||||||
|
|
||||||
|
### 从 v3.1.x 或更早版本升级
|
||||||
|
|
||||||
|
**需要两步迁移**:
|
||||||
|
|
||||||
|
1. 首先升级到 v3.2.x(执行一次性迁移)
|
||||||
|
2. 然后升级到 v3.7.0
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
|
||||||
|
- **Skills**:无需迁移,全新开始
|
||||||
|
- **Prompts**:首次启动时从 live 文件自动导入
|
||||||
|
- **Gemini**:需要单独安装 Gemini CLI
|
||||||
|
- **MCP v3.7.0**:与之前的配置向后兼容
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
### 贡献者
|
||||||
|
|
||||||
|
感谢所有让这个版本成为可能的贡献者:
|
||||||
|
|
||||||
|
- [@YoVinchen](https://github.com/YoVinchen) - Skills & Prompts & Geimini 集成实现
|
||||||
|
- [@farion1231](https://github.com/farion1231) - 从开发沦为 issue 回复机
|
||||||
|
- 社区成员的测试和反馈
|
||||||
|
|
||||||
|
### 赞助商
|
||||||
|
|
||||||
|
**Z.ai** - GLM CODING PLAN 赞助商
|
||||||
|
[通过此链接获得 10% 折扣](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
||||||
|
|
||||||
|
**PackyCode** - API 中继服务合作伙伴
|
||||||
|
[使用 "cc-switch" 代码注册可享受 10% 折扣](https://www.packyapi.com/register?aff=cc-switch)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 反馈与支持
|
||||||
|
|
||||||
|
- **问题反馈**:[GitHub Issues](https://github.com/farion1231/cc-switch/issues)
|
||||||
|
- **讨论**:[GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
||||||
|
- **文档**:[README](../README_ZH.md)
|
||||||
|
- **更新日志**:[CHANGELOG.md](../CHANGELOG.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 未来展望
|
||||||
|
|
||||||
|
**v3.8.0 预览**(暂定):
|
||||||
|
|
||||||
|
- 本地代理功能
|
||||||
|
|
||||||
|
敬请期待更多更新!
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
# CC Switch v3.7.1
|
||||||
|
|
||||||
|
> Stability Enhancements and User Experience Improvements
|
||||||
|
|
||||||
|
**[中文更新说明 Chinese Documentation →](release-note-v3.7.1-zh.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v3.7.1 Updates
|
||||||
|
|
||||||
|
**Release Date**: 2025-11-22
|
||||||
|
**Code Changes**: 17 files, +524 / -81 lines
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **Fix Third-Party Skills Installation Failure** (#268)
|
||||||
|
Fixed installation issues for skills repositories with custom subdirectories, now supports repos like `ComposioHQ/awesome-claude-skills` with subdirectories
|
||||||
|
|
||||||
|
- **Fix Gemini Configuration Persistence Issue**
|
||||||
|
Resolved the issue where settings.json edits in Gemini form were lost when switching providers
|
||||||
|
|
||||||
|
- **Prevent Dialogs from Closing on Overlay Click**
|
||||||
|
Added protection against clicking overlay/backdrop, preventing accidental form data loss across all 11 dialog components
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- **Gemini Configuration Directory Support** (#255)
|
||||||
|
Added Gemini configuration directory option in settings, supports customizing `~/.gemini/` path
|
||||||
|
|
||||||
|
- **ArchLinux Installation Support** (#259)
|
||||||
|
Added AUR installation method: `paru -S cc-switch-bin`
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- **Skills Error Message i18n Enhancement**
|
||||||
|
Added 28+ detailed error messages (English & Chinese) with specific resolution suggestions, extended download timeout from 15s to 60s
|
||||||
|
|
||||||
|
- **Code Formatting**
|
||||||
|
Applied unified Rust and TypeScript code formatting standards
|
||||||
|
|
||||||
|
### Download
|
||||||
|
|
||||||
|
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the latest version
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v3.7.0 Complete Release Notes
|
||||||
|
|
||||||
|
> From Provider Switcher to All-in-One AI CLI Management Platform
|
||||||
|
|
||||||
|
**Release Date**: 2025-11-19
|
||||||
|
**Commits**: 85 from v3.6.0
|
||||||
|
**Code Changes**: 152 files, +18,104 / -3,732 lines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
### Gemini CLI Integration
|
||||||
|
|
||||||
|
Complete support for Google Gemini CLI, becoming the third supported application (Claude Code, Codex, Gemini).
|
||||||
|
|
||||||
|
**Core Capabilities**:
|
||||||
|
|
||||||
|
- **Dual-file configuration** - Support for both `.env` and `settings.json` formats
|
||||||
|
- **Auto-detection** - Automatically detect `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.
|
||||||
|
- **Full MCP support** - Complete MCP server management for Gemini
|
||||||
|
- **Deep link integration** - Import via `ccswitch://` protocol
|
||||||
|
- **System tray** - Quick-switch from tray menu
|
||||||
|
|
||||||
|
**Provider Presets**:
|
||||||
|
|
||||||
|
- **Google Official** - OAuth authentication support
|
||||||
|
- **PackyCode** - Partner integration
|
||||||
|
- **Custom** - Full customization support
|
||||||
|
|
||||||
|
**Technical Implementation**:
|
||||||
|
|
||||||
|
- New backend modules: `gemini_config.rs` (20KB), `gemini_mcp.rs`
|
||||||
|
- Form synchronization with environment editor
|
||||||
|
- Dual-file atomic writes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### MCP v3.7.0 Unified Architecture
|
||||||
|
|
||||||
|
Complete refactoring of MCP management system for cross-application unification.
|
||||||
|
|
||||||
|
**Architecture Improvements**:
|
||||||
|
|
||||||
|
- **Unified panel** - Single interface for Claude/Codex/Gemini MCP servers
|
||||||
|
- **SSE transport** - New Server-Sent Events support
|
||||||
|
- **Smart parser** - Fault-tolerant JSON parsing
|
||||||
|
- **Format correction** - Auto-fix Codex `[mcp_servers]` format
|
||||||
|
- **Extended fields** - Preserve custom TOML fields
|
||||||
|
|
||||||
|
**User Experience**:
|
||||||
|
|
||||||
|
- Default app selection in forms
|
||||||
|
- JSON formatter for validation
|
||||||
|
- Improved visual hierarchy
|
||||||
|
- Better error messages
|
||||||
|
|
||||||
|
**Import/Export**:
|
||||||
|
|
||||||
|
- Unified import from all three apps
|
||||||
|
- Bidirectional synchronization
|
||||||
|
- State preservation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Claude Skills Management System
|
||||||
|
|
||||||
|
**Approximately 2,000 lines of code** - A complete skill ecosystem platform.
|
||||||
|
|
||||||
|
**GitHub Integration**:
|
||||||
|
|
||||||
|
- Auto-scan skills from GitHub repositories
|
||||||
|
- Pre-configured repos:
|
||||||
|
- `ComposioHQ/awesome-claude-skills` - Curated collection
|
||||||
|
- `anthropics/skills` - Official Anthropic skills
|
||||||
|
- `cexll/myclaude` - Community contributions
|
||||||
|
- Add custom repositories
|
||||||
|
- Subdirectory scanning support (`skillsPath`)
|
||||||
|
|
||||||
|
**Lifecycle Management**:
|
||||||
|
|
||||||
|
- **Discover** - Auto-detect `SKILL.md` files
|
||||||
|
- **Install** - One-click to `~/.claude/skills/`
|
||||||
|
- **Uninstall** - Safe removal with tracking
|
||||||
|
- **Update** - Check for updates (infrastructure ready)
|
||||||
|
|
||||||
|
**Technical Architecture**:
|
||||||
|
|
||||||
|
- **Backend**: `SkillService` (526 lines) with GitHub API integration
|
||||||
|
- **Frontend**: SkillsPage, SkillCard, RepoManager
|
||||||
|
- **UI Components**: Badge, Card, Table (shadcn/ui)
|
||||||
|
- **State**: Persistent storage in `config.json`
|
||||||
|
- **i18n**: 47+ translation keys
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Prompts Management System
|
||||||
|
|
||||||
|
**Approximately 1,300 lines of code** - Complete system prompt management.
|
||||||
|
|
||||||
|
**Multi-Preset Management**:
|
||||||
|
|
||||||
|
- Create unlimited prompt presets
|
||||||
|
- Quick switch between presets
|
||||||
|
- One active prompt at a time
|
||||||
|
- Delete protection for active prompts
|
||||||
|
|
||||||
|
**Cross-App Support**:
|
||||||
|
|
||||||
|
- **Claude**: `~/.claude/CLAUDE.md`
|
||||||
|
- **Codex**: `~/.codex/AGENTS.md`
|
||||||
|
- **Gemini**: `~/.gemini/GEMINI.md`
|
||||||
|
|
||||||
|
**Markdown Editor**:
|
||||||
|
|
||||||
|
- Full-featured CodeMirror 6 integration
|
||||||
|
- Syntax highlighting
|
||||||
|
- Dark theme (One Dark)
|
||||||
|
- Real-time preview
|
||||||
|
|
||||||
|
**Smart Synchronization**:
|
||||||
|
|
||||||
|
- **Auto-write** - Immediately write to live files
|
||||||
|
- **Backfill protection** - Save current content before switching
|
||||||
|
- **Auto-import** - Import from live files on first launch
|
||||||
|
- **Modification protection** - Preserve manual modifications
|
||||||
|
|
||||||
|
**Technical Implementation**:
|
||||||
|
|
||||||
|
- **Backend**: `PromptService` (213 lines)
|
||||||
|
- **Frontend**: PromptPanel (177), PromptFormModal (160), MarkdownEditor (159)
|
||||||
|
- **Hooks**: usePromptActions (152 lines)
|
||||||
|
- **i18n**: 41+ translation keys
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Deep Link Protocol (ccswitch://)
|
||||||
|
|
||||||
|
One-click provider configuration import via URL scheme.
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
|
||||||
|
- Protocol registration on all platforms
|
||||||
|
- Import from shared links
|
||||||
|
- Lifecycle integration
|
||||||
|
- Security validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Environment Variable Conflict Detection
|
||||||
|
|
||||||
|
Intelligent detection and management of configuration conflicts.
|
||||||
|
|
||||||
|
**Detection Scope**:
|
||||||
|
|
||||||
|
- **Claude & Codex** - Cross-app conflicts
|
||||||
|
- **Gemini** - Auto-discovery
|
||||||
|
- **MCP** - Server configuration conflicts
|
||||||
|
|
||||||
|
**Management Features**:
|
||||||
|
|
||||||
|
- Visual conflict indicators
|
||||||
|
- Resolution suggestions
|
||||||
|
- Override warnings
|
||||||
|
- Backup before changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Improvements
|
||||||
|
|
||||||
|
### Provider Management
|
||||||
|
|
||||||
|
**New Presets**:
|
||||||
|
|
||||||
|
- **DouBaoSeed** - ByteDance's DouBao
|
||||||
|
- **Kimi For Coding** - Moonshot AI
|
||||||
|
- **BaiLing** - BaiLing AI
|
||||||
|
- **Removed AnyRouter** - To avoid confusion
|
||||||
|
|
||||||
|
**Enhancements**:
|
||||||
|
|
||||||
|
- Model name configuration for Codex and Gemini
|
||||||
|
- Provider notes field for organization
|
||||||
|
- Enhanced preset metadata
|
||||||
|
|
||||||
|
### Configuration Management
|
||||||
|
|
||||||
|
- **Common config migration** - From localStorage to `config.json`
|
||||||
|
- **Unified persistence** - Shared across all apps
|
||||||
|
- **Auto-import** - First launch configuration import
|
||||||
|
- **Backfill priority** - Correct handling of live files
|
||||||
|
|
||||||
|
### UI/UX Improvements
|
||||||
|
|
||||||
|
**Design System**:
|
||||||
|
|
||||||
|
- **macOS native** - System-aligned color scheme
|
||||||
|
- **Window centering** - Default centered position
|
||||||
|
- **Visual polish** - Improved spacing and hierarchy
|
||||||
|
|
||||||
|
**Interactions**:
|
||||||
|
|
||||||
|
- **Password input** - Fixed Edge/IE reveal buttons
|
||||||
|
- **URL overflow** - Fixed card overflow
|
||||||
|
- **Error copying** - Copy-to-clipboard errors
|
||||||
|
- **Tray sync** - Real-time drag-and-drop sync
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
### Critical Fixes
|
||||||
|
|
||||||
|
- **Usage script validation** - Boundary checks
|
||||||
|
- **Gemini validation** - Relaxed constraints
|
||||||
|
- **TOML parsing** - CJK quote handling
|
||||||
|
- **MCP fields** - Custom field preservation
|
||||||
|
- **White screen** - FormLabel crash fix
|
||||||
|
|
||||||
|
### Stability
|
||||||
|
|
||||||
|
- **Tray safety** - Pattern matching instead of unwrap
|
||||||
|
- **Error isolation** - Tray failures don't block operations
|
||||||
|
- **Import classification** - Correct category assignment
|
||||||
|
|
||||||
|
### UI Fixes
|
||||||
|
|
||||||
|
- **Model placeholders** - Removed misleading hints
|
||||||
|
- **Base URL** - Auto-fill for third-party providers
|
||||||
|
- **Drag sort** - Tray menu synchronization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Improvements
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
**MCP v3.7.0**:
|
||||||
|
|
||||||
|
- Removed legacy code (~1,000 lines)
|
||||||
|
- Unified initialization structure
|
||||||
|
- Backward compatibility maintained
|
||||||
|
- Comprehensive code formatting
|
||||||
|
|
||||||
|
**Platform Compatibility**:
|
||||||
|
|
||||||
|
- Windows winreg API fix (v0.52)
|
||||||
|
- Safe pattern matching (no `unwrap()`)
|
||||||
|
- Cross-platform tray handling
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
**Synchronization**:
|
||||||
|
|
||||||
|
- MCP sync across all apps
|
||||||
|
- Gemini form-editor sync
|
||||||
|
- Dual-file reading (.env + settings.json)
|
||||||
|
|
||||||
|
**Validation**:
|
||||||
|
|
||||||
|
- Input boundary checks
|
||||||
|
- TOML quote normalization (CJK)
|
||||||
|
- Custom field preservation
|
||||||
|
- Enhanced error messages
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
**Type Safety**:
|
||||||
|
|
||||||
|
- Complete TypeScript coverage
|
||||||
|
- Rust type refinements
|
||||||
|
- API contract validation
|
||||||
|
|
||||||
|
**Testing**:
|
||||||
|
|
||||||
|
- Simplified assertions
|
||||||
|
- Better test coverage
|
||||||
|
- Integration test updates
|
||||||
|
|
||||||
|
**Dependencies**:
|
||||||
|
|
||||||
|
- Tauri 2.8.x
|
||||||
|
- Rust: `anyhow`, `zip`, `serde_yaml`, `tempfile`
|
||||||
|
- Frontend: CodeMirror 6 packages
|
||||||
|
- winreg 0.52 (Windows)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Statistics
|
||||||
|
|
||||||
|
```
|
||||||
|
Total Changes:
|
||||||
|
- Commits: 85
|
||||||
|
- Files: 152 changed
|
||||||
|
- Additions: +18,104 lines
|
||||||
|
- Deletions: -3,732 lines
|
||||||
|
|
||||||
|
New Modules:
|
||||||
|
- Skills Management: 2,034 lines (21 files)
|
||||||
|
- Prompts Management: 1,302 lines (20 files)
|
||||||
|
- Gemini Integration: ~1,000 lines
|
||||||
|
- MCP Refactor: ~3,000 lines refactored
|
||||||
|
|
||||||
|
Code Distribution:
|
||||||
|
- Backend (Rust): ~4,500 lines new
|
||||||
|
- Frontend (React): ~3,000 lines new
|
||||||
|
- Configuration: ~1,500 lines refactored
|
||||||
|
- Tests: ~500 lines
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Strategic Positioning
|
||||||
|
|
||||||
|
### From Tool to Platform
|
||||||
|
|
||||||
|
v3.7.0 represents a shift in CC Switch's positioning:
|
||||||
|
|
||||||
|
| Aspect | v3.6 | v3.7.0 |
|
||||||
|
| ----------------- | ------------------------ | ---------------------------- |
|
||||||
|
| **Identity** | Provider Switcher | AI CLI Management Platform |
|
||||||
|
| **Scope** | Configuration Management | Ecosystem Management |
|
||||||
|
| **Applications** | Claude + Codex | Claude + Codex + Gemini |
|
||||||
|
| **Capabilities** | Switch configs | Extend capabilities (Skills) |
|
||||||
|
| **Customization** | Manual editing | Visual management (Prompts) |
|
||||||
|
| **Integration** | Isolated apps | Unified management (MCP) |
|
||||||
|
|
||||||
|
### Six Pillars of AI CLI Management
|
||||||
|
|
||||||
|
1. **Configuration Management** - Provider switching and management
|
||||||
|
2. **Capability Extension** - Skills installation and lifecycle
|
||||||
|
3. **Behavior Customization** - System prompt presets
|
||||||
|
4. **Ecosystem Integration** - Deep links and sharing
|
||||||
|
5. **Multi-AI Support** - Claude/Codex/Gemini
|
||||||
|
6. **Intelligent Detection** - Conflict prevention
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Download & Installation
|
||||||
|
|
||||||
|
### System Requirements
|
||||||
|
|
||||||
|
- **Windows**: Windows 10+
|
||||||
|
- **macOS**: macOS 10.15 (Catalina)+
|
||||||
|
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ / ArchLinux
|
||||||
|
|
||||||
|
### Download Links
|
||||||
|
|
||||||
|
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download:
|
||||||
|
|
||||||
|
- **Windows**: `CC-Switch-Windows.msi` or `-Portable.zip`
|
||||||
|
- **macOS**: `CC-Switch-macOS.tar.gz` or `.zip`
|
||||||
|
- **Linux**: `CC-Switch-Linux.AppImage` or `.deb`
|
||||||
|
- **ArchLinux**: `paru -S cc-switch-bin`
|
||||||
|
|
||||||
|
### Homebrew (macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
### From v3.6.x
|
||||||
|
|
||||||
|
**Automatic migration** - No action required, configs are fully compatible
|
||||||
|
|
||||||
|
### From v3.1.x or Earlier
|
||||||
|
|
||||||
|
**Two-step migration required**:
|
||||||
|
|
||||||
|
1. First upgrade to v3.2.x (performs one-time migration)
|
||||||
|
2. Then upgrade to v3.7.0
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- **Skills**: No migration needed, start fresh
|
||||||
|
- **Prompts**: Auto-import from live files on first launch
|
||||||
|
- **Gemini**: Install Gemini CLI separately if needed
|
||||||
|
- **MCP v3.7.0**: Backward compatible with previous configs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
### Contributors
|
||||||
|
|
||||||
|
Thanks to all contributors who made this release possible:
|
||||||
|
|
||||||
|
- [@YoVinchen](https://github.com/YoVinchen) - Skills & Prompts & Gemini integration implementation
|
||||||
|
- [@farion1231](https://github.com/farion1231) - From developer to issue responder
|
||||||
|
- Community members for testing and feedback
|
||||||
|
|
||||||
|
### Sponsors
|
||||||
|
|
||||||
|
**Z.ai** - GLM CODING PLAN sponsor
|
||||||
|
[Get 10% OFF with this link](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
||||||
|
|
||||||
|
**PackyCode** - API relay service partner
|
||||||
|
[Register with "cc-switch" code for 10% discount](https://www.packyapi.com/register?aff=cc-switch)
|
||||||
|
|
||||||
|
**ShanDianShuo** - Local-first AI voice input
|
||||||
|
[Free download](https://shandianshuo.cn) for Mac/Win
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feedback & Support
|
||||||
|
|
||||||
|
- **Issues**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
|
||||||
|
- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
||||||
|
- **Documentation**: [README](../README.md)
|
||||||
|
- **Changelog**: [CHANGELOG.md](../CHANGELOG.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Next
|
||||||
|
|
||||||
|
**v3.8.0 Preview** (Tentative):
|
||||||
|
|
||||||
|
- Local proxy functionality
|
||||||
|
|
||||||
|
Stay tuned for more updates!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Happy Coding!**
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
# CC Switch v3.7.1
|
||||||
|
|
||||||
|
> 稳定性增强与用户体验改进
|
||||||
|
|
||||||
|
**[English Version →](release-note-v3.7.1-en.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v3.7.1 更新内容
|
||||||
|
|
||||||
|
**发布日期**:2025-11-22
|
||||||
|
**代码变更**:17 个文件,+524 / -81 行
|
||||||
|
|
||||||
|
### Bug 修复
|
||||||
|
|
||||||
|
- **修复 Skills 第三方仓库安装失败** (#268)
|
||||||
|
修复使用自定义子目录的 skills 仓库无法安装的问题,支持类似 `ComposioHQ/awesome-claude-skills` 这样带子目录的仓库
|
||||||
|
|
||||||
|
- **修复 Gemini 配置持久化问题**
|
||||||
|
解决在 Gemini 表单中编辑 settings.json 后,切换供应商时修改丢失的问题
|
||||||
|
|
||||||
|
- **防止对话框意外关闭**
|
||||||
|
添加点击遮罩时的保护,避免误操作导致表单数据丢失,影响所有 11 个对话框组件
|
||||||
|
|
||||||
|
### 新增功能
|
||||||
|
|
||||||
|
- **Gemini 配置目录支持** (#255)
|
||||||
|
在设置中添加 Gemini 配置目录选项,支持自定义 `~/.gemini/` 路径
|
||||||
|
|
||||||
|
- **ArchLinux 安装支持** (#259)
|
||||||
|
添加 AUR 安装方式:`paru -S cc-switch-bin`
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
- **Skills 错误消息国际化增强**
|
||||||
|
新增 28+ 条详细错误消息(中英文),提供具体的解决建议,下载超时从 15 秒延长到 60 秒
|
||||||
|
|
||||||
|
- **代码格式化**
|
||||||
|
应用统一的 Rust 和 TypeScript 代码格式化标准
|
||||||
|
|
||||||
|
### 下载
|
||||||
|
|
||||||
|
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载最新版本
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v3.7.0 完整更新说明
|
||||||
|
|
||||||
|
> 从供应商切换器到 AI CLI 一体化管理平台
|
||||||
|
|
||||||
|
**发布日期**:2025-11-19
|
||||||
|
**提交数量**:从 v3.6.0 开始 85 个提交
|
||||||
|
**代码变更**:152 个文件,+18,104 / -3,732 行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 新增功能
|
||||||
|
|
||||||
|
### Gemini CLI 集成
|
||||||
|
|
||||||
|
完整支持 Google Gemini CLI,成为第三个支持的应用(Claude Code、Codex、Gemini)。
|
||||||
|
|
||||||
|
**核心能力**:
|
||||||
|
|
||||||
|
- **双文件配置** - 同时支持 `.env` 和 `settings.json` 格式
|
||||||
|
- **自动检测** - 自动检测 `GOOGLE_GEMINI_BASE_URL`、`GEMINI_MODEL` 等环境变量
|
||||||
|
- **完整 MCP 支持** - 为 Gemini 提供完整的 MCP 服务器管理
|
||||||
|
- **深度链接集成** - 通过 `ccswitch://` 协议导入配置
|
||||||
|
- **系统托盘** - 从托盘菜单快速切换
|
||||||
|
|
||||||
|
**供应商预设**:
|
||||||
|
|
||||||
|
- **Google Official** - 支持 OAuth 认证
|
||||||
|
- **PackyCode** - 合作伙伴集成
|
||||||
|
- **自定义** - 完全自定义支持
|
||||||
|
|
||||||
|
**技术实现**:
|
||||||
|
|
||||||
|
- 新增后端模块:`gemini_config.rs`(20KB)、`gemini_mcp.rs`
|
||||||
|
- 表单与环境编辑器同步
|
||||||
|
- 双文件原子写入
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### MCP v3.7.0 统一架构
|
||||||
|
|
||||||
|
MCP 管理系统完整重构,实现跨应用统一管理。
|
||||||
|
|
||||||
|
**架构改进**:
|
||||||
|
|
||||||
|
- **统一管理面板** - 单一界面管理 Claude/Codex/Gemini MCP 服务器
|
||||||
|
- **SSE 传输类型** - 新增 Server-Sent Events 支持
|
||||||
|
- **智能解析器** - 容错性 JSON 解析
|
||||||
|
- **格式修正** - 自动修复 Codex `[mcp_servers]` 格式
|
||||||
|
- **扩展字段** - 保留自定义 TOML 字段
|
||||||
|
|
||||||
|
**用户体验**:
|
||||||
|
|
||||||
|
- 表单中的默认应用选择
|
||||||
|
- JSON 格式化器用于验证
|
||||||
|
- 改进的视觉层次
|
||||||
|
- 更好的错误消息
|
||||||
|
|
||||||
|
**导入/导出**:
|
||||||
|
|
||||||
|
- 统一从三个应用导入
|
||||||
|
- 双向同步
|
||||||
|
- 状态保持
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Claude Skills 管理系统
|
||||||
|
|
||||||
|
**约 2,000 行代码** - 完整的技能生态平台。
|
||||||
|
|
||||||
|
**GitHub 集成**:
|
||||||
|
|
||||||
|
- 从 GitHub 仓库自动扫描技能
|
||||||
|
- 预配置仓库:
|
||||||
|
- `ComposioHQ/awesome-claude-skills` - 精选集合
|
||||||
|
- `anthropics/skills` - Anthropic 官方技能
|
||||||
|
- `cexll/myclaude` - 社区贡献
|
||||||
|
- 添加自定义仓库
|
||||||
|
- 子目录扫描支持(`skillsPath`)
|
||||||
|
|
||||||
|
**生命周期管理**:
|
||||||
|
|
||||||
|
- **发现** - 自动检测 `SKILL.md` 文件
|
||||||
|
- **安装** - 一键安装到 `~/.claude/skills/`
|
||||||
|
- **卸载** - 安全移除并跟踪状态
|
||||||
|
- **更新** - 检查更新(基础设施已就绪)
|
||||||
|
|
||||||
|
**技术架构**:
|
||||||
|
|
||||||
|
- **后端**:`SkillService`(526 行)集成 GitHub API
|
||||||
|
- **前端**:SkillsPage、SkillCard、RepoManager
|
||||||
|
- **UI 组件**:Badge、Card、Table(shadcn/ui)
|
||||||
|
- **状态**:持久化存储在 `config.json`
|
||||||
|
- **国际化**:47+ 个翻译键
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Prompts 管理系统
|
||||||
|
|
||||||
|
**约 1,300 行代码** - 完整的系统提示词管理。
|
||||||
|
|
||||||
|
**多预设管理**:
|
||||||
|
|
||||||
|
- 创建无限数量的提示词预设
|
||||||
|
- 快速在预设间切换
|
||||||
|
- 同时只能激活一个提示词
|
||||||
|
- 活动提示词删除保护
|
||||||
|
|
||||||
|
**跨应用支持**:
|
||||||
|
|
||||||
|
- **Claude**:`~/.claude/CLAUDE.md`
|
||||||
|
- **Codex**:`~/.codex/AGENTS.md`
|
||||||
|
- **Gemini**:`~/.gemini/GEMINI.md`
|
||||||
|
|
||||||
|
**Markdown 编辑器**:
|
||||||
|
|
||||||
|
- 完整的 CodeMirror 6 集成
|
||||||
|
- 语法高亮
|
||||||
|
- 暗色主题(One Dark)
|
||||||
|
- 实时预览
|
||||||
|
|
||||||
|
**智能同步**:
|
||||||
|
|
||||||
|
- **自动写入** - 立即写入 live 文件
|
||||||
|
- **回填保护** - 切换前保存当前内容
|
||||||
|
- **自动导入** - 首次启动从 live 文件导入
|
||||||
|
- **修改保护** - 保留手动修改
|
||||||
|
|
||||||
|
**技术实现**:
|
||||||
|
|
||||||
|
- **后端**:`PromptService`(213 行)
|
||||||
|
- **前端**:PromptPanel(177)、PromptFormModal(160)、MarkdownEditor(159)
|
||||||
|
- **Hooks**:usePromptActions(152 行)
|
||||||
|
- **国际化**:41+ 个翻译键
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 深度链接协议(ccswitch://)
|
||||||
|
|
||||||
|
通过 URL 方案一键导入供应商配置。
|
||||||
|
|
||||||
|
**功能特性**:
|
||||||
|
|
||||||
|
- 所有平台的协议注册
|
||||||
|
- 从共享链接导入
|
||||||
|
- 生命周期集成
|
||||||
|
- 安全验证
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 环境变量冲突检测
|
||||||
|
|
||||||
|
智能检测和管理配置冲突。
|
||||||
|
|
||||||
|
**检测范围**:
|
||||||
|
|
||||||
|
- **Claude & Codex** - 跨应用冲突
|
||||||
|
- **Gemini** - 自动发现
|
||||||
|
- **MCP** - 服务器配置冲突
|
||||||
|
|
||||||
|
**管理功能**:
|
||||||
|
|
||||||
|
- 可视化冲突指示器
|
||||||
|
- 解决建议
|
||||||
|
- 覆盖警告
|
||||||
|
- 更改前备份
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 改进优化
|
||||||
|
|
||||||
|
### 供应商管理
|
||||||
|
|
||||||
|
**新增预设**:
|
||||||
|
|
||||||
|
- **DouBaoSeed** - 字节跳动的豆包
|
||||||
|
- **Kimi For Coding** - 月之暗面
|
||||||
|
- **BaiLing** - 百灵 AI
|
||||||
|
- **移除 AnyRouter** - 避免误导
|
||||||
|
|
||||||
|
**增强功能**:
|
||||||
|
|
||||||
|
- Codex 和 Gemini 的模型名称配置
|
||||||
|
- 供应商备注字段用于组织
|
||||||
|
- 增强的预设元数据
|
||||||
|
|
||||||
|
### 配置管理
|
||||||
|
|
||||||
|
- **通用配置迁移** - 从 localStorage 迁移到 `config.json`
|
||||||
|
- **统一持久化** - 跨所有应用共享
|
||||||
|
- **自动导入** - 首次启动配置导入
|
||||||
|
- **回填优先级** - 正确处理 live 文件
|
||||||
|
|
||||||
|
### UI/UX 改进
|
||||||
|
|
||||||
|
**设计系统**:
|
||||||
|
|
||||||
|
- **macOS 原生** - 与系统对齐的配色方案
|
||||||
|
- **窗口居中** - 默认居中位置
|
||||||
|
- **视觉优化** - 改进的间距和层次
|
||||||
|
|
||||||
|
**交互优化**:
|
||||||
|
|
||||||
|
- **密码输入** - 修复 Edge/IE 显示按钮
|
||||||
|
- **URL 溢出** - 修复卡片溢出
|
||||||
|
- **错误复制** - 可复制到剪贴板的错误
|
||||||
|
- **托盘同步** - 实时拖放同步
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug 修复
|
||||||
|
|
||||||
|
### 关键修复
|
||||||
|
|
||||||
|
- **用量脚本验证** - 边界检查
|
||||||
|
- **Gemini 验证** - 放宽约束
|
||||||
|
- **TOML 解析** - CJK 引号处理
|
||||||
|
- **MCP 字段** - 自定义字段保留
|
||||||
|
- **白屏** - FormLabel 崩溃修复
|
||||||
|
|
||||||
|
### 稳定性
|
||||||
|
|
||||||
|
- **托盘安全** - 模式匹配替代 unwrap
|
||||||
|
- **错误隔离** - 托盘失败不阻塞操作
|
||||||
|
- **导入分类** - 正确的类别分配
|
||||||
|
|
||||||
|
### UI 修复
|
||||||
|
|
||||||
|
- **模型占位符** - 移除误导性提示
|
||||||
|
- **Base URL** - 第三方供应商自动填充
|
||||||
|
- **拖拽排序** - 托盘菜单同步
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术改进
|
||||||
|
|
||||||
|
### 架构
|
||||||
|
|
||||||
|
**MCP v3.7.0**:
|
||||||
|
|
||||||
|
- 移除遗留代码(约 1,000 行)
|
||||||
|
- 统一初始化结构
|
||||||
|
- 保持向后兼容性
|
||||||
|
- 全面的代码格式化
|
||||||
|
|
||||||
|
**平台兼容性**:
|
||||||
|
|
||||||
|
- Windows winreg API 修复(v0.52)
|
||||||
|
- 安全模式匹配(无 `unwrap()`)
|
||||||
|
- 跨平台托盘处理
|
||||||
|
|
||||||
|
### 配置
|
||||||
|
|
||||||
|
**同步机制**:
|
||||||
|
|
||||||
|
- 跨所有应用的 MCP 同步
|
||||||
|
- Gemini 表单-编辑器同步
|
||||||
|
- 双文件读取(.env + settings.json)
|
||||||
|
|
||||||
|
**验证增强**:
|
||||||
|
|
||||||
|
- 输入边界检查
|
||||||
|
- TOML 引号规范化(CJK)
|
||||||
|
- 自定义字段保留
|
||||||
|
- 增强的错误消息
|
||||||
|
|
||||||
|
### 代码质量
|
||||||
|
|
||||||
|
**类型安全**:
|
||||||
|
|
||||||
|
- 完整的 TypeScript 覆盖
|
||||||
|
- Rust 类型改进
|
||||||
|
- API 契约验证
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
|
||||||
|
- 简化的断言
|
||||||
|
- 更好的测试覆盖
|
||||||
|
- 集成测试更新
|
||||||
|
|
||||||
|
**依赖项**:
|
||||||
|
|
||||||
|
- Tauri 2.8.x
|
||||||
|
- Rust:`anyhow`、`zip`、`serde_yaml`、`tempfile`
|
||||||
|
- 前端:CodeMirror 6 包
|
||||||
|
- winreg 0.52(Windows)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术统计
|
||||||
|
|
||||||
|
```
|
||||||
|
总体变更:
|
||||||
|
- 提交数:85
|
||||||
|
- 文件数:152 个文件变更
|
||||||
|
- 新增:+18,104 行
|
||||||
|
- 删除:-3,732 行
|
||||||
|
|
||||||
|
新增模块:
|
||||||
|
- Skills 管理:2,034 行(21 个文件)
|
||||||
|
- Prompts 管理:1,302 行(20 个文件)
|
||||||
|
- Gemini 集成:约 1,000 行
|
||||||
|
- MCP 重构:约 3,000 行重构
|
||||||
|
|
||||||
|
代码分布:
|
||||||
|
- 后端(Rust):约 4,500 行新增
|
||||||
|
- 前端(React):约 3,000 行新增
|
||||||
|
- 配置:约 1,500 行重构
|
||||||
|
- 测试:约 500 行
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 战略定位
|
||||||
|
|
||||||
|
### 从工具到平台
|
||||||
|
|
||||||
|
v3.7.0 代表了 CC Switch 定位的转变:
|
||||||
|
|
||||||
|
| 方面 | v3.6 | v3.7.0 |
|
||||||
|
| -------- | -------------- | ----------------------- |
|
||||||
|
| **身份** | 供应商切换器 | AI CLI 管理平台 |
|
||||||
|
| **范围** | 配置管理 | 生态系统管理 |
|
||||||
|
| **应用** | Claude + Codex | Claude + Codex + Gemini |
|
||||||
|
| **能力** | 切换配置 | 扩展能力(Skills) |
|
||||||
|
| **定制** | 手动编辑 | 可视化管理(Prompts) |
|
||||||
|
| **集成** | 孤立应用 | 统一管理(MCP) |
|
||||||
|
|
||||||
|
### AI CLI 管理六大支柱
|
||||||
|
|
||||||
|
1. **配置管理** - 供应商切换和管理
|
||||||
|
2. **能力扩展** - Skills 安装和生命周期
|
||||||
|
3. **行为定制** - 系统提示词预设
|
||||||
|
4. **生态集成** - 深度链接和共享
|
||||||
|
5. **多 AI 支持** - Claude/Codex/Gemini
|
||||||
|
6. **智能检测** - 冲突预防
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下载与安装
|
||||||
|
|
||||||
|
### 系统要求
|
||||||
|
|
||||||
|
- **Windows**:Windows 10+
|
||||||
|
- **macOS**:macOS 10.15(Catalina)+
|
||||||
|
- **Linux**:Ubuntu 22.04+ / Debian 11+ / Fedora 34+ / ArchLinux
|
||||||
|
|
||||||
|
### 下载链接
|
||||||
|
|
||||||
|
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载:
|
||||||
|
|
||||||
|
- **Windows**:`CC-Switch-Windows.msi` 或 `-Portable.zip`
|
||||||
|
- **macOS**:`CC-Switch-macOS.tar.gz` 或 `.zip`
|
||||||
|
- **Linux**:`CC-Switch-Linux.AppImage` 或 `.deb`
|
||||||
|
- **ArchLinux**:`paru -S cc-switch-bin`
|
||||||
|
|
||||||
|
### Homebrew(macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
更新:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 迁移说明
|
||||||
|
|
||||||
|
### 从 v3.6.x 升级
|
||||||
|
|
||||||
|
**自动迁移** - 无需任何操作,配置完全兼容
|
||||||
|
|
||||||
|
### 从 v3.1.x 或更早版本升级
|
||||||
|
|
||||||
|
**需要两步迁移**:
|
||||||
|
|
||||||
|
1. 首先升级到 v3.2.x(执行一次性迁移)
|
||||||
|
2. 然后升级到 v3.7.0
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
|
||||||
|
- **Skills**:无需迁移,全新开始
|
||||||
|
- **Prompts**:首次启动时从 live 文件自动导入
|
||||||
|
- **Gemini**:需要单独安装 Gemini CLI
|
||||||
|
- **MCP v3.7.0**:与之前的配置向后兼容
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
### 贡献者
|
||||||
|
|
||||||
|
感谢所有让这个版本成为可能的贡献者:
|
||||||
|
|
||||||
|
- [@YoVinchen](https://github.com/YoVinchen) - Skills & Prompts & Gemini 集成实现
|
||||||
|
- [@farion1231](https://github.com/farion1231) - 从开发沦为 issue 回复机
|
||||||
|
- 社区成员的测试和反馈
|
||||||
|
|
||||||
|
### 赞助商
|
||||||
|
|
||||||
|
**智谱AI** - GLM CODING PLAN 赞助商
|
||||||
|
[使用此链接购买可享九折优惠](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
|
||||||
|
|
||||||
|
**PackyCode** - API 中转服务合作伙伴
|
||||||
|
[使用 "cc-switch" 优惠码注册享 9 折优惠](https://www.packyapi.com/register?aff=cc-switch)
|
||||||
|
|
||||||
|
**闪电说** - 本地优先的 AI 语音输入法
|
||||||
|
[免费下载](https://shandianshuo.cn) Mac/Win 双平台
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 反馈与支持
|
||||||
|
|
||||||
|
- **问题反馈**:[GitHub Issues](https://github.com/farion1231/cc-switch/issues)
|
||||||
|
- **讨论**:[GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
||||||
|
- **文档**:[README](../README_ZH.md)
|
||||||
|
- **更新日志**:[CHANGELOG.md](../CHANGELOG.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 未来展望
|
||||||
|
|
||||||
|
**v3.8.0 预览**(暂定):
|
||||||
|
|
||||||
|
- 本地代理功能
|
||||||
|
|
||||||
|
敬请期待更多更新!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Happy Coding!**
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
# CC Switch v3.8.0
|
||||||
|
|
||||||
|
> Persistence Architecture Upgrade, Laying the Foundation for Cloud Sync
|
||||||
|
|
||||||
|
**[中文版 →](release-note-v3.8.0-zh.md) | [日本語版 →](release-note-v3.8.0-ja.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
CC Switch v3.8.0 is a major architectural upgrade that restructures the data persistence layer and user interface, laying the foundation for future cloud sync and local proxy features.
|
||||||
|
|
||||||
|
**Release Date**: 2025-11-28
|
||||||
|
**Commits**: 51 commits since v3.7.1
|
||||||
|
**Code Changes**: 207 files, +17,297 / -6,870 lines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Major Updates
|
||||||
|
|
||||||
|
### Persistence Architecture Upgrade
|
||||||
|
|
||||||
|
Migrated from single JSON file storage to SQLite + JSON dual-layer architecture for hierarchical data management.
|
||||||
|
|
||||||
|
**Architecture Changes**:
|
||||||
|
|
||||||
|
```
|
||||||
|
v3.7.x (Old) v3.8.0 (New)
|
||||||
|
┌─────────────────┐ ┌─────────────────────────────────┐
|
||||||
|
│ config.json │ │ SQLite (Syncable Data) │
|
||||||
|
│ ┌───────────┐ │ │ ├─ providers Provider cfg │
|
||||||
|
│ │ providers │ │ │ ├─ mcp_servers MCP servers │
|
||||||
|
│ │ mcp │ │ ──> │ ├─ prompts Prompts │
|
||||||
|
│ │ prompts │ │ │ ├─ skills Skills │
|
||||||
|
│ │ settings │ │ │ └─ settings General cfg │
|
||||||
|
│ └───────────┘ │ ├─────────────────────────────────┤
|
||||||
|
└─────────────────┘ │ JSON (Device-level Data) │
|
||||||
|
│ └─ settings.json Local settings│
|
||||||
|
│ ├─ Window position │
|
||||||
|
│ ├─ Path overrides │
|
||||||
|
│ └─ Current provider ID │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dual-layer Structure Design**:
|
||||||
|
|
||||||
|
| Layer | Storage | Data Types | Sync Strategy |
|
||||||
|
| ---------- | ------- | ------------------------------- | --------------- |
|
||||||
|
| Cloud Sync | SQLite | Providers, MCP, Prompts, Skills | Future syncable |
|
||||||
|
| Device | JSON | Window state, local paths | Stays local |
|
||||||
|
|
||||||
|
**Technical Implementation**:
|
||||||
|
|
||||||
|
- **Schema Version Management** - Supports database structure upgrade migrations
|
||||||
|
- **SQL Import/Export** - `backup.rs` supports SQL dump for cloud storage
|
||||||
|
- **Transaction Support** - SQLite native transactions ensure data consistency
|
||||||
|
- **Auto Migration** - Automatically migrates from `config.json` on first launch
|
||||||
|
|
||||||
|
**Modular Refactoring**:
|
||||||
|
|
||||||
|
```
|
||||||
|
database/
|
||||||
|
├── mod.rs Core Database struct and initialization
|
||||||
|
├── schema.rs Table definitions, schema version migrations
|
||||||
|
├── backup.rs SQL import/export, binary snapshot backup
|
||||||
|
├── migration.rs JSON → SQLite data migration engine
|
||||||
|
└── dao/ Data Access Object layer
|
||||||
|
├── providers.rs Provider CRUD
|
||||||
|
├── mcp.rs MCP server CRUD
|
||||||
|
├── prompts.rs Prompts CRUD
|
||||||
|
├── skills.rs Skills CRUD
|
||||||
|
└── settings.rs Key-value settings storage
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Brand New User Interface
|
||||||
|
|
||||||
|
Completely redesigned UI providing a more modern visual experience.
|
||||||
|
|
||||||
|
**Visual Improvements**:
|
||||||
|
|
||||||
|
- Redesigned interface layout
|
||||||
|
- Unified component styles
|
||||||
|
- Smoother transition animations
|
||||||
|
- Optimized visual hierarchy
|
||||||
|
|
||||||
|
**Interaction Enhancements**:
|
||||||
|
|
||||||
|
- Redesigned header toolbar
|
||||||
|
- Unified ConfirmDialog styling
|
||||||
|
- Disabled overscroll bounce effect on main view
|
||||||
|
- Improved form validation feedback
|
||||||
|
|
||||||
|
**Compatibility Adjustments**:
|
||||||
|
|
||||||
|
- Downgraded Tailwind CSS from v4 to v3.4 for better browser compatibility
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Japanese Language Support
|
||||||
|
|
||||||
|
Added Japanese interface support, expanding internationalization to three languages.
|
||||||
|
|
||||||
|
**Supported Languages**:
|
||||||
|
|
||||||
|
- Simplified Chinese
|
||||||
|
- English
|
||||||
|
- Japanese (New)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
### Skills Recursive Scanning
|
||||||
|
|
||||||
|
Skills management system now supports recursive scanning of repository directories, automatically discovering nested skill files.
|
||||||
|
|
||||||
|
**Improvements**:
|
||||||
|
|
||||||
|
- Support for multi-level directory structures
|
||||||
|
- Automatic discovery of all `SKILL.md` files
|
||||||
|
- Allow same-named skills from different repositories (using full path for deduplication)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Provider Icon Configuration
|
||||||
|
|
||||||
|
Provider presets now support custom icon configuration.
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
|
||||||
|
- Preset providers include default icons
|
||||||
|
- Icon settings preserved when duplicating providers
|
||||||
|
- Custom icon colors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Enhanced Form Validation
|
||||||
|
|
||||||
|
Provider forms now include required field validation with friendlier error messages.
|
||||||
|
|
||||||
|
**Improvements**:
|
||||||
|
|
||||||
|
- Real-time validation for required fields
|
||||||
|
- Unified Toast notifications for validation errors
|
||||||
|
- Clearer error messages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Auto Launch on Startup
|
||||||
|
|
||||||
|
Added auto-launch functionality supporting Windows, macOS, and Linux platforms.
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
|
||||||
|
- One-click enable/disable in settings
|
||||||
|
- Implemented using platform-native APIs
|
||||||
|
- Windows uses Registry, macOS uses LaunchAgent, Linux uses XDG autostart
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### New Provider Presets
|
||||||
|
|
||||||
|
- **MiniMax** - Official partner
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
### Critical Fixes
|
||||||
|
|
||||||
|
**Custom Endpoints Lost Issue**
|
||||||
|
|
||||||
|
Fixed an issue where custom request URLs were unexpectedly lost when updating providers.
|
||||||
|
|
||||||
|
- Root Cause: `INSERT OR REPLACE` executes `DELETE + INSERT` under the hood in SQLite, triggering foreign key cascade deletion
|
||||||
|
- Fix: Changed to use `UPDATE` statement for existing providers
|
||||||
|
|
||||||
|
**Gemini Configuration Issues**
|
||||||
|
|
||||||
|
- Fixed custom provider environment variables not correctly written to `.env` file
|
||||||
|
- Fixed security auth config incorrectly written to other config files
|
||||||
|
|
||||||
|
**Provider Validation Issues**
|
||||||
|
|
||||||
|
- Fixed validation error when current provider ID doesn't exist
|
||||||
|
- Fixed icon fields lost when duplicating providers
|
||||||
|
|
||||||
|
### Platform Compatibility
|
||||||
|
|
||||||
|
**Linux**
|
||||||
|
|
||||||
|
- Resolved WebKitGTK DMA-BUF rendering issue
|
||||||
|
- Preserve user `.desktop` file customizations
|
||||||
|
|
||||||
|
### Other Fixes
|
||||||
|
|
||||||
|
- Fixed redundant usage queries when switching apps
|
||||||
|
- Fixed DMXAPI preset using wrong auth token field
|
||||||
|
- Fixed missing translation keys in deeplink components
|
||||||
|
- Fixed usage script template initialization logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Improvements
|
||||||
|
|
||||||
|
### Architecture Refactoring
|
||||||
|
|
||||||
|
**Provider Service Modularization**:
|
||||||
|
|
||||||
|
```
|
||||||
|
services/provider/
|
||||||
|
├── mod.rs Core service - add/update/delete/switch/validate
|
||||||
|
├── live.rs Live config file operations
|
||||||
|
├── gemini_auth.rs Gemini auth type detection
|
||||||
|
├── endpoints.rs Custom endpoint management
|
||||||
|
└── usage.rs Usage script execution
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deeplink Modularization**:
|
||||||
|
|
||||||
|
```
|
||||||
|
deeplink/
|
||||||
|
├── mod.rs Module exports
|
||||||
|
├── parser.rs URL parsing
|
||||||
|
├── provider.rs Provider import logic
|
||||||
|
├── mcp.rs MCP import logic
|
||||||
|
├── prompt.rs Prompt import
|
||||||
|
├── skill.rs Skills import
|
||||||
|
└── utils.rs Utility functions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
**Cleanup**:
|
||||||
|
|
||||||
|
- Removed legacy JSON-era import/export dead code
|
||||||
|
- Removed unused MCP type exports
|
||||||
|
- Unified error handling approach
|
||||||
|
|
||||||
|
**Test Updates**:
|
||||||
|
|
||||||
|
- Migrated tests to SQLite database architecture
|
||||||
|
- Updated component tests to match current implementation
|
||||||
|
- Fixed MSW handlers to adapt to new API
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Statistics
|
||||||
|
|
||||||
|
```
|
||||||
|
Overall Changes:
|
||||||
|
- Commits: 51
|
||||||
|
- Files: 207 files changed
|
||||||
|
- Additions: +17,297 lines
|
||||||
|
- Deletions: -6,870 lines
|
||||||
|
- Net: +10,427 lines
|
||||||
|
|
||||||
|
Commit Type Distribution:
|
||||||
|
- fix: 25 (Bug fixes)
|
||||||
|
- refactor: 11 (Code refactoring)
|
||||||
|
- feat: 9 (New features)
|
||||||
|
- test: 1 (Testing)
|
||||||
|
- other: 5
|
||||||
|
|
||||||
|
Change Area Distribution:
|
||||||
|
- Frontend source: 112 files
|
||||||
|
- Rust backend: 63 files
|
||||||
|
- Test files: 20 files
|
||||||
|
- i18n files: 3 files
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### Upgrading from v3.7.x
|
||||||
|
|
||||||
|
**Auto Migration** - Executes automatically on first launch:
|
||||||
|
|
||||||
|
1. Detects if `config.json` exists
|
||||||
|
2. Migrates all data to SQLite within a transaction
|
||||||
|
3. Migrates device-level settings to `settings.json`
|
||||||
|
4. Shows migration success notification
|
||||||
|
|
||||||
|
**Data Safety**:
|
||||||
|
|
||||||
|
- Original `config.json` file is preserved (not deleted)
|
||||||
|
- Error dialog displayed on migration failure, `config.json` preserved
|
||||||
|
- Supports Dry-run mode to verify migration logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Download & Installation
|
||||||
|
|
||||||
|
### System Requirements
|
||||||
|
|
||||||
|
- **Windows**: Windows 10+
|
||||||
|
- **macOS**: macOS 10.15 (Catalina)+
|
||||||
|
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+
|
||||||
|
|
||||||
|
### Download Links
|
||||||
|
|
||||||
|
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download:
|
||||||
|
|
||||||
|
- **Windows**: `CC-Switch-v3.8.0-Windows.msi` or `-Portable.zip`
|
||||||
|
- **macOS**: `CC-Switch-v3.8.0-macOS.tar.gz` or `.zip`
|
||||||
|
- **Linux**: `CC-Switch-v3.8.0-Linux.AppImage` or `.deb`
|
||||||
|
|
||||||
|
### Homebrew (macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
### Contributors
|
||||||
|
|
||||||
|
Thanks to all contributors who made this release possible:
|
||||||
|
|
||||||
|
- [@YoVinchen](https://github.com/YoVinchen) - UI and database refactoring
|
||||||
|
- [@farion1231](https://github.com/farion1231) - Bug fixes and feature enhancements
|
||||||
|
- Community members for testing and feedback
|
||||||
|
|
||||||
|
### Sponsors
|
||||||
|
|
||||||
|
**Zhipu AI** - GLM CODING PLAN Sponsor
|
||||||
|
[Get 10% off with this link](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
||||||
|
|
||||||
|
**PackyCode** - API Relay Service Partner
|
||||||
|
[Use code "cc-switch" for 10% off registration](https://www.packyapi.com/register?aff=cc-switch)
|
||||||
|
|
||||||
|
**ShandianShuo** - Local-first AI Voice Input
|
||||||
|
[Free download](https://shandianshuo.cn) for Mac/Windows
|
||||||
|
|
||||||
|
**MiniMax** - MiniMax M2 CODING PLAN Sponsor
|
||||||
|
[Black Friday sale, plans starting at $2](https://platform.minimax.io/subscribe/coding-plan)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feedback & Support
|
||||||
|
|
||||||
|
- **Issue Reports**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
|
||||||
|
- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
||||||
|
- **Documentation**: [README](../README.md)
|
||||||
|
- **Changelog**: [CHANGELOG.md](../CHANGELOG.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Roadmap
|
||||||
|
|
||||||
|
**v3.9.0 Preview** (Tentative):
|
||||||
|
|
||||||
|
- Local proxy feature
|
||||||
|
|
||||||
|
Stay tuned for more updates!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Happy Coding!**
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
# CC Switch v3.8.0
|
||||||
|
|
||||||
|
> 永続化アーキテクチャを刷新し、クラウド同期の土台を構築
|
||||||
|
|
||||||
|
**[English →](release-note-v3.8.0-en.md) | [中文版 →](release-note-v3.8.0-zh.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
CC Switch v3.8.0 はデータ永続化レイヤーと UI を大幅に作り替え、今後のクラウド同期やローカルプロキシ機能に向けた基盤を整えたメジャーアップデートです。
|
||||||
|
|
||||||
|
**リリース日**: 2025-11-28
|
||||||
|
**コミット数**: v3.7.1 以降 51 commits
|
||||||
|
**変更量**: 207 files, +17,297 / -6,870 lines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 主要アップデート
|
||||||
|
|
||||||
|
### 永続化アーキテクチャの刷新
|
||||||
|
|
||||||
|
単一の JSON 保存から、階層化された SQLite + JSON の二層構造へ移行。
|
||||||
|
|
||||||
|
**アーキテクチャ変更**:
|
||||||
|
|
||||||
|
```
|
||||||
|
v3.7.x (旧) v3.8.0 (新)
|
||||||
|
┌─────────────────┐ ┌─────────────────────────────────┐
|
||||||
|
│ config.json │ │ SQLite (同期対象データ) │
|
||||||
|
│ ┌───────────┐ │ │ ├─ providers プロバイダ設定 │
|
||||||
|
│ │ providers │ │ │ ├─ mcp_servers MCP サーバー │
|
||||||
|
│ │ mcp │ │ ──> │ ├─ prompts プロンプト │
|
||||||
|
│ │ prompts │ │ │ ├─ skills Skills │
|
||||||
|
│ │ settings │ │ │ └─ settings 汎用設定 │
|
||||||
|
│ └───────────┘ │ ├─────────────────────────────────┤
|
||||||
|
└─────────────────┘ │ JSON (デバイス固有データ) │
|
||||||
|
│ └─ settings.json ローカル設定 │
|
||||||
|
│ ├─ ウィンドウ位置 │
|
||||||
|
│ ├─ パスの上書き │
|
||||||
|
│ └─ 現在のプロバイダ ID │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**二層構造の設計**:
|
||||||
|
|
||||||
|
| レイヤー | ストレージ | データ種別 | 同期戦略 |
|
||||||
|
| -------- | ---------- | ----------------------------------- | ---------------- |
|
||||||
|
| クラウド | SQLite | Providers, MCP, Prompts, Skills | 将来同期対象 |
|
||||||
|
| デバイス | JSON | ウィンドウ状態、ローカルパス | ローカル保持 |
|
||||||
|
|
||||||
|
**実装ポイント**:
|
||||||
|
|
||||||
|
- **スキーマバージョン管理**: DB 構造のマイグレーションに対応
|
||||||
|
- **SQL インポート/エクスポート**: `backup.rs` が SQL ダンプをサポート
|
||||||
|
- **トランザクション対応**: SQLite ネイティブで整合性を確保
|
||||||
|
- **自動マイグレーション**: 初回起動で `config.json` から自動移行
|
||||||
|
|
||||||
|
**モジュール分割**:
|
||||||
|
|
||||||
|
```
|
||||||
|
database/
|
||||||
|
├── mod.rs Database 構造体と初期化
|
||||||
|
├── schema.rs テーブル定義とスキーマ移行
|
||||||
|
├── backup.rs SQL インポート/エクスポートとスナップショット
|
||||||
|
├── migration.rs JSON → SQLite 変換エンジン
|
||||||
|
└── dao/ DAO レイヤー
|
||||||
|
├── providers.rs プロバイダ CRUD
|
||||||
|
├── mcp.rs MCP CRUD
|
||||||
|
├── prompts.rs プロンプト CRUD
|
||||||
|
├── skills.rs Skills CRUD
|
||||||
|
└── settings.rs 設定 Key-Value 保存
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 新しいユーザーインターフェース
|
||||||
|
|
||||||
|
よりモダンな見た目と操作感に再設計。
|
||||||
|
|
||||||
|
- レイアウト全面刷新、コンポーネントスタイルを統一
|
||||||
|
- トランジションを滑らかにし、視覚的階層を最適化
|
||||||
|
- メインビューのオーバースクロールバウンスを無効化
|
||||||
|
- ブラウザ互換性向上のため Tailwind CSS を v4→v3.4 にダウングレード
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 日語化
|
||||||
|
|
||||||
|
UI が日本語に対応し、国際化が 3 言語(中/英/日)へ拡大。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 新機能
|
||||||
|
|
||||||
|
### Skills 递帰スキャン
|
||||||
|
|
||||||
|
Skills 管理がリポジトリを再帰的に走査し、ネストされた `SKILL.md` を自動検出。
|
||||||
|
|
||||||
|
- 複数階層のディレクトリに対応
|
||||||
|
- すべての `SKILL.md` を自動発見
|
||||||
|
- パスをキーにした重複排除で同名スキルを許容
|
||||||
|
|
||||||
|
### プロバイダアイコン設定
|
||||||
|
|
||||||
|
プリセットがデフォルトアイコンを持ち、複製してもアイコンを保持。カスタム色も設定可能。
|
||||||
|
|
||||||
|
### フォームバリデーション強化
|
||||||
|
|
||||||
|
必須項目にリアルタイム検証と分かりやすいエラーメッセージを追加し、トースト通知を統一。
|
||||||
|
|
||||||
|
### 自動起動
|
||||||
|
|
||||||
|
Windows/macOS/Linux で自動起動をサポート。
|
||||||
|
|
||||||
|
- 設定画面からワンクリックで ON/OFF
|
||||||
|
- Registry / LaunchAgent / XDG autostart を使用
|
||||||
|
|
||||||
|
### 新プロバイダプリセット
|
||||||
|
|
||||||
|
- **MiniMax** - 公式パートナー
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## バグ修正
|
||||||
|
|
||||||
|
### 重要修正
|
||||||
|
|
||||||
|
**カスタムエンドポイント消失**
|
||||||
|
|
||||||
|
- 原因: SQLite の `INSERT OR REPLACE` が内部で `DELETE + INSERT` を実行し、外部キーのカスケード削除が発生
|
||||||
|
- 対応: 既存プロバイダ更新を `UPDATE` に変更
|
||||||
|
|
||||||
|
**Gemini 設定**
|
||||||
|
|
||||||
|
- カスタム環境変数が `.env` に正しく書き込まれない問題を修正
|
||||||
|
- 認証設定が他ファイルに誤って書き込まれる問題を修正
|
||||||
|
|
||||||
|
**プロバイダ検証**
|
||||||
|
|
||||||
|
- 現在プロバイダ ID が欠落している場合のバリデーションエラーを修正
|
||||||
|
- 複製時にアイコンフィールドが失われる問題を修正
|
||||||
|
|
||||||
|
### プラットフォーム互換性
|
||||||
|
|
||||||
|
**Linux**
|
||||||
|
|
||||||
|
- WebKitGTK の DMA-BUF 描画問題を解消
|
||||||
|
- ユーザーの `.desktop` カスタマイズを保持
|
||||||
|
|
||||||
|
### その他修正
|
||||||
|
|
||||||
|
- アプリ切り替え時の不要な使用量クエリを削減
|
||||||
|
- DMXAPI プリセットの誤ったトークンフィールドを修正
|
||||||
|
- Deeplink コンポーネントの欠損翻訳キーを補完
|
||||||
|
- 使用量スクリプトテンプレート初期化を修正
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技術的改善
|
||||||
|
|
||||||
|
### アーキテクチャ再編
|
||||||
|
|
||||||
|
**Provider Service のモジュール化**:
|
||||||
|
|
||||||
|
```
|
||||||
|
services/provider/
|
||||||
|
├── mod.rs 追加/更新/削除/切替/検証の中核
|
||||||
|
├── live.rs ライブ設定ファイル操作
|
||||||
|
├── gemini_auth.rs Gemini 認証タイプ検出
|
||||||
|
├── endpoints.rs カスタムエンドポイント管理
|
||||||
|
└── usage.rs 使用量スクリプト実行
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deeplink のモジュール化**:
|
||||||
|
|
||||||
|
```
|
||||||
|
deeplink/
|
||||||
|
├── mod.rs エクスポート
|
||||||
|
├── parser.rs URL パース
|
||||||
|
├── provider.rs プロバイダ取り込み
|
||||||
|
├── mcp.rs MCP 取り込み
|
||||||
|
├── prompt.rs プロンプト取り込み
|
||||||
|
├── skill.rs Skills 取り込み
|
||||||
|
└── utils.rs ユーティリティ
|
||||||
|
```
|
||||||
|
|
||||||
|
### コード品質
|
||||||
|
|
||||||
|
- レガシーな JSON 時代のインポート/エクスポート死蔵コードを削除
|
||||||
|
- 使われていない MCP 型を削除し、エラーハンドリングを統一
|
||||||
|
- テストを SQLite バックエンドに移行し、MSW ハンドラを最新 API に合わせて更新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技術統計
|
||||||
|
|
||||||
|
```
|
||||||
|
全体変更:
|
||||||
|
- コミット: 51
|
||||||
|
- 変更ファイル: 207
|
||||||
|
- 追加: +17,297 行
|
||||||
|
- 削除: -6,870 行
|
||||||
|
- 純増: +10,427 行
|
||||||
|
|
||||||
|
コミット種別:
|
||||||
|
- fix: 25
|
||||||
|
- refactor: 11
|
||||||
|
- feat: 9
|
||||||
|
- test: 1
|
||||||
|
- other: 5
|
||||||
|
|
||||||
|
変更箇所:
|
||||||
|
- フロントエンド: 112 files
|
||||||
|
- Rust バックエンド: 63 files
|
||||||
|
- テスト: 20 files
|
||||||
|
- i18n: 3 files
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## マイグレーションガイド
|
||||||
|
|
||||||
|
### v3.7.x からのアップグレード
|
||||||
|
|
||||||
|
**自動マイグレーション**(初回起動時):
|
||||||
|
|
||||||
|
1. `config.json` の存在を検出
|
||||||
|
2. 全データをトランザクションで SQLite に移行
|
||||||
|
3. デバイス設定を `settings.json` へ移行
|
||||||
|
4. 移行成功の通知を表示
|
||||||
|
|
||||||
|
**データ保護**:
|
||||||
|
|
||||||
|
- 元の `config.json` は保持(削除しない)
|
||||||
|
- 失敗時はエラーダイアログを表示し、`config.json` を温存
|
||||||
|
- Dry-run モードで検証可能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ダウンロード & インストール
|
||||||
|
|
||||||
|
### システム要件
|
||||||
|
|
||||||
|
- **Windows**: Windows 10+
|
||||||
|
- **macOS**: macOS 10.15 (Catalina)+
|
||||||
|
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+
|
||||||
|
|
||||||
|
### ダウンロード
|
||||||
|
|
||||||
|
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から入手:
|
||||||
|
|
||||||
|
- **Windows**: `CC-Switch-v3.8.0-Windows.msi` または `-Portable.zip`
|
||||||
|
- **macOS**: `CC-Switch-v3.8.0-macOS.tar.gz` または `.zip`
|
||||||
|
- **Linux**: `CC-Switch-v3.8.0-Linux.AppImage` または `.deb`
|
||||||
|
|
||||||
|
### Homebrew (macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
アップデート:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 謝辞
|
||||||
|
|
||||||
|
### コントリビューター
|
||||||
|
|
||||||
|
- [@YoVinchen](https://github.com/YoVinchen) - UI とデータベースリファクタ
|
||||||
|
- [@farion1231](https://github.com/farion1231) - バグ修正と機能拡張
|
||||||
|
- コミュニティの皆さん - テストとフィードバック
|
||||||
|
|
||||||
|
### スポンサー
|
||||||
|
|
||||||
|
**Zhipu AI** - GLM CODING PLAN スポンサー
|
||||||
|
[10% オフリンク](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
||||||
|
|
||||||
|
**PackyCode** - API リレーサービスパートナー
|
||||||
|
[登録時に「cc-switch」で 10% オフ](https://www.packyapi.com/register?aff=cc-switch)
|
||||||
|
|
||||||
|
**ShandianShuo** - ローカルファースト音声入力
|
||||||
|
[Mac/Windows 無料ダウンロード](https://shandianshuo.cn)
|
||||||
|
|
||||||
|
**MiniMax** - MiniMax M2 CODING PLAN スポンサー
|
||||||
|
[ブラックフライデーセール中、$2 から](https://platform.minimax.io/subscribe/coding-plan)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## フィードバック & サポート
|
||||||
|
|
||||||
|
- **Issue**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
|
||||||
|
- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
||||||
|
- **ドキュメント**: [README](../README.md)
|
||||||
|
- **更新履歴**: [CHANGELOG.md](../CHANGELOG.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 今後のロードマップ
|
||||||
|
|
||||||
|
**v3.9.0 予告(予定)**:
|
||||||
|
|
||||||
|
- ローカルプロキシ機能
|
||||||
|
|
||||||
|
続報にご期待ください!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Happy Coding!**
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
# CC Switch v3.8.0
|
||||||
|
|
||||||
|
> 持久化架构升级,为云同步奠定基础
|
||||||
|
|
||||||
|
**[English Version →](release-note-v3.8.0-en.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概览
|
||||||
|
|
||||||
|
CC Switch v3.8.0 是一次重大的架构升级版本,重构了数据持久化层和用户界面,为未来的云同步和本地代理功能奠定基础。
|
||||||
|
|
||||||
|
**发布日期**:2025-11-28
|
||||||
|
**提交数量**:从 v3.7.1 开始 51 个提交
|
||||||
|
**代码变更**:207 个文件,+17,297 / -6,870 行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 重大更新
|
||||||
|
|
||||||
|
### 持久化架构升级
|
||||||
|
|
||||||
|
从单一 JSON 文件存储迁移到 SQLite + JSON 双层架构,实现数据分层管理。
|
||||||
|
|
||||||
|
**架构变更**:
|
||||||
|
|
||||||
|
```
|
||||||
|
v3.7.x (旧) v3.8.0 (新)
|
||||||
|
┌─────────────────┐ ┌─────────────────────────────────┐
|
||||||
|
│ config.json │ │ SQLite (可同步数据) │
|
||||||
|
│ ┌───────────┐ │ │ ├─ providers 供应商配置 │
|
||||||
|
│ │ providers │ │ │ ├─ mcp_servers MCP 服务器 │
|
||||||
|
│ │ mcp │ │ ──> │ ├─ prompts 提示词 │
|
||||||
|
│ │ prompts │ │ │ ├─ skills 技能 │
|
||||||
|
│ │ settings │ │ │ └─ settings 通用设置 │
|
||||||
|
│ └───────────┘ │ ├─────────────────────────────────┤
|
||||||
|
└─────────────────┘ │ JSON (设备级数据) │
|
||||||
|
│ └─ settings.json 本地设置 │
|
||||||
|
│ ├─ 窗口位置 │
|
||||||
|
│ ├─ 路径覆盖 │
|
||||||
|
│ └─ 当前选中供应商 ID │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**双层结构设计**:
|
||||||
|
|
||||||
|
| 层级 | 存储方式 | 数据类型 | 同步策略 |
|
||||||
|
| -------- | -------- | ---------------------------- | ---------- |
|
||||||
|
| 云同步层 | SQLite | 供应商、MCP、Prompts、Skills | 未来可同步 |
|
||||||
|
| 设备层 | JSON | 窗口状态、本地路径、当前选择 | 保持本地 |
|
||||||
|
|
||||||
|
**技术实现**:
|
||||||
|
|
||||||
|
- **Schema 版本管理** - 支持数据库结构升级迁移
|
||||||
|
- **SQL 导入导出** - `backup.rs` 支持 SQL dump,便于云端存储
|
||||||
|
- **事务支持** - SQLite 原生事务保证数据一致性
|
||||||
|
- **自动迁移** - 首次启动自动从 `config.json` 迁移数据
|
||||||
|
|
||||||
|
**模块化重构**:
|
||||||
|
|
||||||
|
```
|
||||||
|
database/
|
||||||
|
├── mod.rs 核心 Database 结构体和初始化
|
||||||
|
├── schema.rs 表结构定义、Schema 版本迁移
|
||||||
|
├── backup.rs SQL 导入导出、二进制快照备份
|
||||||
|
├── migration.rs JSON → SQLite 数据迁移引擎
|
||||||
|
└── dao/ 数据访问对象层
|
||||||
|
├── providers.rs 供应商 CRUD
|
||||||
|
├── mcp.rs MCP 服务器 CRUD
|
||||||
|
├── prompts.rs 提示词 CRUD
|
||||||
|
├── skills.rs Skills CRUD
|
||||||
|
└── settings.rs 键值对设置存储
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 全新用户界面
|
||||||
|
|
||||||
|
完整重构的 UI 设计,提供更现代化的视觉体验。
|
||||||
|
|
||||||
|
**视觉改进**:
|
||||||
|
|
||||||
|
- 重新设计的界面布局
|
||||||
|
- 统一的组件样式
|
||||||
|
- 更流畅的过渡动画
|
||||||
|
- 优化的视觉层次
|
||||||
|
|
||||||
|
**交互优化**:
|
||||||
|
|
||||||
|
- Header toolbar 重新设计
|
||||||
|
- ConfirmDialog 样式统一
|
||||||
|
- 禁用主视图 overscroll 弹跳效果
|
||||||
|
- 改进的表单验证反馈
|
||||||
|
|
||||||
|
**兼容性调整**:
|
||||||
|
|
||||||
|
- Tailwind CSS 从 v4 降级到 v3.4,提升浏览器兼容性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 日语支持
|
||||||
|
|
||||||
|
新增日语(日本語)界面支持,国际化语言扩展到三种。
|
||||||
|
|
||||||
|
**支持语言**:
|
||||||
|
|
||||||
|
- 简体中文
|
||||||
|
- English
|
||||||
|
- 日本語(新增)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 新增功能
|
||||||
|
|
||||||
|
### Skills 递归扫描
|
||||||
|
|
||||||
|
Skills 管理系统支持递归扫描仓库目录,自动发现嵌套的技能文件。
|
||||||
|
|
||||||
|
**改进内容**:
|
||||||
|
|
||||||
|
- 支持多层目录结构
|
||||||
|
- 自动发现所有 `SKILL.md` 文件
|
||||||
|
- 允许不同仓库的同名技能(使用完整路径去重)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 供应商图标配置
|
||||||
|
|
||||||
|
供应商预设支持自定义图标配置。
|
||||||
|
|
||||||
|
**功能特性**:
|
||||||
|
|
||||||
|
- 预设供应商包含默认图标
|
||||||
|
- 复制供应商时保留图标设置
|
||||||
|
- 图标颜色自定义
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 表单验证增强
|
||||||
|
|
||||||
|
供应商表单新增必填字段验证,提供更友好的错误提示。
|
||||||
|
|
||||||
|
**改进内容**:
|
||||||
|
|
||||||
|
- 必填字段实时校验
|
||||||
|
- 统一使用 Toast 通知显示验证错误
|
||||||
|
- 更清晰的错误信息
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 开机自启
|
||||||
|
|
||||||
|
新增开机自动启动功能,支持 Windows、macOS 和 Linux 三个平台。
|
||||||
|
|
||||||
|
**功能特性**:
|
||||||
|
|
||||||
|
- 在设置中一键开启/关闭
|
||||||
|
- 使用平台原生 API 实现
|
||||||
|
- Windows 使用注册表、macOS 使用 LaunchAgent、Linux 使用 XDG autostart
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 新增供应商预设
|
||||||
|
|
||||||
|
- **MiniMax** - 官方合作伙伴
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug 修复
|
||||||
|
|
||||||
|
### 关键修复
|
||||||
|
|
||||||
|
**自定义端点丢失问题**
|
||||||
|
|
||||||
|
修复更新供应商时自定义请求地址意外丢失的问题。
|
||||||
|
|
||||||
|
- 根因:`INSERT OR REPLACE` 在 SQLite 底层执行 `DELETE + INSERT`,触发外键级联删除
|
||||||
|
- 修复:改用 `UPDATE` 语句更新已存在的供应商
|
||||||
|
|
||||||
|
**Gemini 配置问题**
|
||||||
|
|
||||||
|
- 修复自定义供应商环境变量未正确写入 `.env` 文件
|
||||||
|
- 修复安全认证配置错误写入到其他配置文件
|
||||||
|
|
||||||
|
**供应商验证问题**
|
||||||
|
|
||||||
|
- 修复当前供应商 ID 不存在时的验证错误
|
||||||
|
- 修复供应商复制时图标字段丢失
|
||||||
|
|
||||||
|
### 平台兼容性
|
||||||
|
|
||||||
|
**Linux**
|
||||||
|
|
||||||
|
- 解决 WebKitGTK DMA-BUF 渲染问题
|
||||||
|
- 保留用户 `.desktop` 文件自定义
|
||||||
|
|
||||||
|
### 其他修复
|
||||||
|
|
||||||
|
- 修复切换应用时的冗余用量查询
|
||||||
|
- 修复 DMXAPI 预设使用错误的认证令牌字段
|
||||||
|
- 修复深链接组件缺少翻译键
|
||||||
|
- 修复用量脚本模板初始化逻辑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术改进
|
||||||
|
|
||||||
|
### 架构重构
|
||||||
|
|
||||||
|
**供应商服务模块化**:
|
||||||
|
|
||||||
|
```
|
||||||
|
services/provider/
|
||||||
|
├── mod.rs 核心服务 - add/update/delete/switch/validate
|
||||||
|
├── live.rs Live 配置文件操作
|
||||||
|
├── gemini_auth.rs Gemini 认证类型检测
|
||||||
|
├── endpoints.rs 自定义端点管理
|
||||||
|
└── usage.rs 用量脚本执行
|
||||||
|
```
|
||||||
|
|
||||||
|
**深链接模块化**:
|
||||||
|
|
||||||
|
```
|
||||||
|
deeplink/
|
||||||
|
├── mod.rs 模块导出
|
||||||
|
├── parser.rs URL 解析
|
||||||
|
├── provider.rs 供应商导入逻辑
|
||||||
|
├── mcp.rs MCP 导入逻辑
|
||||||
|
├── prompt.rs 提示词导入
|
||||||
|
├── skill.rs Skills 导入
|
||||||
|
└── utils.rs 工具函数
|
||||||
|
```
|
||||||
|
|
||||||
|
### 代码质量
|
||||||
|
|
||||||
|
**清理工作**:
|
||||||
|
|
||||||
|
- 移除 JSON 时代遗留的导入导出死代码
|
||||||
|
- 移除未使用的 MCP 类型导出
|
||||||
|
- 统一错误处理方式
|
||||||
|
|
||||||
|
**测试更新**:
|
||||||
|
|
||||||
|
- 迁移测试到 SQLite 数据库架构
|
||||||
|
- 更新组件测试匹配当前实现
|
||||||
|
- 修复 MSW handlers 适配新 API
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术统计
|
||||||
|
|
||||||
|
```
|
||||||
|
总体变更:
|
||||||
|
- 提交数:51
|
||||||
|
- 文件数:207 个文件变更
|
||||||
|
- 新增:+17,297 行
|
||||||
|
- 删除:-6,870 行
|
||||||
|
- 净增:+10,427 行
|
||||||
|
|
||||||
|
提交类型分布:
|
||||||
|
- fix:25 个(Bug 修复)
|
||||||
|
- refactor:11 个(代码重构)
|
||||||
|
- feat:9 个(新功能)
|
||||||
|
- test:1 个(测试)
|
||||||
|
- 其他:5 个
|
||||||
|
|
||||||
|
改动区域分布:
|
||||||
|
- 前端源码:112 个文件
|
||||||
|
- Rust 后端:63 个文件
|
||||||
|
- 测试文件:20 个文件
|
||||||
|
- 国际化文件:3 个文件
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 迁移说明
|
||||||
|
|
||||||
|
### 从 v3.7.x 升级
|
||||||
|
|
||||||
|
**自动迁移** - 首次启动时自动执行:
|
||||||
|
|
||||||
|
1. 检测 `config.json` 是否存在
|
||||||
|
2. 在事务中迁移所有数据到 SQLite
|
||||||
|
3. 设备级设置迁移到 `settings.json`
|
||||||
|
4. 显示迁移成功通知
|
||||||
|
|
||||||
|
**数据安全**:
|
||||||
|
|
||||||
|
- 原 `config.json` 文件保留不删除
|
||||||
|
- 迁移失败时显示错误对话框,保留`config.json`
|
||||||
|
- 支持 Dry-run 模式验证迁移逻辑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下载与安装
|
||||||
|
|
||||||
|
### 系统要求
|
||||||
|
|
||||||
|
- **Windows**:Windows 10+
|
||||||
|
- **macOS**:macOS 10.15(Catalina)+
|
||||||
|
- **Linux**:Ubuntu 22.04+ / Debian 11+ / Fedora 34+
|
||||||
|
|
||||||
|
### 下载链接
|
||||||
|
|
||||||
|
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载:
|
||||||
|
|
||||||
|
- **Windows**:`CC-Switch-v3.8.0-Windows.msi` 或 `-Portable.zip`
|
||||||
|
- **macOS**:`CC-Switch-v3.8.0-macOS.tar.gz` 或 `.zip`
|
||||||
|
- **Linux**:`CC-Switch-v3.8.0-Linux.AppImage` 或 `.deb`
|
||||||
|
|
||||||
|
### Homebrew(macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
更新:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
### 贡献者
|
||||||
|
|
||||||
|
感谢所有让这个版本成为可能的贡献者:
|
||||||
|
|
||||||
|
- [@YoVinchen](https://github.com/YoVinchen) - UI 和数据库重构
|
||||||
|
- [@farion1231](https://github.com/farion1231) - BUG 修复和功能增强
|
||||||
|
- 社区成员的测试和反馈
|
||||||
|
|
||||||
|
### 赞助商
|
||||||
|
|
||||||
|
**智谱AI** - GLM CODING PLAN 赞助商
|
||||||
|
[使用此链接购买可享九折优惠](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
|
||||||
|
|
||||||
|
**PackyCode** - API 中转服务合作伙伴
|
||||||
|
[使用 "cc-switch" 优惠码注册享 9 折优惠](https://www.packyapi.com/register?aff=cc-switch)
|
||||||
|
|
||||||
|
**闪电说** - 本地优先的 AI 语音输入法
|
||||||
|
[免费下载](https://shandianshuo.cn) Mac/Win 双平台
|
||||||
|
|
||||||
|
**MiniMax** - MiniMax M2 CODING PLAN 赞助商
|
||||||
|
[黑五优惠进行中,套餐9.9元起](https://platform.minimaxi.com/subscribe/coding-plan)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 反馈与支持
|
||||||
|
|
||||||
|
- **问题反馈**:[GitHub Issues](https://github.com/farion1231/cc-switch/issues)
|
||||||
|
- **讨论**:[GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
||||||
|
- **文档**:[README](../README_ZH.md)
|
||||||
|
- **更新日志**:[CHANGELOG.md](../CHANGELOG.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 未来展望
|
||||||
|
|
||||||
|
**v3.9.0 预览**(暂定):
|
||||||
|
|
||||||
|
- 本地代理功能
|
||||||
|
|
||||||
|
敬请期待更多更新!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Happy Coding!**
|
||||||
@@ -0,0 +1,863 @@
|
|||||||
|
# v3.7.0 统一 MCP 管理重构计划
|
||||||
|
|
||||||
|
## 📋 项目概述
|
||||||
|
|
||||||
|
**目标**:将原有的按应用分离的 MCP 管理(Claude/Codex/Gemini 各自独立管理)重构为统一管理面板,每个 MCP 服务器通过多选框控制应用到哪些客户端。
|
||||||
|
|
||||||
|
**版本**:v3.6.2 → v3.7.0
|
||||||
|
|
||||||
|
**开始时间**:2025-11-14
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 核心需求
|
||||||
|
|
||||||
|
### 原有架构(v3.6.x)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
|
│ Claude面板 │ │ Codex面板 │ │ Gemini面板 │
|
||||||
|
│ MCP管理 │ │ MCP管理 │ │ MCP管理 │
|
||||||
|
└─────────────┘ └─────────────┘ └─────────────┘
|
||||||
|
↓ ↓ ↓
|
||||||
|
mcp.claude mcp.codex mcp.gemini
|
||||||
|
{servers} {servers} {servers}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 新架构(v3.7.0)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────┐
|
||||||
|
│ 统一 MCP 管理面板 │
|
||||||
|
│ ┌────────┬────────┬────────┬────┐ │
|
||||||
|
│ │ 服务器 │ Claude │ Codex │Gem │ │
|
||||||
|
│ ├────────┼────────┼────────┼────┤ │
|
||||||
|
│ │ mcp-1 │ ✓ │ ✓ │ │ │
|
||||||
|
│ │ mcp-2 │ ✓ │ │ ✓ │ │
|
||||||
|
│ └────────┴────────┴────────┴────┘ │
|
||||||
|
└───────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
mcp.servers
|
||||||
|
{
|
||||||
|
"mcp-1": {
|
||||||
|
apps: {claude: true, codex: true, gemini: false}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 技术架构
|
||||||
|
|
||||||
|
### 数据结构设计
|
||||||
|
|
||||||
|
#### 新增:McpApps(应用启用状态)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
pub struct McpApps {
|
||||||
|
pub claude: bool,
|
||||||
|
pub codex: bool,
|
||||||
|
pub gemini: bool,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 更新:McpServer(统一服务器定义)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct McpServer {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub server: serde_json::Value, // 连接配置(stdio/http)
|
||||||
|
pub apps: McpApps, // 新增:标记应用到哪些客户端
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub homepage: Option<String>,
|
||||||
|
pub docs: Option<String>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 更新:McpRoot(新旧结构并存)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct McpRoot {
|
||||||
|
// v3.7.0 新结构
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub servers: Option<HashMap<String, McpServer>>,
|
||||||
|
|
||||||
|
// v3.6.x 旧结构(保留用于迁移)
|
||||||
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
|
pub claude: McpConfig,
|
||||||
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
|
pub codex: McpConfig,
|
||||||
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
|
pub gemini: McpConfig,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 迁移策略
|
||||||
|
|
||||||
|
```
|
||||||
|
旧配置 (v3.6.x) 新配置 (v3.7.0)
|
||||||
|
───────────────── ─────────────────
|
||||||
|
mcp: mcp:
|
||||||
|
claude: servers:
|
||||||
|
servers: mcp-fetch:
|
||||||
|
mcp-fetch: {...} → id: "mcp-fetch"
|
||||||
|
codex: server: {...}
|
||||||
|
servers: apps:
|
||||||
|
mcp-filesystem: {...} claude: true
|
||||||
|
codex: true
|
||||||
|
gemini: false
|
||||||
|
```
|
||||||
|
|
||||||
|
**迁移逻辑**:
|
||||||
|
1. 检测 `mcp.servers` 是否存在
|
||||||
|
2. 若不存在,从 `mcp.claude/codex/gemini.servers` 收集所有服务器
|
||||||
|
3. 合并同 id 服务器的 apps 字段
|
||||||
|
4. 清空旧结构字段
|
||||||
|
5. 保存配置(自动触发)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 开发进度
|
||||||
|
|
||||||
|
### Phase 1: 后端数据结构与迁移 ✅ 已完成
|
||||||
|
|
||||||
|
#### 1.1 修改数据结构(app_config.rs)✅
|
||||||
|
|
||||||
|
**文件**:`src-tauri/src/app_config.rs`
|
||||||
|
|
||||||
|
**变更**:
|
||||||
|
- ✅ 新增 `McpApps` 结构体(lines 30-62)
|
||||||
|
- ✅ 新增 `McpServer` 结构体(lines 64-79)
|
||||||
|
- ✅ 更新 `McpRoot` 支持新旧结构(lines 81-96)
|
||||||
|
- ✅ 添加辅助方法:`is_enabled_for`, `set_enabled_for`, `enabled_apps`
|
||||||
|
|
||||||
|
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
||||||
|
|
||||||
|
#### 1.2 实现迁移逻辑 ✅
|
||||||
|
|
||||||
|
**文件**:`src-tauri/src/app_config.rs`
|
||||||
|
|
||||||
|
**实现**:
|
||||||
|
- ✅ `migrate_mcp_to_unified()` 方法(lines 380-509)
|
||||||
|
- 从旧结构收集所有服务器
|
||||||
|
- 按 id 合并重复服务器
|
||||||
|
- 处理冲突(合并 apps 字段)
|
||||||
|
- 清空旧结构
|
||||||
|
- ✅ 集成到 `MultiAppConfig::load()` 方法(lines 252-257)
|
||||||
|
- 自动检测并执行迁移
|
||||||
|
- 迁移后保存配置
|
||||||
|
|
||||||
|
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: 后端服务层重构 ✅ 已完成
|
||||||
|
|
||||||
|
#### 2.1 重写 McpService ✅
|
||||||
|
|
||||||
|
**文件**:`src-tauri/src/services/mcp.rs`
|
||||||
|
|
||||||
|
**新增方法**:
|
||||||
|
- ✅ `get_all_servers()` - 获取所有服务器(lines 13-27)
|
||||||
|
- ✅ `upsert_server()` - 添加/更新服务器(lines 30-52)
|
||||||
|
- ✅ `delete_server()` - 删除服务器(lines 55-75)
|
||||||
|
- ✅ `toggle_app()` - 切换应用启用状态(lines 78-111)
|
||||||
|
- ✅ `sync_all_enabled()` - 同步所有启用的服务器(lines 180-188)
|
||||||
|
|
||||||
|
**兼容层方法**(已废弃):
|
||||||
|
- ✅ `get_servers()` - 按应用过滤服务器(lines 196-210)
|
||||||
|
- ✅ `set_enabled()` - 委托到 toggle_app(lines 213-222)
|
||||||
|
- ✅ `sync_enabled()` - 同步特定应用(lines 225-236)
|
||||||
|
- ✅ `import_from_claude/codex/gemini()` - 导入包装(lines 239-266)
|
||||||
|
|
||||||
|
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
||||||
|
|
||||||
|
#### 2.2 新增同步函数(mcp.rs)✅
|
||||||
|
|
||||||
|
**文件**:`src-tauri/src/mcp.rs`
|
||||||
|
|
||||||
|
**新增函数**(lines 800-965):
|
||||||
|
- ✅ `json_server_to_toml_table()` - JSON → TOML 转换助手(lines 828-889)
|
||||||
|
- ✅ `sync_single_server_to_claude()` - 同步单个服务器到 Claude(lines 800-814)
|
||||||
|
- ✅ `remove_server_from_claude()` - 从 Claude 移除服务器(lines 817-826)
|
||||||
|
- ✅ `sync_single_server_to_codex()` - 同步单个服务器到 Codex(lines 891-936)
|
||||||
|
- ✅ `remove_server_from_codex()` - 从 Codex 移除服务器(lines 939-965)
|
||||||
|
- ✅ `sync_single_server_to_gemini()` - 同步单个服务器到 Gemini(lines 967-977)
|
||||||
|
- ✅ `remove_server_from_gemini()` - 从 Gemini 移除服务器(lines 980-989)
|
||||||
|
|
||||||
|
**关键修复**:
|
||||||
|
- ✅ 修复 toml_edit 类型转换(使用手动构建而非 serde 转换)
|
||||||
|
- ✅ 修复 get_codex_config_path() 调用(返回 PathBuf 而非 Result)
|
||||||
|
|
||||||
|
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
||||||
|
**修复提交**:`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
|
||||||
|
|
||||||
|
#### 2.3 新增 Tauri Commands ✅
|
||||||
|
|
||||||
|
**文件**:`src-tauri/src/commands/mcp.rs`
|
||||||
|
|
||||||
|
**新增命令**(lines 147-196):
|
||||||
|
- ✅ `get_mcp_servers()` - 获取所有服务器(lines 154-159)
|
||||||
|
- ✅ `upsert_mcp_server()` - 添加/更新服务器(lines 162-168)
|
||||||
|
- ✅ `delete_mcp_server()` - 删除服务器(lines 171-177)
|
||||||
|
- ✅ `toggle_mcp_app()` - 切换应用状态(lines 180-189)
|
||||||
|
- ✅ `sync_all_mcp_servers()` - 同步所有服务器(lines 192-195)
|
||||||
|
|
||||||
|
**更新旧命令**(兼容层):
|
||||||
|
- ✅ `upsert_mcp_server_in_config()` - 转换为统一结构(lines 68-131)
|
||||||
|
- ✅ `delete_mcp_server_in_config()` - 忽略 app 参数(lines 134-141)
|
||||||
|
|
||||||
|
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
||||||
|
**修复提交**:`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
|
||||||
|
|
||||||
|
#### 2.4 注册新命令(lib.rs)✅
|
||||||
|
|
||||||
|
**文件**:`src-tauri/src/lib.rs`
|
||||||
|
|
||||||
|
**变更**:
|
||||||
|
- ✅ 导出 `McpServer` 类型(line 21)
|
||||||
|
- ✅ 导出新增的 mcp 同步函数(lines 26-31)
|
||||||
|
- ✅ 注册 5 个新命令到 invoke_handler(lines 550-555)
|
||||||
|
|
||||||
|
**提交**:`c7b235b` - "feat(mcp): implement unified MCP management for v3.7.0"
|
||||||
|
|
||||||
|
#### 2.5 添加缺失的函数(claude_mcp.rs & gemini_mcp.rs)✅
|
||||||
|
|
||||||
|
**文件**:
|
||||||
|
- `src-tauri/src/claude_mcp.rs` (lines 234-253)
|
||||||
|
- `src-tauri/src/gemini_mcp.rs` (lines 160-179)
|
||||||
|
|
||||||
|
**新增**:
|
||||||
|
- ✅ `read_mcp_servers_map()` - 读取现有 MCP 服务器映射
|
||||||
|
|
||||||
|
**提交**:`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
|
||||||
|
|
||||||
|
#### 2.6 编译验证 ✅
|
||||||
|
|
||||||
|
**状态**:✅ 编译成功
|
||||||
|
- ⚠️ 16 个警告(8 个废弃警告 + 8 个未使用函数警告 - 预期内)
|
||||||
|
- ✅ 0 个错误
|
||||||
|
|
||||||
|
**提交**:`7ae2a9f` - "fix(mcp): resolve compilation errors and add backward compatibility"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: 前端开发 ⚠️ 部分完成
|
||||||
|
|
||||||
|
#### 3.1 TypeScript 类型定义 ✅
|
||||||
|
|
||||||
|
**文件**:`src/types.ts`
|
||||||
|
|
||||||
|
**变更**:
|
||||||
|
- ✅ 新增 `McpApps` 接口(lines 129-133)
|
||||||
|
- ✅ 更新 `McpServer` 接口(lines 136-149)
|
||||||
|
- 新增 `apps: McpApps` 字段
|
||||||
|
- `name` 改为必填
|
||||||
|
- 标记 `enabled` 为废弃
|
||||||
|
- ✅ 新增 `McpServersMap` 类型别名(line 152)
|
||||||
|
- ✅ 保持向后兼容(保留 `enabled`, `source` 等旧字段)
|
||||||
|
|
||||||
|
**提交**:`ac09551` - "feat(frontend): add unified MCP types and API layer for v3.7.0"
|
||||||
|
|
||||||
|
#### 3.2 API 层更新 ✅
|
||||||
|
|
||||||
|
**文件**:`src/lib/api/mcp.ts`
|
||||||
|
|
||||||
|
**新增方法**(lines 99-141):
|
||||||
|
- ✅ `getAllServers()` - 获取所有服务器(lines 106-108)
|
||||||
|
- ✅ `upsertUnifiedServer()` - 添加/更新服务器(lines 113-115)
|
||||||
|
- ✅ `deleteUnifiedServer()` - 删除服务器(lines 120-122)
|
||||||
|
- ✅ `toggleApp()` - 切换应用状态(lines 127-133)
|
||||||
|
- ✅ `syncAllServers()` - 同步所有服务器(lines 138-140)
|
||||||
|
|
||||||
|
**导入更新**:
|
||||||
|
- ✅ 导入 `McpServersMap` 类型(line 6)
|
||||||
|
|
||||||
|
**提交**:`ac09551` - "feat(frontend): add unified MCP types and API layer for v3.7.0"
|
||||||
|
|
||||||
|
#### 3.3 React Query Hooks 📝 待开发
|
||||||
|
|
||||||
|
**计划文件**:`src/hooks/useMcp.ts`
|
||||||
|
|
||||||
|
**需要实现的 Hooks**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 查询 hooks
|
||||||
|
export function useAllMcpServers() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['mcp', 'all'],
|
||||||
|
queryFn: () => mcpApi.getAllServers(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 变更 hooks
|
||||||
|
export function useUpsertMcpServer() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (server: McpServer) => mcpApi.upsertUnifiedServer(server),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToggleMcpApp() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ serverId, app, enabled }: {
|
||||||
|
serverId: string;
|
||||||
|
app: AppId;
|
||||||
|
enabled: boolean;
|
||||||
|
}) => mcpApi.toggleApp(serverId, app, enabled),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteMcpServer() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => mcpApi.deleteUnifiedServer(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSyncAllMcpServers() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => mcpApi.syncAllServers(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**依赖**:
|
||||||
|
- `@tanstack/react-query` (已安装)
|
||||||
|
- `src/lib/api/mcp.ts` (✅ 已完成)
|
||||||
|
- `src/types.ts` (✅ 已完成)
|
||||||
|
|
||||||
|
#### 3.4 统一 MCP 面板组件 📝 待开发
|
||||||
|
|
||||||
|
**计划文件**:`src/components/mcp/UnifiedMcpPanel.tsx`
|
||||||
|
|
||||||
|
**组件结构**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface UnifiedMcpPanelProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UnifiedMcpPanel({ className }: UnifiedMcpPanelProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: servers, isLoading } = useAllMcpServers();
|
||||||
|
const toggleApp = useToggleMcpApp();
|
||||||
|
const deleteServer = useDeleteMcpServer();
|
||||||
|
const syncAll = useSyncAllMcpServers();
|
||||||
|
|
||||||
|
// 组件实现...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI 设计**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ MCP 服务器管理 ┌──────────┐ │
|
||||||
|
│ │ 添加服务器 │ │
|
||||||
|
│ ┌─────┐ ┌──────────────┐ ┌─────────┐ └──────────┘ │
|
||||||
|
│ │ 搜索 │ │ 导入自...▼ │ │ 同步全部 │ │
|
||||||
|
│ └─────┘ └──────────────┘ └─────────┘ │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────┐ │
|
||||||
|
│ │ 名称 │ Claude │ Codex │ Gemini │操作│ │
|
||||||
|
│ ├─────────────────────────────────────────────┤ │
|
||||||
|
│ │ mcp-fetch │ ✓ │ ✓ │ │ ⚙️ │ │
|
||||||
|
│ │ filesystem │ ✓ │ │ ✓ │ ⚙️ │ │
|
||||||
|
│ │ brave-search │ │ ✓ │ ✓ │ ⚙️ │ │
|
||||||
|
│ └─────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**功能特性**:
|
||||||
|
- 📋 服务器列表展示(名称、描述、标签)
|
||||||
|
- ☑️ 三个复选框控制应用启用状态(Claude/Codex/Gemini)
|
||||||
|
- ➕ 添加新服务器(表单模态框)
|
||||||
|
- ✏️ 编辑服务器(表单模态框)
|
||||||
|
- 🗑️ 删除服务器(确认对话框)
|
||||||
|
- 📥 导入功能(从 Claude/Codex/Gemini 导入)
|
||||||
|
- 🔄 同步全部(手动触发同步到 live 配置)
|
||||||
|
- 🔍 搜索过滤
|
||||||
|
- 🏷️ 标签过滤
|
||||||
|
|
||||||
|
**子组件**:
|
||||||
|
|
||||||
|
1. **McpServerTable** (`McpServerTable.tsx`)
|
||||||
|
- 服务器列表表格
|
||||||
|
- 应用复选框
|
||||||
|
- 操作按钮(编辑、删除)
|
||||||
|
|
||||||
|
2. **McpServerFormModal** (`McpServerFormModal.tsx`)
|
||||||
|
- 添加/编辑表单
|
||||||
|
- stdio/http 类型切换
|
||||||
|
- 应用选择(多选)
|
||||||
|
- 元信息编辑(描述、标签、链接)
|
||||||
|
|
||||||
|
3. **McpImportDialog** (`McpImportDialog.tsx`)
|
||||||
|
- 选择导入来源(Claude/Codex/Gemini)
|
||||||
|
- 服务器预览
|
||||||
|
- 批量导入
|
||||||
|
|
||||||
|
**依赖组件**(来自 shadcn/ui):
|
||||||
|
- `Table`, `TableBody`, `TableCell`, `TableHead`, `TableHeader`, `TableRow`
|
||||||
|
- `Checkbox`
|
||||||
|
- `Button`
|
||||||
|
- `Dialog`, `DialogContent`, `DialogHeader`, `DialogTitle`
|
||||||
|
- `Input`, `Textarea`, `Label`
|
||||||
|
- `Select`, `SelectContent`, `SelectItem`, `SelectTrigger`, `SelectValue`
|
||||||
|
- `Badge`
|
||||||
|
- `Tooltip`
|
||||||
|
|
||||||
|
#### 3.5 主界面集成 📝 待开发
|
||||||
|
|
||||||
|
**文件**:`src/App.tsx`
|
||||||
|
|
||||||
|
**变更计划**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 原有代码(v3.6.x)
|
||||||
|
{currentApp === 'claude' && <ClaudeMcpPanel />}
|
||||||
|
{currentApp === 'codex' && <CodexMcpPanel />}
|
||||||
|
{currentApp === 'gemini' && <GeminiMcpPanel />}
|
||||||
|
|
||||||
|
// 新代码(v3.7.0)
|
||||||
|
<UnifiedMcpPanel />
|
||||||
|
```
|
||||||
|
|
||||||
|
**移除的组件**:
|
||||||
|
- `ClaudeMcpPanel.tsx`
|
||||||
|
- `CodexMcpPanel.tsx`
|
||||||
|
- `GeminiMcpPanel.tsx`
|
||||||
|
|
||||||
|
**注意**:保留旧组件文件备份,以便回滚
|
||||||
|
|
||||||
|
#### 3.6 国际化文本更新 📝 待开发
|
||||||
|
|
||||||
|
**文件**:
|
||||||
|
- `src/locales/zh/translation.json`
|
||||||
|
- `src/locales/en/translation.json`
|
||||||
|
|
||||||
|
**需要添加的翻译键**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcp": {
|
||||||
|
"unifiedPanel": {
|
||||||
|
"title": "MCP 服务器管理 / MCP Server Management",
|
||||||
|
"addServer": "添加服务器 / Add Server",
|
||||||
|
"editServer": "编辑服务器 / Edit Server",
|
||||||
|
"deleteServer": "删除服务器 / Delete Server",
|
||||||
|
"deleteConfirm": "确定要删除此服务器吗?/ Are you sure to delete this server?",
|
||||||
|
"syncAll": "同步全部 / Sync All",
|
||||||
|
"syncAllSuccess": "已同步所有启用的服务器 / All enabled servers synced",
|
||||||
|
"importFrom": "导入自... / Import from...",
|
||||||
|
"search": "搜索服务器... / Search servers...",
|
||||||
|
"noServers": "暂无服务器 / No servers yet",
|
||||||
|
"enabledApps": "启用的应用 / Enabled Apps",
|
||||||
|
"apps": {
|
||||||
|
"claude": "Claude",
|
||||||
|
"codex": "Codex",
|
||||||
|
"gemini": "Gemini"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"id": "服务器 ID / Server ID",
|
||||||
|
"name": "显示名称 / Display Name",
|
||||||
|
"type": "类型 / Type",
|
||||||
|
"stdio": "本地进程 / Local Process",
|
||||||
|
"http": "远程服务 / Remote Service",
|
||||||
|
"command": "命令 / Command",
|
||||||
|
"args": "参数 / Arguments",
|
||||||
|
"env": "环境变量 / Environment Variables",
|
||||||
|
"cwd": "工作目录 / Working Directory",
|
||||||
|
"url": "URL",
|
||||||
|
"headers": "请求头 / Headers",
|
||||||
|
"description": "描述 / Description",
|
||||||
|
"tags": "标签 / Tags",
|
||||||
|
"homepage": "主页 / Homepage",
|
||||||
|
"docs": "文档 / Documentation",
|
||||||
|
"selectApps": "选择应用 / Select Apps",
|
||||||
|
"selectAppsHint": "勾选此服务器要应用到哪些客户端 / Check which clients this server applies to"
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"name": "名称 / Name",
|
||||||
|
"type": "类型 / Type",
|
||||||
|
"apps": "应用 / Apps",
|
||||||
|
"actions": "操作 / Actions",
|
||||||
|
"edit": "编辑 / Edit",
|
||||||
|
"delete": "删除 / Delete"
|
||||||
|
},
|
||||||
|
"import": {
|
||||||
|
"title": "导入 MCP 服务器 / Import MCP Servers",
|
||||||
|
"fromClaude": "从 Claude 导入 / Import from Claude",
|
||||||
|
"fromCodex": "从 Codex 导入 / Import from Codex",
|
||||||
|
"fromGemini": "从 Gemini 导入 / Import from Gemini",
|
||||||
|
"success": "成功导入 {{count}} 个服务器 / Successfully imported {{count}} server(s)",
|
||||||
|
"noServersFound": "未找到可导入的服务器 / No servers found to import"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 迁移流程
|
||||||
|
|
||||||
|
### 用户体验
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 用户升级到 v3.7.0
|
||||||
|
↓
|
||||||
|
2. 首次启动应用
|
||||||
|
↓
|
||||||
|
3. 后端自动执行迁移
|
||||||
|
- 检测旧结构 (mcp.claude/codex/gemini.servers)
|
||||||
|
- 合并到统一结构 (mcp.servers)
|
||||||
|
- 保存迁移后的配置
|
||||||
|
- 日志记录迁移详情
|
||||||
|
↓
|
||||||
|
4. 前端加载新面板
|
||||||
|
- 显示所有服务器
|
||||||
|
- 三个复选框显示各应用启用状态
|
||||||
|
↓
|
||||||
|
5. 用户无缝使用
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据完整性保证
|
||||||
|
|
||||||
|
1. **迁移前验证**:
|
||||||
|
- ✅ 校验旧结构合法性
|
||||||
|
- ✅ 记录迁移前状态
|
||||||
|
|
||||||
|
2. **迁移中处理**:
|
||||||
|
- ✅ 合并同 id 服务器的 apps 字段
|
||||||
|
- ✅ 处理 id 冲突(保留第一个,记录警告)
|
||||||
|
- ✅ 保留所有元信息(描述、标签、链接)
|
||||||
|
|
||||||
|
3. **迁移后清理**:
|
||||||
|
- ✅ 清空旧结构(claude/codex/gemini)
|
||||||
|
- ✅ 自动保存新配置
|
||||||
|
- ✅ 日志记录迁移完成
|
||||||
|
|
||||||
|
4. **回滚机制**:
|
||||||
|
- 配置文件有备份(`config.v1.backup.<timestamp>.json`)
|
||||||
|
- 迁移失败时可手动回滚
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 测试计划
|
||||||
|
|
||||||
|
### 后端测试 ✅ 已验证
|
||||||
|
|
||||||
|
- [x] 编译测试(cargo check)
|
||||||
|
- [x] 数据结构序列化/反序列化
|
||||||
|
- [ ] 迁移逻辑单元测试
|
||||||
|
- [ ] 服务层方法测试
|
||||||
|
- [ ] 同步函数测试
|
||||||
|
|
||||||
|
### 前端测试 ⏳ 待进行
|
||||||
|
|
||||||
|
- [ ] TypeScript 类型检查
|
||||||
|
- [ ] API 调用测试
|
||||||
|
- [ ] 组件渲染测试
|
||||||
|
- [ ] 用户交互测试
|
||||||
|
- [ ] 国际化文本检查
|
||||||
|
|
||||||
|
### 集成测试 ⏳ 待进行
|
||||||
|
|
||||||
|
- [ ] 完整迁移流程测试
|
||||||
|
- [ ] 从空配置启动
|
||||||
|
- [ ] 从 v3.6.x 配置升级
|
||||||
|
- [ ] 多服务器合并场景
|
||||||
|
- [ ] 冲突处理验证
|
||||||
|
- [ ] 多应用同步测试
|
||||||
|
- [ ] 启用单个应用
|
||||||
|
- [ ] 启用多个应用
|
||||||
|
- [ ] 动态切换应用
|
||||||
|
- [ ] 同步到 live 配置验证
|
||||||
|
- [ ] 边界情况测试
|
||||||
|
- [ ] 空服务器列表
|
||||||
|
- [ ] 超长服务器名称
|
||||||
|
- [ ] 特殊字符处理
|
||||||
|
- [ ] 并发操作
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 交付清单
|
||||||
|
|
||||||
|
### 代码文件
|
||||||
|
|
||||||
|
#### 后端(Rust)✅ 已完成
|
||||||
|
|
||||||
|
- [x] `src-tauri/src/app_config.rs` - 数据结构定义与迁移
|
||||||
|
- [x] `src-tauri/src/services/mcp.rs` - 服务层重构
|
||||||
|
- [x] `src-tauri/src/mcp.rs` - 同步函数实现
|
||||||
|
- [x] `src-tauri/src/commands/mcp.rs` - Tauri 命令
|
||||||
|
- [x] `src-tauri/src/lib.rs` - 命令注册
|
||||||
|
- [x] `src-tauri/src/claude_mcp.rs` - Claude MCP 操作
|
||||||
|
- [x] `src-tauri/src/gemini_mcp.rs` - Gemini MCP 操作
|
||||||
|
|
||||||
|
#### 前端(TypeScript/React)⚠️ 部分完成
|
||||||
|
|
||||||
|
- [x] `src/types.ts` - 类型定义更新
|
||||||
|
- [x] `src/lib/api/mcp.ts` - API 层更新
|
||||||
|
- [ ] `src/hooks/useMcp.ts` - React Query Hooks
|
||||||
|
- [ ] `src/components/mcp/UnifiedMcpPanel.tsx` - 统一面板组件
|
||||||
|
- [ ] `src/components/mcp/McpServerTable.tsx` - 服务器表格
|
||||||
|
- [ ] `src/components/mcp/McpServerFormModal.tsx` - 表单模态框
|
||||||
|
- [ ] `src/components/mcp/McpImportDialog.tsx` - 导入对话框
|
||||||
|
- [ ] `src/App.tsx` - 主界面集成
|
||||||
|
- [ ] `src/locales/zh/translation.json` - 中文翻译
|
||||||
|
- [ ] `src/locales/en/translation.json` - 英文翻译
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
|
||||||
|
- [x] 本重构计划文档 (`docs/v3.7.0-unified-mcp-refactor.md`)
|
||||||
|
- [ ] 用户升级指南 (`docs/upgrade-to-v3.7.0.md`)
|
||||||
|
- [ ] API 变更说明 (`docs/api-changes-v3.7.0.md`)
|
||||||
|
|
||||||
|
### Git 提交记录 ✅
|
||||||
|
|
||||||
|
- [x] `c7b235b` - feat(mcp): implement unified MCP management for v3.7.0
|
||||||
|
- [x] `7ae2a9f` - fix(mcp): resolve compilation errors and add backward compatibility
|
||||||
|
- [x] `ac09551` - feat(frontend): add unified MCP types and API layer for v3.7.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 下一步行动
|
||||||
|
|
||||||
|
### 立即任务(优先级 P0)
|
||||||
|
|
||||||
|
1. ⬜ **实现 useMcp Hook**
|
||||||
|
- 文件:`src/hooks/useMcp.ts`
|
||||||
|
- 估时:1-2 小时
|
||||||
|
- 依赖:API 层(已完成)
|
||||||
|
|
||||||
|
2. ⬜ **创建 UnifiedMcpPanel 核心组件**
|
||||||
|
- 文件:`src/components/mcp/UnifiedMcpPanel.tsx`
|
||||||
|
- 估时:3-4 小时
|
||||||
|
- 依赖:useMcp Hook
|
||||||
|
|
||||||
|
3. ⬜ **添加国际化文本**
|
||||||
|
- 文件:`src/locales/{zh,en}/translation.json`
|
||||||
|
- 估时:30 分钟
|
||||||
|
|
||||||
|
4. ⬜ **集成到主界面**
|
||||||
|
- 文件:`src/App.tsx`
|
||||||
|
- 估时:30 分钟
|
||||||
|
- 依赖:UnifiedMcpPanel 组件
|
||||||
|
|
||||||
|
### 次要任务(优先级 P1)
|
||||||
|
|
||||||
|
5. ⬜ **实现子组件**
|
||||||
|
- McpServerTable
|
||||||
|
- McpServerFormModal
|
||||||
|
- McpImportDialog
|
||||||
|
- 估时:4-6 小时
|
||||||
|
|
||||||
|
6. ⬜ **编写测试用例**
|
||||||
|
- 后端单元测试
|
||||||
|
- 前端组件测试
|
||||||
|
- 集成测试
|
||||||
|
- 估时:6-8 小时
|
||||||
|
|
||||||
|
7. ⬜ **编写用户文档**
|
||||||
|
- 升级指南
|
||||||
|
- API 变更说明
|
||||||
|
- 估时:2-3 小时
|
||||||
|
|
||||||
|
### 优化任务(优先级 P2)
|
||||||
|
|
||||||
|
8. ⬜ **性能优化**
|
||||||
|
- 服务器列表虚拟滚动
|
||||||
|
- 批量操作优化
|
||||||
|
- 估时:2-3 小时
|
||||||
|
|
||||||
|
9. ⬜ **用户体验增强**
|
||||||
|
- 添加加载状态
|
||||||
|
- 添加错误提示
|
||||||
|
- 添加操作确认
|
||||||
|
- 估时:2-3 小时
|
||||||
|
|
||||||
|
10. ⬜ **代码清理**
|
||||||
|
- 移除旧的分应用面板组件
|
||||||
|
- 清理废弃代码
|
||||||
|
- 代码格式化
|
||||||
|
- 估时:1-2 小时
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 技术亮点
|
||||||
|
|
||||||
|
### 1. 平滑迁移机制
|
||||||
|
|
||||||
|
- ✅ 自动检测旧配置并迁移
|
||||||
|
- ✅ 新旧结构并存(过渡期)
|
||||||
|
- ✅ 无需用户手动操作
|
||||||
|
- ✅ 保留所有历史数据
|
||||||
|
|
||||||
|
### 2. 向后兼容
|
||||||
|
|
||||||
|
- ✅ 旧命令继续可用(带废弃警告)
|
||||||
|
- ✅ 前端可增量更新
|
||||||
|
- ✅ 渐进式重构策略
|
||||||
|
|
||||||
|
### 3. 类型安全
|
||||||
|
|
||||||
|
- ✅ Rust 强类型保证数据完整性
|
||||||
|
- ✅ TypeScript 类型定义与后端一致
|
||||||
|
- ✅ serde 序列化/反序列化自动处理
|
||||||
|
|
||||||
|
### 4. 清晰的架构分层
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend (React)
|
||||||
|
↓ (Tauri IPC)
|
||||||
|
Commands Layer
|
||||||
|
↓
|
||||||
|
Services Layer
|
||||||
|
↓
|
||||||
|
Data Layer (Config + Live Sync)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. SSOT 原则
|
||||||
|
|
||||||
|
- 单一配置源:`~/.cc-switch/config.json`
|
||||||
|
- 统一管理:`mcp.servers` 字段
|
||||||
|
- 按需同步:写入各应用 live 配置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 参考资源
|
||||||
|
|
||||||
|
### 内部文档
|
||||||
|
|
||||||
|
- [项目 README](../README.md)
|
||||||
|
- [CLAUDE.md](../CLAUDE.md) - Claude Code 工作指南
|
||||||
|
- [架构文档](../CLAUDE.md#架构概述)
|
||||||
|
|
||||||
|
### 相关 Issues/PRs
|
||||||
|
|
||||||
|
- 无(新功能开发)
|
||||||
|
|
||||||
|
### 技术栈文档
|
||||||
|
|
||||||
|
- [Tauri 2.0](https://tauri.app/v1/guides/)
|
||||||
|
- [React 18](https://react.dev/)
|
||||||
|
- [TanStack Query](https://tanstack.com/query/latest)
|
||||||
|
- [shadcn/ui](https://ui.shadcn.com/)
|
||||||
|
- [serde](https://serde.rs/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 变更日志
|
||||||
|
|
||||||
|
### 2025-11-14
|
||||||
|
|
||||||
|
- ✅ 完成后端 Phase 1 & 2(数据结构、服务层、命令层)
|
||||||
|
- ✅ 修复所有编译错误
|
||||||
|
- ✅ 完成前端类型定义和 API 层
|
||||||
|
- ✅ 创建本重构计划文档
|
||||||
|
|
||||||
|
### 待更新...
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👥 团队协作
|
||||||
|
|
||||||
|
**开发者**:Claude Code (AI Assistant) + User
|
||||||
|
|
||||||
|
**审查者**:User
|
||||||
|
|
||||||
|
**测试者**:User
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 风险与对策
|
||||||
|
|
||||||
|
### 风险 1:迁移数据丢失
|
||||||
|
|
||||||
|
**概率**:低
|
||||||
|
**影响**:高
|
||||||
|
**对策**:
|
||||||
|
- ✅ 迁移前自动备份配置
|
||||||
|
- ✅ 详细日志记录
|
||||||
|
- ✅ 测试各种边界情况
|
||||||
|
|
||||||
|
### 风险 2:性能问题(大量服务器)
|
||||||
|
|
||||||
|
**概率**:中
|
||||||
|
**影响**:中
|
||||||
|
**对策**:
|
||||||
|
- ⬜ 实现虚拟滚动
|
||||||
|
- ⬜ 分页或懒加载
|
||||||
|
- ⬜ 性能测试
|
||||||
|
|
||||||
|
### 风险 3:兼容性问题
|
||||||
|
|
||||||
|
**概率**:中
|
||||||
|
**影响**:中
|
||||||
|
**对策**:
|
||||||
|
- ✅ 保留旧命令兼容层
|
||||||
|
- ✅ 前端增量更新
|
||||||
|
- ⬜ 多版本测试
|
||||||
|
|
||||||
|
### 风险 4:用户学习成本
|
||||||
|
|
||||||
|
**概率**:低
|
||||||
|
**影响**:低
|
||||||
|
**对策**:
|
||||||
|
- ⬜ 清晰的 UI 设计
|
||||||
|
- ⬜ 详细的升级指南
|
||||||
|
- ⬜ 操作提示和引导
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 预期收益
|
||||||
|
|
||||||
|
### 用户体验提升
|
||||||
|
|
||||||
|
- ⭐ **简化操作**:不再需要在不同应用面板切换
|
||||||
|
- ⭐ **统一视图**:一目了然看到所有 MCP 配置
|
||||||
|
- ⭐ **灵活配置**:轻松控制每个 MCP 应用到哪些客户端
|
||||||
|
|
||||||
|
### 代码质量提升
|
||||||
|
|
||||||
|
- ⭐ **架构优化**:统一数据源,消除冗余
|
||||||
|
- ⭐ **维护性**:单一面板组件,代码更简洁
|
||||||
|
- ⭐ **扩展性**:未来添加新应用(如 Cursor)更容易
|
||||||
|
|
||||||
|
### 性能提升
|
||||||
|
|
||||||
|
- ⭐ **减少重复加载**:统一管理减少配置文件读写
|
||||||
|
- ⭐ **更快同步**:批量操作更高效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 联系方式
|
||||||
|
|
||||||
|
**问题反馈**:[GitHub Issues](https://github.com/jasonyoungyang/cc-switch/issues)
|
||||||
|
|
||||||
|
**功能建议**:[GitHub Discussions](https://github.com/jasonyoungyang/cc-switch/discussions)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档版本**:v1.0
|
||||||
|
**最后更新**:2025-11-14
|
||||||
|
**状态**:🟡 开发中(后端完成 ✅,前端进行中 ⚠️)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Flatpak Build Guide
|
||||||
|
|
||||||
|
This directory contains the Flatpak manifest (`com.ccswitch.desktop`) for CC Switch, used to convert the generated `.deb` artifact into an installable `.flatpak` package via CI or local builds.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `flatpak`
|
||||||
|
- `flatpak-builder`
|
||||||
|
- Flathub remote (for installing `org.gnome.Platform//46` runtime)
|
||||||
|
|
||||||
|
For Ubuntu/Debian:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install flatpak flatpak-builder
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local Build (Generate .flatpak from .deb)
|
||||||
|
|
||||||
|
1) Build the deb on Linux first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm tauri build -- --bundles deb
|
||||||
|
```
|
||||||
|
|
||||||
|
2) Copy the generated deb to this directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp "$(find src-tauri/target/release/bundle -name '*.deb' | head -n 1)" flatpak/cc-switch.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
3) Build the local Flatpak repository and export the `.flatpak`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak-builder --force-clean --user --disable-cache --repo flatpak-repo flatpak-build flatpak/com.ccswitch.desktop.yml
|
||||||
|
flatpak build-bundle --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo flatpak-repo CC-Switch-Linux.flatpak com.ccswitch.desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
4) Install and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak install --user ./CC-Switch-Linux.flatpak
|
||||||
|
flatpak run com.ccswitch.desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Permissions Note
|
||||||
|
|
||||||
|
The current manifest uses `--filesystem=home` by default for "download and run" convenience, allowing the app to directly read/write CLI configuration files and app data on the host (and supporting the "directory override" feature).
|
||||||
|
|
||||||
|
If you prefer minimal permissions (e.g., for Flathub submission or security concerns), you can replace `--filesystem=home` in `flatpak/com.ccswitch.desktop.yml` with more precise grants:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- --filesystem=~/.cc-switch:create
|
||||||
|
- --filesystem=~/.claude:create
|
||||||
|
- --filesystem=~/.claude.json
|
||||||
|
- --filesystem=~/.codex:create
|
||||||
|
- --filesystem=~/.gemini:create
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Flatpak's `:create` modifier only works with directories, not files. Therefore, `~/.claude.json` cannot use `:create`. If this file doesn't exist on the user's machine, the app may not be able to create it with restricted permissions. Users should either run Claude Code once to generate it, or manually create an empty JSON file (content: `{}`).
|
||||||
|
|
||||||
|
If you plan to publish on Flathub or want stricter permission control, adjust the `finish-args` in `flatpak/com.ccswitch.desktop.yml` accordingly.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=CC Switch
|
||||||
|
Comment=All-in-One Assistant for Claude Code, Codex & Gemini CLI
|
||||||
|
Exec=cc-switch
|
||||||
|
Icon=com.ccswitch.desktop
|
||||||
|
Terminal=false
|
||||||
|
Categories=Utility;Development;
|
||||||
|
StartupNotify=true
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>com.ccswitch.desktop</id>
|
||||||
|
<name>CC Switch</name>
|
||||||
|
<summary>All-in-One Assistant for Claude Code, Codex & Gemini CLI</summary>
|
||||||
|
<metadata_license>CC0-1.0</metadata_license>
|
||||||
|
<project_license>MIT</project_license>
|
||||||
|
|
||||||
|
<description>
|
||||||
|
<p>CC Switch is a cross-platform desktop app for managing and switching provider configurations for Claude Code, Codex, and Gemini CLI.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Manage multiple provider configurations and endpoints</li>
|
||||||
|
<li>One-click switch and sync to client live configurations</li>
|
||||||
|
<li>MCP servers and Prompt/Skills management</li>
|
||||||
|
</ul>
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<launchable type="desktop-id">com.ccswitch.desktop.desktop</launchable>
|
||||||
|
<provides>
|
||||||
|
<binary>cc-switch</binary>
|
||||||
|
</provides>
|
||||||
|
|
||||||
|
<url type="homepage">https://github.com/farion1231/cc-switch</url>
|
||||||
|
<url type="bugtracker">https://github.com/farion1231/cc-switch/issues</url>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
id: com.ccswitch.desktop
|
||||||
|
|
||||||
|
runtime: org.gnome.Platform
|
||||||
|
runtime-version: '46'
|
||||||
|
sdk: org.gnome.Sdk
|
||||||
|
|
||||||
|
command: cc-switch
|
||||||
|
|
||||||
|
finish-args:
|
||||||
|
- --share=ipc
|
||||||
|
- --share=network
|
||||||
|
- --socket=wayland
|
||||||
|
- --socket=fallback-x11
|
||||||
|
- --device=dri
|
||||||
|
# Tray icon permissions (required by Tauri tray-icon)
|
||||||
|
- --talk-name=org.kde.StatusNotifierWatcher
|
||||||
|
- --filesystem=xdg-run/tray-icon:create
|
||||||
|
# GitHub Releases scenario: Users download and install manually.
|
||||||
|
# For "download and run" convenience (needs read/write access to ~/.cc-switch, ~/.claude, ~/.claude.json, ~/.codex, ~/.gemini,
|
||||||
|
# and supports custom directory overrides), we grant full Home access by default.
|
||||||
|
# If you plan to publish on Flathub or prefer minimal permissions, replace this with more precise directory grants (see flatpak/README.md).
|
||||||
|
- --filesystem=home
|
||||||
|
|
||||||
|
modules:
|
||||||
|
- name: cc-switch
|
||||||
|
buildsystem: simple
|
||||||
|
sources:
|
||||||
|
# Placed in flatpak/ directory by CI or local build script
|
||||||
|
- type: file
|
||||||
|
path: cc-switch.deb
|
||||||
|
- type: file
|
||||||
|
path: com.ccswitch.desktop.desktop
|
||||||
|
- type: file
|
||||||
|
path: com.ccswitch.desktop.metainfo.xml
|
||||||
|
- type: file
|
||||||
|
path: ../src-tauri/icons/128x128.png
|
||||||
|
build-commands:
|
||||||
|
- ar -x *.deb
|
||||||
|
- tar -xf data.tar.*
|
||||||
|
- cp -a usr/* /app/
|
||||||
|
# Use our own desktop/metainfo/icon to align with Flatpak app id
|
||||||
|
- rm -f /app/share/applications/*.desktop
|
||||||
|
- install -Dm644 com.ccswitch.desktop.desktop /app/share/applications/com.ccswitch.desktop.desktop
|
||||||
|
- install -Dm644 com.ccswitch.desktop.metainfo.xml /app/share/metainfo/com.ccswitch.desktop.metainfo.xml
|
||||||
|
- install -Dm644 128x128.png /app/share/icons/hicolor/128x128/apps/com.ccswitch.desktop.png
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "cc-switch",
|
"name": "cc-switch",
|
||||||
"version": "3.6.2",
|
"version": "3.9.0-3",
|
||||||
"description": "Claude Code & Codex 供应商切换工具",
|
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm tauri dev",
|
"dev": "pnpm tauri dev",
|
||||||
"build": "pnpm tauri build",
|
"build": "pnpm tauri build",
|
||||||
@@ -26,17 +27,22 @@
|
|||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.0",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "^18.2.0",
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"code-inspector-plugin": "^1.3.3",
|
||||||
"cross-fetch": "^4.1.0",
|
"cross-fetch": "^4.1.0",
|
||||||
"jsdom": "^25.0.0",
|
"jsdom": "^25.0.0",
|
||||||
"msw": "^2.11.6",
|
"msw": "^2.11.6",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
"vite": "^5.0.0",
|
"vite": "^7.3.0",
|
||||||
"vitest": "^2.0.5"
|
"vitest": "^2.0.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
"@codemirror/lint": "^6.8.5",
|
"@codemirror/lint": "^6.8.5",
|
||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
@@ -45,6 +51,8 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@lobehub/icons-static-svg": "^1.73.0",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -53,7 +61,7 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@tailwindcss/vite": "^4.1.13",
|
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||||
"@tanstack/react-query": "^5.90.3",
|
"@tanstack/react-query": "^5.90.3",
|
||||||
"@tauri-apps/api": "^2.8.0",
|
"@tauri-apps/api": "^2.8.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||||
@@ -62,7 +70,9 @@
|
|||||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
|
"framer-motion": "^12.23.25",
|
||||||
"i18next": "^25.5.2",
|
"i18next": "^25.5.2",
|
||||||
"jsonc-parser": "^3.2.1",
|
"jsonc-parser": "^3.2.1",
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
@@ -70,10 +80,11 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "^7.65.0",
|
"react-hook-form": "^7.65.0",
|
||||||
"react-i18next": "^16.0.0",
|
"react-i18next": "^16.0.0",
|
||||||
|
"recharts": "^3.5.1",
|
||||||
"smol-toml": "^1.4.2",
|
"smol-toml": "^1.4.2",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.13",
|
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
}
|
},
|
||||||
}
|
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// 要提取的图标列表(按分类组织)
|
||||||
|
const ICONS_TO_EXTRACT = {
|
||||||
|
// AI 服务商(必需)
|
||||||
|
aiProviders: [
|
||||||
|
'openai', 'anthropic', 'claude', 'google', 'gemini',
|
||||||
|
'deepseek', 'kimi', 'moonshot', 'zhipu', 'minimax',
|
||||||
|
'baidu', 'alibaba', 'tencent', 'meta', 'microsoft',
|
||||||
|
'cohere', 'perplexity', 'mistral', 'huggingface'
|
||||||
|
],
|
||||||
|
|
||||||
|
// 云平台
|
||||||
|
cloudPlatforms: [
|
||||||
|
'aws', 'azure', 'huawei', 'cloudflare'
|
||||||
|
],
|
||||||
|
|
||||||
|
// 开发工具
|
||||||
|
devTools: [
|
||||||
|
'github', 'gitlab', 'docker', 'kubernetes', 'vscode'
|
||||||
|
],
|
||||||
|
|
||||||
|
// 其他
|
||||||
|
others: [
|
||||||
|
'settings', 'folder', 'file', 'link'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// 合并所有图标
|
||||||
|
const ALL_ICONS = [
|
||||||
|
...ICONS_TO_EXTRACT.aiProviders,
|
||||||
|
...ICONS_TO_EXTRACT.cloudPlatforms,
|
||||||
|
...ICONS_TO_EXTRACT.devTools,
|
||||||
|
...ICONS_TO_EXTRACT.others
|
||||||
|
];
|
||||||
|
|
||||||
|
// 提取逻辑
|
||||||
|
const OUTPUT_DIR = path.join(__dirname, '../src/icons/extracted');
|
||||||
|
const SOURCE_DIR = path.join(__dirname, '../node_modules/@lobehub/icons-static-svg/icons');
|
||||||
|
|
||||||
|
// 确保输出目录存在
|
||||||
|
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||||
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🎨 CC-Switch Icon Extractor\n');
|
||||||
|
console.log('========================================');
|
||||||
|
console.log('📦 Extracting icons...\n');
|
||||||
|
|
||||||
|
let extracted = 0;
|
||||||
|
let notFound = [];
|
||||||
|
|
||||||
|
// 提取图标
|
||||||
|
ALL_ICONS.forEach(iconName => {
|
||||||
|
const sourceFile = path.join(SOURCE_DIR, `${iconName}.svg`);
|
||||||
|
const targetFile = path.join(OUTPUT_DIR, `${iconName}.svg`);
|
||||||
|
|
||||||
|
if (fs.existsSync(sourceFile)) {
|
||||||
|
fs.copyFileSync(sourceFile, targetFile);
|
||||||
|
console.log(` ✓ ${iconName}.svg`);
|
||||||
|
extracted++;
|
||||||
|
} else {
|
||||||
|
console.log(` ✗ ${iconName}.svg (not found)`);
|
||||||
|
notFound.push(iconName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 生成索引文件
|
||||||
|
console.log('\n📝 Generating index file...\n');
|
||||||
|
|
||||||
|
const indexContent = `// Auto-generated icon index
|
||||||
|
// Do not edit manually
|
||||||
|
|
||||||
|
export const icons: Record<string, string> = {
|
||||||
|
${ALL_ICONS.filter(name => !notFound.includes(name))
|
||||||
|
.map(name => {
|
||||||
|
const svg = fs.readFileSync(path.join(OUTPUT_DIR, `${name}.svg`), 'utf-8');
|
||||||
|
const escaped = svg.replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
||||||
|
return ` '${name}': \`${escaped}\`,`;
|
||||||
|
})
|
||||||
|
.join('\n')}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const iconList = Object.keys(icons);
|
||||||
|
|
||||||
|
export function getIcon(name: string): string {
|
||||||
|
return icons[name.toLowerCase()] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasIcon(name: string): boolean {
|
||||||
|
return name.toLowerCase() in icons;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(OUTPUT_DIR, 'index.ts'), indexContent);
|
||||||
|
console.log('✓ Generated: src/icons/extracted/index.ts');
|
||||||
|
|
||||||
|
// 生成图标元数据
|
||||||
|
const metadataContent = `// Icon metadata for search and categorization
|
||||||
|
import { IconMetadata } from '@/types/icon';
|
||||||
|
|
||||||
|
export const iconMetadata: Record<string, IconMetadata> = {
|
||||||
|
// AI Providers
|
||||||
|
openai: { name: 'openai', displayName: 'OpenAI', category: 'ai-provider', keywords: ['gpt', 'chatgpt'], defaultColor: '#00A67E' },
|
||||||
|
anthropic: { name: 'anthropic', displayName: 'Anthropic', category: 'ai-provider', keywords: ['claude'], defaultColor: '#D4915D' },
|
||||||
|
claude: { name: 'claude', displayName: 'Claude', category: 'ai-provider', keywords: ['anthropic'], defaultColor: '#D4915D' },
|
||||||
|
google: { name: 'google', displayName: 'Google', category: 'ai-provider', keywords: ['gemini', 'bard'], defaultColor: '#4285F4' },
|
||||||
|
gemini: { name: 'gemini', displayName: 'Gemini', category: 'ai-provider', keywords: ['google'], defaultColor: '#4285F4' },
|
||||||
|
deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' },
|
||||||
|
moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' },
|
||||||
|
kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' },
|
||||||
|
zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' },
|
||||||
|
minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' },
|
||||||
|
baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' },
|
||||||
|
alibaba: { name: 'alibaba', displayName: 'Alibaba', category: 'ai-provider', keywords: ['qwen', 'tongyi'], defaultColor: '#FF6A00' },
|
||||||
|
tencent: { name: 'tencent', displayName: 'Tencent', category: 'ai-provider', keywords: ['hunyuan'], defaultColor: '#00A4FF' },
|
||||||
|
meta: { name: 'meta', displayName: 'Meta', category: 'ai-provider', keywords: ['facebook', 'llama'], defaultColor: '#0081FB' },
|
||||||
|
microsoft: { name: 'microsoft', displayName: 'Microsoft', category: 'ai-provider', keywords: ['copilot', 'azure'], defaultColor: '#00A4EF' },
|
||||||
|
cohere: { name: 'cohere', displayName: 'Cohere', category: 'ai-provider', keywords: ['cohere'], defaultColor: '#39594D' },
|
||||||
|
perplexity: { name: 'perplexity', displayName: 'Perplexity', category: 'ai-provider', keywords: ['perplexity'], defaultColor: '#20808D' },
|
||||||
|
mistral: { name: 'mistral', displayName: 'Mistral', category: 'ai-provider', keywords: ['mistral'], defaultColor: '#FF7000' },
|
||||||
|
huggingface: { name: 'huggingface', displayName: 'Hugging Face', category: 'ai-provider', keywords: ['huggingface', 'hf'], defaultColor: '#FFD21E' },
|
||||||
|
|
||||||
|
// Cloud Platforms
|
||||||
|
aws: { name: 'aws', displayName: 'AWS', category: 'cloud', keywords: ['amazon', 'cloud'], defaultColor: '#FF9900' },
|
||||||
|
azure: { name: 'azure', displayName: 'Azure', category: 'cloud', keywords: ['microsoft', 'cloud'], defaultColor: '#0078D4' },
|
||||||
|
huawei: { name: 'huawei', displayName: 'Huawei', category: 'cloud', keywords: ['huawei', 'cloud'], defaultColor: '#FF0000' },
|
||||||
|
cloudflare: { name: 'cloudflare', displayName: 'Cloudflare', category: 'cloud', keywords: ['cloudflare', 'cdn'], defaultColor: '#F38020' },
|
||||||
|
|
||||||
|
// Dev Tools
|
||||||
|
github: { name: 'github', displayName: 'GitHub', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#181717' },
|
||||||
|
gitlab: { name: 'gitlab', displayName: 'GitLab', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#FC6D26' },
|
||||||
|
docker: { name: 'docker', displayName: 'Docker', category: 'tool', keywords: ['container'], defaultColor: '#2496ED' },
|
||||||
|
kubernetes: { name: 'kubernetes', displayName: 'Kubernetes', category: 'tool', keywords: ['k8s', 'container'], defaultColor: '#326CE5' },
|
||||||
|
vscode: { name: 'vscode', displayName: 'VS Code', category: 'tool', keywords: ['editor', 'ide'], defaultColor: '#007ACC' },
|
||||||
|
|
||||||
|
// Others
|
||||||
|
settings: { name: 'settings', displayName: 'Settings', category: 'other', keywords: ['config', 'preferences'], defaultColor: '#6B7280' },
|
||||||
|
folder: { name: 'folder', displayName: 'Folder', category: 'other', keywords: ['directory'], defaultColor: '#6B7280' },
|
||||||
|
file: { name: 'file', displayName: 'File', category: 'other', keywords: ['document'], defaultColor: '#6B7280' },
|
||||||
|
link: { name: 'link', displayName: 'Link', category: 'other', keywords: ['url', 'hyperlink'], defaultColor: '#6B7280' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getIconMetadata(name: string): IconMetadata | undefined {
|
||||||
|
return iconMetadata[name.toLowerCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchIcons(query: string): string[] {
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
return Object.values(iconMetadata)
|
||||||
|
.filter(meta =>
|
||||||
|
meta.name.includes(lowerQuery) ||
|
||||||
|
meta.displayName.toLowerCase().includes(lowerQuery) ||
|
||||||
|
meta.keywords.some(k => k.includes(lowerQuery))
|
||||||
|
)
|
||||||
|
.map(meta => meta.name);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(OUTPUT_DIR, 'metadata.ts'), metadataContent);
|
||||||
|
console.log('✓ Generated: src/icons/extracted/metadata.ts');
|
||||||
|
|
||||||
|
// 生成 README
|
||||||
|
const readmeContent = `# Extracted Icons
|
||||||
|
|
||||||
|
This directory contains extracted icons from @lobehub/icons-static-svg.
|
||||||
|
|
||||||
|
## Statistics
|
||||||
|
- Total extracted: ${extracted} icons
|
||||||
|
- Not found: ${notFound.length} icons
|
||||||
|
|
||||||
|
## Extracted Icons
|
||||||
|
${ALL_ICONS.filter(name => !notFound.includes(name)).map(name => `- ${name}`).join('\n')}
|
||||||
|
|
||||||
|
${notFound.length > 0 ? `\n## Not Found\n${notFound.map(name => `- ${name}`).join('\n')}` : ''}
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
import { getIcon, hasIcon, iconList } from './extracted';
|
||||||
|
|
||||||
|
// Get icon SVG
|
||||||
|
const svg = getIcon('openai');
|
||||||
|
|
||||||
|
// Check if icon exists
|
||||||
|
if (hasIcon('openai')) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all available icons
|
||||||
|
console.log(iconList);
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
---
|
||||||
|
Last updated: ${new Date().toISOString()}
|
||||||
|
Generated by: scripts/extract-icons.js
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(OUTPUT_DIR, 'README.md'), readmeContent);
|
||||||
|
console.log('✓ Generated: src/icons/extracted/README.md');
|
||||||
|
|
||||||
|
console.log('\n========================================');
|
||||||
|
console.log('✅ Extraction complete!\n');
|
||||||
|
console.log(` ✓ Extracted: ${extracted} icons`);
|
||||||
|
console.log(` ✗ Not found: ${notFound.length} icons`);
|
||||||
|
console.log(` 📉 Bundle size reduction: ~${Math.round((1 - extracted / 723) * 100)}%`);
|
||||||
|
console.log('========================================\n');
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ICONS_DIR = path.join(__dirname, '../src/icons/extracted');
|
||||||
|
|
||||||
|
// List of "Famous" icons to keep
|
||||||
|
// Based on common AI providers and tools
|
||||||
|
const KEEP_LIST = [
|
||||||
|
// AI Providers
|
||||||
|
'openai', 'anthropic', 'claude', 'google', 'gemini', 'gemma', 'palm',
|
||||||
|
'microsoft', 'azure', 'copilot', 'meta', 'llama',
|
||||||
|
'alibaba', 'qwen', 'tencent', 'hunyuan', 'baidu', 'wenxin',
|
||||||
|
'bytedance', 'doubao', 'deepseek', 'moonshot', 'kimi',
|
||||||
|
'zhipu', 'chatglm', 'glm', 'minimax', 'mistral', 'cohere',
|
||||||
|
'perplexity', 'huggingface', 'midjourney', 'stability',
|
||||||
|
'xai', 'grok', 'yi', 'zeroone', 'ollama',
|
||||||
|
'packycode',
|
||||||
|
|
||||||
|
// Cloud/Tools
|
||||||
|
'aws', 'googlecloud', 'huawei', 'cloudflare',
|
||||||
|
'github', 'githubcopilot', 'vercel', 'notion', 'discord',
|
||||||
|
'gitlab', 'docker', 'kubernetes', 'vscode', 'settings', 'folder', 'file', 'link'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get all SVG files
|
||||||
|
const files = fs.readdirSync(ICONS_DIR).filter(file => file.endsWith('.svg'));
|
||||||
|
|
||||||
|
console.log(`Scanning ${files.length} files...`);
|
||||||
|
|
||||||
|
let keptCount = 0;
|
||||||
|
let deletedCount = 0;
|
||||||
|
let renamedCount = 0;
|
||||||
|
|
||||||
|
// First pass: Identify files to keep and prefer color versions
|
||||||
|
const fileMap = {}; // name -> { hasColor: bool, hasMono: bool }
|
||||||
|
|
||||||
|
files.forEach(file => {
|
||||||
|
const isColor = file.endsWith('-color.svg');
|
||||||
|
const baseName = isColor ? file.replace('-color.svg', '') : file.replace('.svg', '');
|
||||||
|
|
||||||
|
if (!fileMap[baseName]) {
|
||||||
|
fileMap[baseName] = { hasColor: false, hasMono: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isColor) {
|
||||||
|
fileMap[baseName].hasColor = true;
|
||||||
|
} else {
|
||||||
|
fileMap[baseName].hasMono = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second pass: Process files
|
||||||
|
Object.keys(fileMap).forEach(baseName => {
|
||||||
|
const info = fileMap[baseName];
|
||||||
|
const shouldKeep = KEEP_LIST.includes(baseName);
|
||||||
|
|
||||||
|
if (!shouldKeep) {
|
||||||
|
// Delete both versions if not in keep list
|
||||||
|
if (info.hasColor) {
|
||||||
|
fs.unlinkSync(path.join(ICONS_DIR, `${baseName}-color.svg`));
|
||||||
|
deletedCount++;
|
||||||
|
}
|
||||||
|
if (info.hasMono) {
|
||||||
|
fs.unlinkSync(path.join(ICONS_DIR, `${baseName}.svg`));
|
||||||
|
deletedCount++;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If keeping, prefer color
|
||||||
|
if (info.hasColor) {
|
||||||
|
// Rename color version to base version (overwrite mono if exists)
|
||||||
|
const colorPath = path.join(ICONS_DIR, `${baseName}-color.svg`);
|
||||||
|
const targetPath = path.join(ICONS_DIR, `${baseName}.svg`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// If mono exists, it will be overwritten/replaced
|
||||||
|
fs.renameSync(colorPath, targetPath);
|
||||||
|
renamedCount++;
|
||||||
|
keptCount++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error renaming ${baseName}:`, e);
|
||||||
|
}
|
||||||
|
} else if (info.hasMono) {
|
||||||
|
// Keep mono if no color version
|
||||||
|
keptCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\nCleanup complete:`);
|
||||||
|
console.log(`- Kept: ${keptCount}`);
|
||||||
|
console.log(`- Deleted: ${deletedCount}`);
|
||||||
|
console.log(`- Renamed (Color -> Standard): ${renamedCount}`);
|
||||||
|
|
||||||
|
// Regenerate index and metadata
|
||||||
|
require('./generate-icon-index.js');
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ICONS_DIR = path.join(__dirname, '../src/icons/extracted');
|
||||||
|
const INDEX_FILE = path.join(ICONS_DIR, 'index.ts');
|
||||||
|
const METADATA_FILE = path.join(ICONS_DIR, 'metadata.ts');
|
||||||
|
|
||||||
|
// Known metadata from previous configuration
|
||||||
|
const KNOWN_METADATA = {
|
||||||
|
openai: { name: 'openai', displayName: 'OpenAI', category: 'ai-provider', keywords: ['gpt', 'chatgpt'], defaultColor: '#00A67E' },
|
||||||
|
anthropic: { name: 'anthropic', displayName: 'Anthropic', category: 'ai-provider', keywords: ['claude'], defaultColor: '#D4915D' },
|
||||||
|
claude: { name: 'claude', displayName: 'Claude', category: 'ai-provider', keywords: ['anthropic'], defaultColor: '#D4915D' },
|
||||||
|
google: { name: 'google', displayName: 'Google', category: 'ai-provider', keywords: ['gemini', 'bard'], defaultColor: '#4285F4' },
|
||||||
|
gemini: { name: 'gemini', displayName: 'Gemini', category: 'ai-provider', keywords: ['google'], defaultColor: '#4285F4' },
|
||||||
|
deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' },
|
||||||
|
moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' },
|
||||||
|
kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' },
|
||||||
|
zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' },
|
||||||
|
minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' },
|
||||||
|
baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' },
|
||||||
|
alibaba: { name: 'alibaba', displayName: 'Alibaba', category: 'ai-provider', keywords: ['qwen', 'tongyi'], defaultColor: '#FF6A00' },
|
||||||
|
tencent: { name: 'tencent', displayName: 'Tencent', category: 'ai-provider', keywords: ['hunyuan'], defaultColor: '#00A4FF' },
|
||||||
|
meta: { name: 'meta', displayName: 'Meta', category: 'ai-provider', keywords: ['facebook', 'llama'], defaultColor: '#0081FB' },
|
||||||
|
microsoft: { name: 'microsoft', displayName: 'Microsoft', category: 'ai-provider', keywords: ['copilot', 'azure'], defaultColor: '#00A4EF' },
|
||||||
|
cohere: { name: 'cohere', displayName: 'Cohere', category: 'ai-provider', keywords: ['cohere'], defaultColor: '#39594D' },
|
||||||
|
perplexity: { name: 'perplexity', displayName: 'Perplexity', category: 'ai-provider', keywords: ['perplexity'], defaultColor: '#20808D' },
|
||||||
|
packycode: { name: 'packycode', displayName: 'PackyCode', category: 'ai-provider', keywords: ['packycode', 'packy', 'packyapi'], defaultColor: 'currentColor' },
|
||||||
|
mistral: { name: 'mistral', displayName: 'Mistral', category: 'ai-provider', keywords: ['mistral'], defaultColor: '#FF7000' },
|
||||||
|
huggingface: { name: 'huggingface', displayName: 'Hugging Face', category: 'ai-provider', keywords: ['huggingface', 'hf'], defaultColor: '#FFD21E' },
|
||||||
|
aws: { name: 'aws', displayName: 'AWS', category: 'cloud', keywords: ['amazon', 'cloud'], defaultColor: '#FF9900' },
|
||||||
|
azure: { name: 'azure', displayName: 'Azure', category: 'cloud', keywords: ['microsoft', 'cloud'], defaultColor: '#0078D4' },
|
||||||
|
huawei: { name: 'huawei', displayName: 'Huawei', category: 'cloud', keywords: ['huawei', 'cloud'], defaultColor: '#FF0000' },
|
||||||
|
cloudflare: { name: 'cloudflare', displayName: 'Cloudflare', category: 'cloud', keywords: ['cloudflare', 'cdn'], defaultColor: '#F38020' },
|
||||||
|
github: { name: 'github', displayName: 'GitHub', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#181717' },
|
||||||
|
gitlab: { name: 'gitlab', displayName: 'GitLab', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#FC6D26' },
|
||||||
|
docker: { name: 'docker', displayName: 'Docker', category: 'tool', keywords: ['container'], defaultColor: '#2496ED' },
|
||||||
|
kubernetes: { name: 'kubernetes', displayName: 'Kubernetes', category: 'tool', keywords: ['k8s', 'container'], defaultColor: '#326CE5' },
|
||||||
|
vscode: { name: 'vscode', displayName: 'VS Code', category: 'tool', keywords: ['editor', 'ide'], defaultColor: '#007ACC' },
|
||||||
|
settings: { name: 'settings', displayName: 'Settings', category: 'other', keywords: ['config', 'preferences'], defaultColor: '#6B7280' },
|
||||||
|
folder: { name: 'folder', displayName: 'Folder', category: 'other', keywords: ['directory'], defaultColor: '#6B7280' },
|
||||||
|
file: { name: 'file', displayName: 'File', category: 'other', keywords: ['document'], defaultColor: '#6B7280' },
|
||||||
|
link: { name: 'link', displayName: 'Link', category: 'other', keywords: ['url', 'hyperlink'], defaultColor: '#6B7280' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all SVG files
|
||||||
|
const files = fs.readdirSync(ICONS_DIR).filter(file => file.endsWith('.svg'));
|
||||||
|
|
||||||
|
console.log(`Found ${files.length} SVG files.`);
|
||||||
|
|
||||||
|
// Generate index.ts
|
||||||
|
const indexContent = `// Auto-generated icon index
|
||||||
|
// Do not edit manually
|
||||||
|
|
||||||
|
export const icons: Record<string, string> = {
|
||||||
|
${files.map(file => {
|
||||||
|
const name = path.basename(file, '.svg');
|
||||||
|
const svg = fs.readFileSync(path.join(ICONS_DIR, file), 'utf-8');
|
||||||
|
const escaped = svg.replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
||||||
|
return ` '${name}': \`${escaped}\`,`;
|
||||||
|
}).join('\n')}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const iconList = Object.keys(icons);
|
||||||
|
|
||||||
|
export function getIcon(name: string): string {
|
||||||
|
return icons[name.toLowerCase()] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasIcon(name: string): boolean {
|
||||||
|
return name.toLowerCase() in icons;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync(INDEX_FILE, indexContent);
|
||||||
|
console.log(`Generated ${INDEX_FILE}`);
|
||||||
|
|
||||||
|
// Generate metadata.ts
|
||||||
|
const metadataEntries = files.map(file => {
|
||||||
|
const name = path.basename(file, '.svg').toLowerCase();
|
||||||
|
const known = KNOWN_METADATA[name];
|
||||||
|
|
||||||
|
if (known) {
|
||||||
|
return ` ${name}: ${JSON.stringify(known)},`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default metadata for unknown icons
|
||||||
|
return ` '${name}': { name: '${name}', displayName: '${name}', category: 'other', keywords: [], defaultColor: 'currentColor' },`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadataContent = `// Icon metadata for search and categorization
|
||||||
|
import { IconMetadata } from '@/types/icon';
|
||||||
|
|
||||||
|
export const iconMetadata: Record<string, IconMetadata> = {
|
||||||
|
${metadataEntries.join('\n')}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getIconMetadata(name: string): IconMetadata | undefined {
|
||||||
|
return iconMetadata[name.toLowerCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchIcons(query: string): string[] {
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
return Object.values(iconMetadata)
|
||||||
|
.filter(meta =>
|
||||||
|
meta.name.includes(lowerQuery) ||
|
||||||
|
meta.displayName.toLowerCase().includes(lowerQuery) ||
|
||||||
|
meta.keywords.some(k => k.includes(lowerQuery))
|
||||||
|
)
|
||||||
|
.map(meta => meta.name);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync(METADATA_FILE, metadataContent);
|
||||||
|
console.log(`Generated ${METADATA_FILE}`);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.6.2"
|
version = "3.9.0-3"
|
||||||
description = "Claude Code & Codex 供应商配置管理工具"
|
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||||
authors = ["Jason Young"]
|
authors = ["Jason Young"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/farion1231/cc-switch"
|
repository = "https://github.com/farion1231/cc-switch"
|
||||||
@@ -25,27 +25,49 @@ tauri-build = { version = "2.4.0", features = [] }
|
|||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
chrono = "0.4"
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
tauri = { version = "2.8.2", features = ["tray-icon"] }
|
tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset", "image-png"] }
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-store = "2"
|
tauri-plugin-store = "2"
|
||||||
|
tauri-plugin-deep-link = "2"
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
toml_edit = "0.22"
|
toml_edit = "0.22"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream"] }
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
async-stream = "0.3"
|
||||||
|
bytes = "1.5"
|
||||||
|
axum = "0.7"
|
||||||
|
tower = "0.4"
|
||||||
|
tower-http = { version = "0.5", features = ["cors"] }
|
||||||
|
hyper = { version = "1.0", features = ["full"] }
|
||||||
regex = "1.10"
|
regex = "1.10"
|
||||||
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
|
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
|
||||||
thiserror = "1.0"
|
thiserror = "2.0"
|
||||||
|
anyhow = "1.0"
|
||||||
|
zip = "2.2"
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
tempfile = "3"
|
||||||
|
url = "2.5"
|
||||||
|
auto-launch = "0.5"
|
||||||
|
once_cell = "1.21.3"
|
||||||
|
base64 = "0.22"
|
||||||
|
rusqlite = { version = "0.31", features = ["bundled", "backup"] }
|
||||||
|
indexmap = { version = "2", features = ["serde"] }
|
||||||
|
rust_decimal = "1.33"
|
||||||
|
uuid = { version = "1.11", features = ["v4"] }
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
winreg = "0.52"
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
objc2 = "0.5"
|
objc2 = "0.5"
|
||||||
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
|
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
|
||||||
@@ -57,3 +79,7 @@ lto = "thin"
|
|||||||
opt-level = "s"
|
opt-level = "s"
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
strip = "symbols"
|
strip = "symbols"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serial_test = "3"
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- 注册 ccswitch:// 自定义 URL 协议,用于深链接导入 -->
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>CC Switch Deep Link</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>ccswitch</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
"opener:default",
|
"opener:default",
|
||||||
"updater:default",
|
"updater:default",
|
||||||
"core:window:allow-set-skip-taskbar",
|
"core:window:allow-set-skip-taskbar",
|
||||||
|
"core:window:allow-start-dragging",
|
||||||
"process:allow-restart",
|
"process:allow-restart",
|
||||||
"dialog:default"
|
"dialog:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -2,7 +2,181 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
/// MCP 配置:单客户端维度(claude 或 codex 下的一组服务器)
|
use crate::services::skill::SkillStore;
|
||||||
|
|
||||||
|
/// MCP 服务器应用状态(标记应用到哪些客户端)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
pub struct McpApps {
|
||||||
|
#[serde(default)]
|
||||||
|
pub claude: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub codex: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gemini: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl McpApps {
|
||||||
|
/// 检查指定应用是否启用
|
||||||
|
pub fn is_enabled_for(&self, app: &AppType) -> bool {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => self.claude,
|
||||||
|
AppType::Codex => self.codex,
|
||||||
|
AppType::Gemini => self.gemini,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置指定应用的启用状态
|
||||||
|
pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => self.claude = enabled,
|
||||||
|
AppType::Codex => self.codex = enabled,
|
||||||
|
AppType::Gemini => self.gemini = enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取所有启用的应用列表
|
||||||
|
pub fn enabled_apps(&self) -> Vec<AppType> {
|
||||||
|
let mut apps = Vec::new();
|
||||||
|
if self.claude {
|
||||||
|
apps.push(AppType::Claude);
|
||||||
|
}
|
||||||
|
if self.codex {
|
||||||
|
apps.push(AppType::Codex);
|
||||||
|
}
|
||||||
|
if self.gemini {
|
||||||
|
apps.push(AppType::Gemini);
|
||||||
|
}
|
||||||
|
apps
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否所有应用都未启用
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
!self.claude && !self.codex && !self.gemini
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skill 应用启用状态(标记 Skill 应用到哪些客户端)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
pub struct SkillApps {
|
||||||
|
#[serde(default)]
|
||||||
|
pub claude: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub codex: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gemini: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SkillApps {
|
||||||
|
/// 检查指定应用是否启用
|
||||||
|
pub fn is_enabled_for(&self, app: &AppType) -> bool {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => self.claude,
|
||||||
|
AppType::Codex => self.codex,
|
||||||
|
AppType::Gemini => self.gemini,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置指定应用的启用状态
|
||||||
|
pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => self.claude = enabled,
|
||||||
|
AppType::Codex => self.codex = enabled,
|
||||||
|
AppType::Gemini => self.gemini = enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取所有启用的应用列表
|
||||||
|
pub fn enabled_apps(&self) -> Vec<AppType> {
|
||||||
|
let mut apps = Vec::new();
|
||||||
|
if self.claude {
|
||||||
|
apps.push(AppType::Claude);
|
||||||
|
}
|
||||||
|
if self.codex {
|
||||||
|
apps.push(AppType::Codex);
|
||||||
|
}
|
||||||
|
if self.gemini {
|
||||||
|
apps.push(AppType::Gemini);
|
||||||
|
}
|
||||||
|
apps
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否所有应用都未启用
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
!self.claude && !self.codex && !self.gemini
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仅启用指定应用(其他应用设为禁用)
|
||||||
|
pub fn only(app: &AppType) -> Self {
|
||||||
|
let mut apps = Self::default();
|
||||||
|
apps.set_enabled_for(app, true);
|
||||||
|
apps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已安装的 Skill(v3.10.0+ 统一结构)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct InstalledSkill {
|
||||||
|
/// 唯一标识符(格式:"owner/repo:directory" 或 "local:directory")
|
||||||
|
pub id: String,
|
||||||
|
/// 显示名称
|
||||||
|
pub name: String,
|
||||||
|
/// 描述
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// 安装目录名(在 SSOT 目录中的子目录名)
|
||||||
|
pub directory: String,
|
||||||
|
/// 仓库所有者(GitHub 用户/组织)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub repo_owner: Option<String>,
|
||||||
|
/// 仓库名称
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub repo_name: Option<String>,
|
||||||
|
/// 仓库分支
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub repo_branch: Option<String>,
|
||||||
|
/// README URL
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub readme_url: Option<String>,
|
||||||
|
/// 应用启用状态
|
||||||
|
pub apps: SkillApps,
|
||||||
|
/// 安装时间(Unix 时间戳)
|
||||||
|
pub installed_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 未管理的 Skill(在应用目录中发现但未被 CC Switch 管理)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UnmanagedSkill {
|
||||||
|
/// 目录名
|
||||||
|
pub directory: String,
|
||||||
|
/// 显示名称(从 SKILL.md 解析)
|
||||||
|
pub name: String,
|
||||||
|
/// 描述
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// 在哪些应用目录中发现(如 ["claude", "codex"])
|
||||||
|
pub found_in: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MCP 服务器定义(v3.7.0 统一结构)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct McpServer {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub server: serde_json::Value,
|
||||||
|
pub apps: McpApps,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub homepage: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub docs: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MCP 配置:单客户端维度(v3.6.x 及以前,保留用于向后兼容)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct McpConfig {
|
pub struct McpConfig {
|
||||||
/// 以 id 为键的服务器定义(宽松 JSON 对象,包含 enabled/source 等 UI 辅助字段)
|
/// 以 id 为键的服务器定义(宽松 JSON 对象,包含 enabled/source 等 UI 辅助字段)
|
||||||
@@ -10,25 +184,72 @@ pub struct McpConfig {
|
|||||||
pub servers: HashMap<String, serde_json::Value>,
|
pub servers: HashMap<String, serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MCP 根:按客户端分开维护(无历史兼容压力,直接以 v2 结构落地)
|
impl McpConfig {
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
/// 检查配置是否为空
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.servers.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MCP 根配置(v3.7.0 新旧结构并存)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct McpRoot {
|
pub struct McpRoot {
|
||||||
#[serde(default)]
|
/// 统一的 MCP 服务器存储(v3.7.0+)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub servers: Option<HashMap<String, McpServer>>,
|
||||||
|
|
||||||
|
/// 旧的分应用存储(v3.6.x 及以前,保留用于迁移)
|
||||||
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
pub claude: McpConfig,
|
pub claude: McpConfig,
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
pub codex: McpConfig,
|
pub codex: McpConfig,
|
||||||
|
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||||
|
pub gemini: McpConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for McpRoot {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
// v3.7.0+ 默认使用新的统一结构(空 HashMap)
|
||||||
|
servers: Some(HashMap::new()),
|
||||||
|
// 旧结构保持空,仅用于反序列化旧配置时的迁移
|
||||||
|
claude: McpConfig::default(),
|
||||||
|
codex: McpConfig::default(),
|
||||||
|
gemini: McpConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt 配置:单客户端维度
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct PromptConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub prompts: HashMap<String, crate::prompt::Prompt>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt 根:按客户端分开维护
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct PromptRoot {
|
||||||
|
#[serde(default)]
|
||||||
|
pub claude: PromptConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub codex: PromptConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gemini: PromptConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
use crate::prompt_files::prompt_file_path;
|
||||||
use crate::provider::ProviderManager;
|
use crate::provider::ProviderManager;
|
||||||
|
|
||||||
/// 应用类型
|
/// 应用类型
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum AppType {
|
pub enum AppType {
|
||||||
Claude,
|
Claude,
|
||||||
Codex,
|
Codex,
|
||||||
|
Gemini, // 新增
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppType {
|
impl AppType {
|
||||||
@@ -36,6 +257,7 @@ impl AppType {
|
|||||||
match self {
|
match self {
|
||||||
AppType::Claude => "claude",
|
AppType::Claude => "claude",
|
||||||
AppType::Codex => "codex",
|
AppType::Codex => "codex",
|
||||||
|
AppType::Gemini => "gemini", // 新增
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,15 +270,49 @@ impl FromStr for AppType {
|
|||||||
match normalized.as_str() {
|
match normalized.as_str() {
|
||||||
"claude" => Ok(AppType::Claude),
|
"claude" => Ok(AppType::Claude),
|
||||||
"codex" => Ok(AppType::Codex),
|
"codex" => Ok(AppType::Codex),
|
||||||
|
"gemini" => Ok(AppType::Gemini), // 新增
|
||||||
other => Err(AppError::localized(
|
other => Err(AppError::localized(
|
||||||
"unsupported_app",
|
"unsupported_app",
|
||||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex。"),
|
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini。"),
|
||||||
format!("Unsupported app id: '{other}'. Allowed: claude, codex."),
|
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini."),
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 通用配置片段(按应用分治)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct CommonConfigSnippets {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub claude: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub codex: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gemini: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonConfigSnippets {
|
||||||
|
/// 获取指定应用的通用配置片段
|
||||||
|
pub fn get(&self, app: &AppType) -> Option<&String> {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => self.claude.as_ref(),
|
||||||
|
AppType::Codex => self.codex.as_ref(),
|
||||||
|
AppType::Gemini => self.gemini.as_ref(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置指定应用的通用配置片段
|
||||||
|
pub fn set(&mut self, app: &AppType, snippet: Option<String>) {
|
||||||
|
match app {
|
||||||
|
AppType::Claude => self.claude = snippet,
|
||||||
|
AppType::Codex => self.codex = snippet,
|
||||||
|
AppType::Gemini => self.gemini = snippet,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 多应用配置结构(向后兼容)
|
/// 多应用配置结构(向后兼容)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MultiAppConfig {
|
pub struct MultiAppConfig {
|
||||||
@@ -68,6 +324,18 @@ pub struct MultiAppConfig {
|
|||||||
/// MCP 配置(按客户端分治)
|
/// MCP 配置(按客户端分治)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mcp: McpRoot,
|
pub mcp: McpRoot,
|
||||||
|
/// Prompt 配置(按客户端分治)
|
||||||
|
#[serde(default)]
|
||||||
|
pub prompts: PromptRoot,
|
||||||
|
/// Claude Skills 配置
|
||||||
|
#[serde(default)]
|
||||||
|
pub skills: SkillStore,
|
||||||
|
/// 通用配置片段(按应用分治)
|
||||||
|
#[serde(default)]
|
||||||
|
pub common_config_snippets: CommonConfigSnippets,
|
||||||
|
/// Claude 通用配置片段(旧字段,用于向后兼容迁移)
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub claude_common_config_snippet: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_version() -> u32 {
|
fn default_version() -> u32 {
|
||||||
@@ -79,11 +347,16 @@ impl Default for MultiAppConfig {
|
|||||||
let mut apps = HashMap::new();
|
let mut apps = HashMap::new();
|
||||||
apps.insert("claude".to_string(), ProviderManager::default());
|
apps.insert("claude".to_string(), ProviderManager::default());
|
||||||
apps.insert("codex".to_string(), ProviderManager::default());
|
apps.insert("codex".to_string(), ProviderManager::default());
|
||||||
|
apps.insert("gemini".to_string(), ProviderManager::default()); // 新增
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
version: 2,
|
version: 2,
|
||||||
apps,
|
apps,
|
||||||
mcp: McpRoot::default(),
|
mcp: McpRoot::default(),
|
||||||
|
prompts: PromptRoot::default(),
|
||||||
|
skills: SkillStore::default(),
|
||||||
|
common_config_snippets: CommonConfigSnippets::default(),
|
||||||
|
claude_common_config_snippet: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,8 +367,12 @@ impl MultiAppConfig {
|
|||||||
let config_path = get_app_config_path();
|
let config_path = get_app_config_path();
|
||||||
|
|
||||||
if !config_path.exists() {
|
if !config_path.exists() {
|
||||||
log::info!("配置文件不存在,创建新的多应用配置");
|
log::info!("配置文件不存在,创建新的多应用配置并自动导入提示词");
|
||||||
return Ok(Self::default());
|
// 使用新的方法,支持自动导入提示词
|
||||||
|
let config = Self::default_with_auto_import()?;
|
||||||
|
// 立即保存到磁盘
|
||||||
|
config.save()?;
|
||||||
|
return Ok(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试读取文件
|
// 尝试读取文件
|
||||||
@@ -121,8 +398,73 @@ impl MultiAppConfig {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let has_skills_in_config = value
|
||||||
|
.as_object()
|
||||||
|
.is_some_and(|map| map.contains_key("skills"));
|
||||||
|
|
||||||
// 解析 v2 结构
|
// 解析 v2 结构
|
||||||
serde_json::from_value::<Self>(value).map_err(|e| AppError::json(&config_path, e))
|
let mut config: Self =
|
||||||
|
serde_json::from_value(value).map_err(|e| AppError::json(&config_path, e))?;
|
||||||
|
let mut updated = false;
|
||||||
|
|
||||||
|
if !has_skills_in_config {
|
||||||
|
let skills_path = get_app_config_dir().join("skills.json");
|
||||||
|
if skills_path.exists() {
|
||||||
|
match std::fs::read_to_string(&skills_path) {
|
||||||
|
Ok(content) => match serde_json::from_str::<SkillStore>(&content) {
|
||||||
|
Ok(store) => {
|
||||||
|
config.skills = store;
|
||||||
|
updated = true;
|
||||||
|
log::info!("已从旧版 skills.json 导入 Claude Skills 配置");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("解析旧版 skills.json 失败: {e}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("读取旧版 skills.json 失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 gemini 应用存在(兼容旧配置文件)
|
||||||
|
if !config.apps.contains_key("gemini") {
|
||||||
|
config
|
||||||
|
.apps
|
||||||
|
.insert("gemini".to_string(), ProviderManager::default());
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行 MCP 迁移(v3.6.x → v3.7.0)
|
||||||
|
let migrated = config.migrate_mcp_to_unified()?;
|
||||||
|
if migrated {
|
||||||
|
log::info!("MCP 配置已迁移到 v3.7.0 统一结构,保存配置...");
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对于已经存在的配置文件,如果此前版本还没有 Prompt 功能,
|
||||||
|
// 且 prompts 仍然是空的,则尝试自动导入现有提示词文件。
|
||||||
|
let imported_prompts = config.maybe_auto_import_prompts_for_existing_config()?;
|
||||||
|
if imported_prompts {
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 迁移通用配置片段:claude_common_config_snippet → common_config_snippets.claude
|
||||||
|
if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() {
|
||||||
|
log::info!(
|
||||||
|
"迁移通用配置:claude_common_config_snippet → common_config_snippets.claude"
|
||||||
|
);
|
||||||
|
config.common_config_snippets.claude = Some(old_claude_snippet);
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if updated {
|
||||||
|
log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置...");
|
||||||
|
config.save()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存配置到文件
|
/// 保存配置到文件
|
||||||
@@ -132,7 +474,7 @@ impl MultiAppConfig {
|
|||||||
if config_path.exists() {
|
if config_path.exists() {
|
||||||
let backup_path = get_app_config_dir().join("config.json.bak");
|
let backup_path = get_app_config_dir().join("config.json.bak");
|
||||||
if let Err(e) = copy_file(&config_path, &backup_path) {
|
if let Err(e) = copy_file(&config_path, &backup_path) {
|
||||||
log::warn!("备份 config.json 到 .bak 失败: {}", e);
|
log::warn!("备份 config.json 到 .bak 失败: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +505,7 @@ impl MultiAppConfig {
|
|||||||
match app {
|
match app {
|
||||||
AppType::Claude => &self.mcp.claude,
|
AppType::Claude => &self.mcp.claude,
|
||||||
AppType::Codex => &self.mcp.codex,
|
AppType::Codex => &self.mcp.codex,
|
||||||
|
AppType::Gemini => &self.mcp.gemini,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +514,454 @@ impl MultiAppConfig {
|
|||||||
match app {
|
match app {
|
||||||
AppType::Claude => &mut self.mcp.claude,
|
AppType::Claude => &mut self.mcp.claude,
|
||||||
AppType::Codex => &mut self.mcp.codex,
|
AppType::Codex => &mut self.mcp.codex,
|
||||||
|
AppType::Gemini => &mut self.mcp.gemini,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 创建默认配置并自动导入已存在的提示词文件
|
||||||
|
fn default_with_auto_import() -> Result<Self, AppError> {
|
||||||
|
log::info!("首次启动,创建默认配置并检测提示词文件");
|
||||||
|
|
||||||
|
let mut config = Self::default();
|
||||||
|
|
||||||
|
// 为每个应用尝试自动导入提示词
|
||||||
|
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)?;
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已存在配置文件时的 Prompt 自动导入逻辑
|
||||||
|
///
|
||||||
|
/// 适用于「老版本已经生成过 config.json,但当时还没有 Prompt 功能」的升级场景。
|
||||||
|
/// 判定规则:
|
||||||
|
/// - 仅当所有应用的 prompts 都为空时才尝试导入(避免打扰已经在使用 Prompt 功能的用户)
|
||||||
|
/// - 每个应用最多导入一次,对应各自的提示词文件(如 CLAUDE.md/AGENTS.md/GEMINI.md)
|
||||||
|
///
|
||||||
|
/// 返回值:
|
||||||
|
/// - Ok(true) 表示至少有一个应用成功导入了提示词
|
||||||
|
/// - Ok(false) 表示无需导入或未导入任何内容
|
||||||
|
fn maybe_auto_import_prompts_for_existing_config(&mut self) -> Result<bool, AppError> {
|
||||||
|
// 如果任一应用已经有提示词配置,说明用户已经在使用 Prompt 功能,避免再次自动导入
|
||||||
|
if !self.prompts.claude.prompts.is_empty()
|
||||||
|
|| !self.prompts.codex.prompts.is_empty()
|
||||||
|
|| !self.prompts.gemini.prompts.is_empty()
|
||||||
|
{
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
|
||||||
|
|
||||||
|
let mut imported = false;
|
||||||
|
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||||
|
// 复用已有的单应用导入逻辑
|
||||||
|
if Self::auto_import_prompt_if_exists(self, app)? {
|
||||||
|
imported = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(imported)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查并自动导入单个应用的提示词文件
|
||||||
|
///
|
||||||
|
/// 返回值:
|
||||||
|
/// - Ok(true) 表示成功导入了非空文件
|
||||||
|
/// - Ok(false) 表示未导入(文件不存在、内容为空或读取失败)
|
||||||
|
fn auto_import_prompt_if_exists(config: &mut Self, app: AppType) -> Result<bool, AppError> {
|
||||||
|
let file_path = prompt_file_path(&app)?;
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
if !file_path.exists() {
|
||||||
|
log::debug!("提示词文件不存在,跳过自动导入: {file_path:?}");
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取文件内容
|
||||||
|
let content = match std::fs::read_to_string(&file_path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("读取提示词文件失败: {file_path:?}, 错误: {e}");
|
||||||
|
return Ok(false); // 失败时不中断,继续处理其他应用
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查内容是否为空
|
||||||
|
if content.trim().is_empty() {
|
||||||
|
log::debug!("提示词文件内容为空,跳过导入: {file_path:?}");
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("发现提示词文件,自动导入: {file_path:?}");
|
||||||
|
|
||||||
|
// 创建提示词对象
|
||||||
|
let timestamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
log::warn!("Failed to get system time, using 0 as timestamp");
|
||||||
|
0
|
||||||
|
});
|
||||||
|
|
||||||
|
let id = format!("auto-imported-{timestamp}");
|
||||||
|
let prompt = crate::prompt::Prompt {
|
||||||
|
id: id.clone(),
|
||||||
|
name: format!(
|
||||||
|
"Auto-imported Prompt {}",
|
||||||
|
chrono::Local::now().format("%Y-%m-%d %H:%M")
|
||||||
|
),
|
||||||
|
content,
|
||||||
|
description: Some("Automatically imported on first launch".to_string()),
|
||||||
|
enabled: true, // 自动启用
|
||||||
|
created_at: Some(timestamp),
|
||||||
|
updated_at: Some(timestamp),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 插入到对应的应用配置中
|
||||||
|
let prompts = match app {
|
||||||
|
AppType::Claude => &mut config.prompts.claude.prompts,
|
||||||
|
AppType::Codex => &mut config.prompts.codex.prompts,
|
||||||
|
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
||||||
|
};
|
||||||
|
|
||||||
|
prompts.insert(id, prompt);
|
||||||
|
|
||||||
|
log::info!("自动导入完成: {}", app.as_str());
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 v3.6.x 的分应用 MCP 结构迁移到 v3.7.0 的统一结构
|
||||||
|
///
|
||||||
|
/// 迁移策略:
|
||||||
|
/// 1. 检查是否已经迁移(mcp.servers 是否存在)
|
||||||
|
/// 2. 收集所有应用的 MCP,按 ID 去重合并
|
||||||
|
/// 3. 生成统一的 McpServer 结构,标记应用到哪些客户端
|
||||||
|
/// 4. 清空旧的分应用配置
|
||||||
|
pub fn migrate_mcp_to_unified(&mut self) -> Result<bool, AppError> {
|
||||||
|
// 检查是否已经是新结构
|
||||||
|
if self.mcp.servers.is_some() {
|
||||||
|
log::debug!("MCP 配置已是统一结构,跳过迁移");
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("检测到旧版 MCP 配置格式,开始迁移到 v3.7.0 统一结构...");
|
||||||
|
|
||||||
|
let mut unified_servers: HashMap<String, McpServer> = HashMap::new();
|
||||||
|
let mut conflicts = Vec::new();
|
||||||
|
|
||||||
|
// 收集所有应用的 MCP
|
||||||
|
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||||
|
let old_servers = match app {
|
||||||
|
AppType::Claude => &self.mcp.claude.servers,
|
||||||
|
AppType::Codex => &self.mcp.codex.servers,
|
||||||
|
AppType::Gemini => &self.mcp.gemini.servers,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (id, entry) in old_servers {
|
||||||
|
let enabled = entry
|
||||||
|
.get("enabled")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
if let Some(existing) = unified_servers.get_mut(id) {
|
||||||
|
// 该 ID 已存在,合并 apps 字段
|
||||||
|
existing.apps.set_enabled_for(&app, enabled);
|
||||||
|
|
||||||
|
// 检测配置冲突(同 ID 但配置不同)
|
||||||
|
if existing.server != *entry.get("server").unwrap_or(&serde_json::json!({})) {
|
||||||
|
conflicts.push(format!(
|
||||||
|
"MCP '{id}' 在 {} 和之前的应用中配置不同,将使用首次遇到的配置",
|
||||||
|
app.as_str()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 首次遇到该 MCP,创建新条目
|
||||||
|
let name = entry
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(id)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let server = entry
|
||||||
|
.get("server")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(serde_json::json!({}));
|
||||||
|
|
||||||
|
let description = entry
|
||||||
|
.get("description")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let homepage = entry
|
||||||
|
.get("homepage")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let docs = entry
|
||||||
|
.get("docs")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let tags = entry
|
||||||
|
.get("tags")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut apps = McpApps::default();
|
||||||
|
apps.set_enabled_for(&app, enabled);
|
||||||
|
|
||||||
|
unified_servers.insert(
|
||||||
|
id.clone(),
|
||||||
|
McpServer {
|
||||||
|
id: id.clone(),
|
||||||
|
name,
|
||||||
|
server,
|
||||||
|
apps,
|
||||||
|
description,
|
||||||
|
homepage,
|
||||||
|
docs,
|
||||||
|
tags,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录冲突警告
|
||||||
|
if !conflicts.is_empty() {
|
||||||
|
log::warn!("MCP 迁移过程中检测到配置冲突:");
|
||||||
|
for conflict in &conflicts {
|
||||||
|
log::warn!(" - {conflict}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"MCP 迁移完成,共迁移 {} 个服务器{}",
|
||||||
|
unified_servers.len(),
|
||||||
|
if !conflicts.is_empty() {
|
||||||
|
format!("(存在 {} 个冲突)", conflicts.len())
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 替换为新结构
|
||||||
|
self.mcp.servers = Some(unified_servers);
|
||||||
|
|
||||||
|
// 清空旧的分应用配置
|
||||||
|
self.mcp.claude = McpConfig::default();
|
||||||
|
self.mcp.codex = McpConfig::default();
|
||||||
|
self.mcp.gemini = McpConfig::default();
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serial_test::serial;
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
struct TempHome {
|
||||||
|
#[allow(dead_code)] // 字段通过 Drop trait 管理临时目录生命周期
|
||||||
|
dir: TempDir,
|
||||||
|
original_home: Option<String>,
|
||||||
|
original_userprofile: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TempHome {
|
||||||
|
fn new() -> Self {
|
||||||
|
let dir = TempDir::new().expect("failed to create temp home");
|
||||||
|
let original_home = env::var("HOME").ok();
|
||||||
|
let original_userprofile = env::var("USERPROFILE").ok();
|
||||||
|
|
||||||
|
env::set_var("HOME", dir.path());
|
||||||
|
env::set_var("USERPROFILE", dir.path());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
dir,
|
||||||
|
original_home,
|
||||||
|
original_userprofile,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempHome {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
match &self.original_home {
|
||||||
|
Some(value) => env::set_var("HOME", value),
|
||||||
|
None => env::remove_var("HOME"),
|
||||||
|
}
|
||||||
|
|
||||||
|
match &self.original_userprofile {
|
||||||
|
Some(value) => env::set_var("USERPROFILE", value),
|
||||||
|
None => env::remove_var("USERPROFILE"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_prompt_file(app: AppType, content: &str) {
|
||||||
|
let path = crate::prompt_files::prompt_file_path(&app).expect("prompt path");
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).expect("create parent dir");
|
||||||
|
}
|
||||||
|
fs::write(path, content).expect("write prompt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn auto_imports_existing_prompt_when_config_missing() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
write_prompt_file(AppType::Claude, "# hello");
|
||||||
|
|
||||||
|
let config = MultiAppConfig::load().expect("load config");
|
||||||
|
|
||||||
|
assert_eq!(config.prompts.claude.prompts.len(), 1);
|
||||||
|
let prompt = config
|
||||||
|
.prompts
|
||||||
|
.claude
|
||||||
|
.prompts
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.expect("prompt exists");
|
||||||
|
assert!(prompt.enabled);
|
||||||
|
assert_eq!(prompt.content, "# hello");
|
||||||
|
|
||||||
|
let config_path = crate::config::get_app_config_path();
|
||||||
|
assert!(
|
||||||
|
config_path.exists(),
|
||||||
|
"auto import should persist config to disk"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn skips_empty_prompt_files_during_import() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
write_prompt_file(AppType::Claude, " \n ");
|
||||||
|
|
||||||
|
let config = MultiAppConfig::load().expect("load config");
|
||||||
|
assert!(
|
||||||
|
config.prompts.claude.prompts.is_empty(),
|
||||||
|
"empty files must be ignored"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn auto_import_happens_only_once() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
write_prompt_file(AppType::Claude, "first version");
|
||||||
|
|
||||||
|
let first = MultiAppConfig::load().expect("load config");
|
||||||
|
assert_eq!(first.prompts.claude.prompts.len(), 1);
|
||||||
|
let claude_prompt = first
|
||||||
|
.prompts
|
||||||
|
.claude
|
||||||
|
.prompts
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.expect("prompt exists")
|
||||||
|
.content
|
||||||
|
.clone();
|
||||||
|
assert_eq!(claude_prompt, "first version");
|
||||||
|
|
||||||
|
// 覆盖文件内容,但保留 config.json
|
||||||
|
write_prompt_file(AppType::Claude, "second version");
|
||||||
|
let second = MultiAppConfig::load().expect("load config again");
|
||||||
|
|
||||||
|
assert_eq!(second.prompts.claude.prompts.len(), 1);
|
||||||
|
let prompt = second
|
||||||
|
.prompts
|
||||||
|
.claude
|
||||||
|
.prompts
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.expect("prompt exists");
|
||||||
|
assert_eq!(
|
||||||
|
prompt.content, "first version",
|
||||||
|
"should not re-import when config already exists"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn auto_imports_gemini_prompt_on_first_launch() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
write_prompt_file(AppType::Gemini, "# Gemini Prompt\n\nTest content");
|
||||||
|
|
||||||
|
let config = MultiAppConfig::load().expect("load config");
|
||||||
|
|
||||||
|
assert_eq!(config.prompts.gemini.prompts.len(), 1);
|
||||||
|
let prompt = config
|
||||||
|
.prompts
|
||||||
|
.gemini
|
||||||
|
.prompts
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.expect("gemini prompt exists");
|
||||||
|
assert!(prompt.enabled, "gemini prompt should be enabled");
|
||||||
|
assert_eq!(prompt.content, "# Gemini Prompt\n\nTest content");
|
||||||
|
assert_eq!(
|
||||||
|
prompt.description,
|
||||||
|
Some("Automatically imported on first launch".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn auto_imports_all_three_apps_prompts() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
write_prompt_file(AppType::Claude, "# Claude prompt");
|
||||||
|
write_prompt_file(AppType::Codex, "# Codex prompt");
|
||||||
|
write_prompt_file(AppType::Gemini, "# Gemini prompt");
|
||||||
|
|
||||||
|
let config = MultiAppConfig::load().expect("load config");
|
||||||
|
|
||||||
|
// 验证所有三个应用的提示词都被导入
|
||||||
|
assert_eq!(config.prompts.claude.prompts.len(), 1);
|
||||||
|
assert_eq!(config.prompts.codex.prompts.len(), 1);
|
||||||
|
assert_eq!(config.prompts.gemini.prompts.len(), 1);
|
||||||
|
|
||||||
|
// 验证所有提示词都被启用
|
||||||
|
assert!(
|
||||||
|
config
|
||||||
|
.prompts
|
||||||
|
.claude
|
||||||
|
.prompts
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.enabled
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
config
|
||||||
|
.prompts
|
||||||
|
.codex
|
||||||
|
.prompts
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.enabled
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
config
|
||||||
|
.prompts
|
||||||
|
.gemini
|
||||||
|
.prompts
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.enabled
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ fn read_override_from_store(app: &tauri::AppHandle) -> Option<PathBuf> {
|
|||||||
let store = match app.store_builder("app_paths.json").build() {
|
let store = match app.store_builder("app_paths.json").build() {
|
||||||
Ok(store) => store,
|
Ok(store) => store,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("无法创建 Store: {}", e);
|
log::warn!("无法创建 Store: {e}");
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -46,21 +46,17 @@ fn read_override_from_store(app: &tauri::AppHandle) -> Option<PathBuf> {
|
|||||||
|
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Store 中配置的 app_config_dir 不存在: {:?}\n\
|
"Store 中配置的 app_config_dir 不存在: {path:?}\n\
|
||||||
将使用默认路径。",
|
将使用默认路径。"
|
||||||
path
|
|
||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("使用 Store 中的 app_config_dir: {:?}", path);
|
log::info!("使用 Store 中的 app_config_dir: {path:?}");
|
||||||
Some(path)
|
Some(path)
|
||||||
}
|
}
|
||||||
Some(_) => {
|
Some(_) => {
|
||||||
log::warn!(
|
log::warn!("Store 中的 {STORE_KEY_APP_CONFIG_DIR} 类型不正确,应为字符串");
|
||||||
"Store 中的 {} 类型不正确,应为字符串",
|
|
||||||
STORE_KEY_APP_CONFIG_DIR
|
|
||||||
);
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
@@ -82,14 +78,14 @@ pub fn set_app_config_dir_to_store(
|
|||||||
let store = app
|
let store = app
|
||||||
.store_builder("app_paths.json")
|
.store_builder("app_paths.json")
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| AppError::Message(format!("创建 Store 失败: {}", e)))?;
|
.map_err(|e| AppError::Message(format!("创建 Store 失败: {e}")))?;
|
||||||
|
|
||||||
match path {
|
match path {
|
||||||
Some(p) => {
|
Some(p) => {
|
||||||
let trimmed = p.trim();
|
let trimmed = p.trim();
|
||||||
if !trimmed.is_empty() {
|
if !trimmed.is_empty() {
|
||||||
store.set(STORE_KEY_APP_CONFIG_DIR, Value::String(trimmed.to_string()));
|
store.set(STORE_KEY_APP_CONFIG_DIR, Value::String(trimmed.to_string()));
|
||||||
log::info!("已将 app_config_dir 写入 Store: {}", trimmed);
|
log::info!("已将 app_config_dir 写入 Store: {trimmed}");
|
||||||
} else {
|
} else {
|
||||||
store.delete(STORE_KEY_APP_CONFIG_DIR);
|
store.delete(STORE_KEY_APP_CONFIG_DIR);
|
||||||
log::info!("已从 Store 中删除 app_config_dir 配置");
|
log::info!("已从 Store 中删除 app_config_dir 配置");
|
||||||
@@ -103,7 +99,7 @@ pub fn set_app_config_dir_to_store(
|
|||||||
|
|
||||||
store
|
store
|
||||||
.save()
|
.save()
|
||||||
.map_err(|e| AppError::Message(format!("保存 Store 失败: {}", e)))?;
|
.map_err(|e| AppError::Message(format!("保存 Store 失败: {e}")))?;
|
||||||
|
|
||||||
refresh_app_config_dir_override(app);
|
refresh_app_config_dir_override(app);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
use crate::error::AppError;
|
||||||
|
use auto_launch::{AutoLaunch, AutoLaunchBuilder};
|
||||||
|
|
||||||
|
/// 获取 macOS 上的 .app bundle 路径
|
||||||
|
/// 将 `/path/to/CC Switch.app/Contents/MacOS/CC Switch` 转换为 `/path/to/CC Switch.app`
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn get_macos_app_bundle_path(exe_path: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||||
|
let path_str = exe_path.to_string_lossy();
|
||||||
|
// 查找 .app/Contents/MacOS/ 模式
|
||||||
|
if let Some(app_pos) = path_str.find(".app/Contents/MacOS/") {
|
||||||
|
let app_bundle_end = app_pos + 4; // ".app" 的结束位置
|
||||||
|
Some(std::path::PathBuf::from(&path_str[..app_bundle_end]))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始化 AutoLaunch 实例
|
||||||
|
fn get_auto_launch() -> Result<AutoLaunch, AppError> {
|
||||||
|
let app_name = "CC Switch";
|
||||||
|
let exe_path =
|
||||||
|
std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?;
|
||||||
|
|
||||||
|
// macOS 需要使用 .app bundle 路径,否则 AppleScript login item 会打开终端
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let app_path = get_macos_app_bundle_path(&exe_path).unwrap_or(exe_path);
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
let app_path = exe_path;
|
||||||
|
|
||||||
|
// 使用 AutoLaunchBuilder 消除平台差异
|
||||||
|
// macOS: 使用 AppleScript 方式(默认),需要 .app bundle 路径
|
||||||
|
// Windows/Linux: 使用注册表/XDG autostart
|
||||||
|
let auto_launch = AutoLaunchBuilder::new()
|
||||||
|
.set_app_name(app_name)
|
||||||
|
.set_app_path(&app_path.to_string_lossy())
|
||||||
|
.build()
|
||||||
|
.map_err(|e| AppError::Message(format!("创建 AutoLaunch 失败: {e}")))?;
|
||||||
|
|
||||||
|
Ok(auto_launch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启用开机自启
|
||||||
|
pub fn enable_auto_launch() -> Result<(), AppError> {
|
||||||
|
let auto_launch = get_auto_launch()?;
|
||||||
|
auto_launch
|
||||||
|
.enable()
|
||||||
|
.map_err(|e| AppError::Message(format!("启用开机自启失败: {e}")))?;
|
||||||
|
log::info!("已启用开机自启");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 禁用开机自启
|
||||||
|
pub fn disable_auto_launch() -> Result<(), AppError> {
|
||||||
|
let auto_launch = get_auto_launch()?;
|
||||||
|
auto_launch
|
||||||
|
.disable()
|
||||||
|
.map_err(|e| AppError::Message(format!("禁用开机自启失败: {e}")))?;
|
||||||
|
log::info!("已禁用开机自启");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否已启用开机自启
|
||||||
|
pub fn is_auto_launch_enabled() -> Result<bool, AppError> {
|
||||||
|
let auto_launch = get_auto_launch()?;
|
||||||
|
auto_launch
|
||||||
|
.is_enabled()
|
||||||
|
.map_err(|e| AppError::Message(format!("检查开机自启状态失败: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[test]
|
||||||
|
fn test_get_macos_app_bundle_path_valid() {
|
||||||
|
let exe_path = std::path::Path::new("/Applications/CC Switch.app/Contents/MacOS/CC Switch");
|
||||||
|
let result = get_macos_app_bundle_path(exe_path);
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Some(std::path::PathBuf::from("/Applications/CC Switch.app"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[test]
|
||||||
|
fn test_get_macos_app_bundle_path_with_spaces() {
|
||||||
|
let exe_path =
|
||||||
|
std::path::Path::new("/Users/test/My Apps/CC Switch.app/Contents/MacOS/CC Switch");
|
||||||
|
let result = get_macos_app_bundle_path(exe_path);
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Some(std::path::PathBuf::from(
|
||||||
|
"/Users/test/My Apps/CC Switch.app"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[test]
|
||||||
|
fn test_get_macos_app_bundle_path_not_in_bundle() {
|
||||||
|
let exe_path = std::path::Path::new("/usr/local/bin/cc-switch");
|
||||||
|
let result = get_macos_app_bundle_path(exe_path);
|
||||||
|
assert_eq!(result, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[test]
|
||||||
|
fn test_get_macos_app_bundle_path_dev_build() {
|
||||||
|
// 开发环境下的路径通常不在 .app bundle 内
|
||||||
|
let exe_path = std::path::Path::new("/Users/dev/project/target/debug/cc-switch");
|
||||||
|
let result = get_macos_app_bundle_path(exe_path);
|
||||||
|
assert_eq!(result, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,64 @@ use std::path::{Path, PathBuf};
|
|||||||
use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path};
|
use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
/// 需要在 Windows 上用 cmd /c 包装的命令
|
||||||
|
/// 这些命令在 Windows 上实际是 .cmd 批处理文件,需要通过 cmd /c 来执行
|
||||||
|
#[cfg(windows)]
|
||||||
|
const WINDOWS_WRAP_COMMANDS: &[&str] = &["npx", "npm", "yarn", "pnpm", "node", "bun", "deno"];
|
||||||
|
|
||||||
|
/// Windows 平台:将 `npx args...` 转换为 `cmd /c npx args...`
|
||||||
|
/// 解决 Claude Code /doctor 报告的 "Windows requires 'cmd /c' wrapper to execute npx" 警告
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn wrap_command_for_windows(obj: &mut Map<String, Value>) {
|
||||||
|
// 只处理 stdio 类型(默认或显式)
|
||||||
|
let server_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
|
||||||
|
if server_type != "stdio" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(cmd) = obj.get("command").and_then(|v| v.as_str()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 已经是 cmd 的不重复包装
|
||||||
|
if cmd.eq_ignore_ascii_case("cmd") || cmd.eq_ignore_ascii_case("cmd.exe") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取命令名(去掉 .cmd 后缀和路径)
|
||||||
|
let cmd_name = Path::new(cmd)
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or(cmd);
|
||||||
|
|
||||||
|
let needs_wrap = WINDOWS_WRAP_COMMANDS
|
||||||
|
.iter()
|
||||||
|
.any(|&c| cmd_name.eq_ignore_ascii_case(c));
|
||||||
|
|
||||||
|
if !needs_wrap {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建新的 args: ["/c", "原命令", ...原args]
|
||||||
|
let original_args = obj
|
||||||
|
.get("args")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut new_args = vec![Value::String("/c".into()), Value::String(cmd.into())];
|
||||||
|
new_args.extend(original_args);
|
||||||
|
|
||||||
|
obj.insert("command".into(), Value::String("cmd".into()));
|
||||||
|
obj.insert("args".into(), Value::Array(new_args));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 非 Windows 平台无需处理
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn wrap_command_for_windows(_obj: &mut Map<String, Value>) {
|
||||||
|
// 非 Windows 平台不做任何处理
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct McpStatus {
|
pub struct McpStatus {
|
||||||
@@ -37,7 +95,7 @@ fn ensure_mcp_override_migrated() {
|
|||||||
|
|
||||||
if let Some(parent) = new_path.parent() {
|
if let Some(parent) = new_path.parent() {
|
||||||
if let Err(err) = fs::create_dir_all(parent) {
|
if let Err(err) = fs::create_dir_all(parent) {
|
||||||
log::warn!("创建 MCP 目录失败: {}", err);
|
log::warn!("创建 MCP 目录失败: {err}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,6 +163,55 @@ pub fn read_mcp_json() -> Result<Option<String>, AppError> {
|
|||||||
Ok(Some(content))
|
Ok(Some(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 在 ~/.claude.json 根对象写入 hasCompletedOnboarding=true(用于跳过 Claude Code 初次安装确认)
|
||||||
|
/// 仅增量写入该字段,其他字段保持不变
|
||||||
|
pub fn set_has_completed_onboarding() -> Result<bool, AppError> {
|
||||||
|
let path = user_config_path();
|
||||||
|
let mut root = if path.exists() {
|
||||||
|
read_json_value(&path)?
|
||||||
|
} else {
|
||||||
|
serde_json::json!({})
|
||||||
|
};
|
||||||
|
|
||||||
|
let obj = root
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?;
|
||||||
|
|
||||||
|
let already = obj
|
||||||
|
.get("hasCompletedOnboarding")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if already {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
obj.insert("hasCompletedOnboarding".into(), Value::Bool(true));
|
||||||
|
write_json_value(&path, &root)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除 ~/.claude.json 根对象的 hasCompletedOnboarding 字段(恢复 Claude Code 初次安装确认)
|
||||||
|
/// 仅增量删除该字段,其他字段保持不变
|
||||||
|
pub fn clear_has_completed_onboarding() -> Result<bool, AppError> {
|
||||||
|
let path = user_config_path();
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut root = read_json_value(&path)?;
|
||||||
|
let obj = root
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?;
|
||||||
|
|
||||||
|
let existed = obj.remove("hasCompletedOnboarding").is_some();
|
||||||
|
if !existed {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
write_json_value(&path, &root)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
|
pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
|
||||||
if id.trim().is_empty() {
|
if id.trim().is_empty() {
|
||||||
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
||||||
@@ -118,9 +225,10 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
|
|||||||
let t_opt = spec.get("type").and_then(|x| x.as_str());
|
let t_opt = spec.get("type").and_then(|x| x.as_str());
|
||||||
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); // 兼容缺省(按 stdio 处理)
|
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); // 兼容缺省(按 stdio 处理)
|
||||||
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
|
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
|
||||||
if !(is_stdio || is_http) {
|
let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false);
|
||||||
|
if !(is_stdio || is_http || is_sse) {
|
||||||
return Err(AppError::McpValidation(
|
return Err(AppError::McpValidation(
|
||||||
"MCP 服务器 type 必须是 'stdio' 或 'http'(或省略表示 stdio)".into(),
|
"MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'(或省略表示 stdio)".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,13 +242,15 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// http 类型必须有 url
|
// http/sse 类型必须有 url
|
||||||
if is_http {
|
if is_http || is_sse {
|
||||||
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
||||||
if url.is_empty() {
|
if url.is_empty() {
|
||||||
return Err(AppError::McpValidation(
|
return Err(AppError::McpValidation(if is_http {
|
||||||
"http 类型的 MCP 服务器缺少 url 字段".into(),
|
"http 类型的 MCP 服务器缺少 url 字段".into()
|
||||||
));
|
} else {
|
||||||
|
"sse 类型的 MCP 服务器缺少 url 字段".into()
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +341,23 @@ pub fn validate_command_in_path(cmd: &str) -> Result<bool, AppError> {
|
|||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 读取 ~/.claude.json 中的 mcpServers 映射
|
||||||
|
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
|
||||||
|
let path = user_config_path();
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(std::collections::HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = read_json_value(&path)?;
|
||||||
|
let servers = root
|
||||||
|
.get("mcpServers")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(servers)
|
||||||
|
}
|
||||||
|
|
||||||
/// 将给定的启用 MCP 服务器映射写入到用户级 ~/.claude.json 的 mcpServers 字段
|
/// 将给定的启用 MCP 服务器映射写入到用户级 ~/.claude.json 的 mcpServers 字段
|
||||||
/// 仅覆盖 mcpServers,其他字段保持不变
|
/// 仅覆盖 mcpServers,其他字段保持不变
|
||||||
pub fn set_mcp_servers_map(
|
pub fn set_mcp_servers_map(
|
||||||
@@ -250,14 +377,13 @@ pub fn set_mcp_servers_map(
|
|||||||
map.clone()
|
map.clone()
|
||||||
} else {
|
} else {
|
||||||
return Err(AppError::McpValidation(format!(
|
return Err(AppError::McpValidation(format!(
|
||||||
"MCP 服务器 '{}' 不是对象",
|
"MCP 服务器 '{id}' 不是对象"
|
||||||
id
|
|
||||||
)));
|
)));
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(server_val) = obj.remove("server") {
|
if let Some(server_val) = obj.remove("server") {
|
||||||
let server_obj = server_val.as_object().cloned().ok_or_else(|| {
|
let server_obj = server_val.as_object().cloned().ok_or_else(|| {
|
||||||
AppError::McpValidation(format!("MCP 服务器 '{}' server 字段不是对象", id))
|
AppError::McpValidation(format!("MCP 服务器 '{id}' server 字段不是对象"))
|
||||||
})?;
|
})?;
|
||||||
obj = server_obj;
|
obj = server_obj;
|
||||||
}
|
}
|
||||||
@@ -271,6 +397,9 @@ pub fn set_mcp_servers_map(
|
|||||||
obj.remove("homepage");
|
obj.remove("homepage");
|
||||||
obj.remove("docs");
|
obj.remove("docs");
|
||||||
|
|
||||||
|
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式
|
||||||
|
wrap_command_for_windows(&mut obj);
|
||||||
|
|
||||||
out.insert(id.clone(), Value::Object(obj));
|
out.insert(id.clone(), Value::Object(obj));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,3 +413,136 @@ pub fn set_mcp_servers_map(
|
|||||||
write_json_value(&path, &root)?;
|
write_json_value(&path, &root)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
/// 测试 Windows 命令包装功能
|
||||||
|
/// 由于使用条件编译,在非 Windows 平台上测试的是空函数
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_command_for_windows_npx() {
|
||||||
|
let mut obj = json!({"command": "npx", "args": ["-y", "@upstash/context7-mcp"]})
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
wrap_command_for_windows(&mut obj);
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
assert_eq!(obj["command"], "cmd");
|
||||||
|
assert_eq!(
|
||||||
|
obj["args"],
|
||||||
|
json!(["/c", "npx", "-y", "@upstash/context7-mcp"])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
// 非 Windows 平台不做任何处理
|
||||||
|
assert_eq!(obj["command"], "npx");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_command_for_windows_npm() {
|
||||||
|
let mut obj = json!({"command": "npm", "args": ["run", "start"]})
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
wrap_command_for_windows(&mut obj);
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
assert_eq!(obj["command"], "cmd");
|
||||||
|
assert_eq!(obj["args"], json!(["/c", "npm", "run", "start"]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_command_for_windows_already_cmd() {
|
||||||
|
// 已经是 cmd 的不应该重复包装
|
||||||
|
let mut obj = json!({"command": "cmd", "args": ["/c", "npx", "-y", "foo"]})
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
wrap_command_for_windows(&mut obj);
|
||||||
|
|
||||||
|
assert_eq!(obj["command"], "cmd");
|
||||||
|
// args 应该保持不变,不会变成 ["/c", "cmd", "/c", "npx", ...]
|
||||||
|
assert_eq!(obj["args"], json!(["/c", "npx", "-y", "foo"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_command_for_windows_http_type_skipped() {
|
||||||
|
// http 类型不应该被处理
|
||||||
|
let mut obj = json!({"type": "http", "url": "https://example.com/mcp"})
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
wrap_command_for_windows(&mut obj);
|
||||||
|
|
||||||
|
assert!(!obj.contains_key("command"));
|
||||||
|
assert_eq!(obj["url"], "https://example.com/mcp");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_command_for_windows_other_command_skipped() {
|
||||||
|
// 非目标命令(如 python)不应该被包装
|
||||||
|
let mut obj = json!({"command": "python", "args": ["server.py"]})
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
wrap_command_for_windows(&mut obj);
|
||||||
|
|
||||||
|
// python 不在 WINDOWS_WRAP_COMMANDS 列表中,不应该被包装
|
||||||
|
assert_eq!(obj["command"], "python");
|
||||||
|
assert_eq!(obj["args"], json!(["server.py"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_command_for_windows_no_args() {
|
||||||
|
// 没有 args 的情况
|
||||||
|
let mut obj = json!({"command": "npx"}).as_object().unwrap().clone();
|
||||||
|
wrap_command_for_windows(&mut obj);
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
assert_eq!(obj["command"], "cmd");
|
||||||
|
assert_eq!(obj["args"], json!(["/c", "npx"]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_command_for_windows_with_cmd_suffix() {
|
||||||
|
// 处理 npx.cmd 格式
|
||||||
|
let mut obj = json!({"command": "npx.cmd", "args": ["-y", "foo"]})
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
wrap_command_for_windows(&mut obj);
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
assert_eq!(obj["command"], "cmd");
|
||||||
|
assert_eq!(obj["args"], json!(["/c", "npx.cmd", "-y", "foo"]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_command_for_windows_case_insensitive() {
|
||||||
|
// 大小写不敏感
|
||||||
|
let mut obj = json!({"command": "NPX", "args": ["-y", "foo"]})
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
wrap_command_for_windows(&mut obj);
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
assert_eq!(obj["command"], "cmd");
|
||||||
|
assert_eq!(obj["args"], json!(["/c", "NPX", "-y", "foo"]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ pub fn write_claude_config() -> Result<bool, AppError> {
|
|||||||
if changed || !path.exists() {
|
if changed || !path.exists() {
|
||||||
let serialized = serde_json::to_string_pretty(&obj)
|
let serialized = serde_json::to_string_pretty(&obj)
|
||||||
.map_err(|e| AppError::JsonSerialize { source: e })?;
|
.map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||||
fs::write(&path, format!("{}\n", serialized)).map_err(|e| AppError::io(&path, e))?;
|
fs::write(&path, format!("{serialized}\n")).map_err(|e| AppError::io(&path, e))?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
} else {
|
} else {
|
||||||
Ok(false)
|
Ok(false)
|
||||||
@@ -114,7 +114,7 @@ pub fn clear_claude_config() -> Result<bool, AppError> {
|
|||||||
|
|
||||||
let serialized =
|
let serialized =
|
||||||
serde_json::to_string_pretty(&value).map_err(|e| AppError::JsonSerialize { source: e })?;
|
serde_json::to_string_pretty(&value).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||||
fs::write(&path, format!("{}\n", serialized)).map_err(|e| AppError::io(&path, e))?;
|
fs::write(&path, format!("{serialized}\n")).map_err(|e| AppError::io(&path, e))?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ pub fn get_codex_config_path() -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 Codex 供应商配置文件路径
|
/// 获取 Codex 供应商配置文件路径
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn get_codex_provider_paths(
|
pub fn get_codex_provider_paths(
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
provider_name: Option<&str>,
|
provider_name: Option<&str>,
|
||||||
@@ -37,13 +38,14 @@ pub fn get_codex_provider_paths(
|
|||||||
.map(sanitize_provider_name)
|
.map(sanitize_provider_name)
|
||||||
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
||||||
|
|
||||||
let auth_path = get_codex_config_dir().join(format!("auth-{}.json", base_name));
|
let auth_path = get_codex_config_dir().join(format!("auth-{base_name}.json"));
|
||||||
let config_path = get_codex_config_dir().join(format!("config-{}.toml", base_name));
|
let config_path = get_codex_config_dir().join(format!("config-{base_name}.toml"));
|
||||||
|
|
||||||
(auth_path, config_path)
|
(auth_path, config_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除 Codex 供应商配置文件
|
/// 删除 Codex 供应商配置文件
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn delete_codex_provider_config(
|
pub fn delete_codex_provider_config(
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
provider_name: &str,
|
provider_name: &str,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use tauri_plugin_opener::OpenerExt;
|
|||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::codex_config;
|
use crate::codex_config;
|
||||||
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
||||||
|
use crate::settings;
|
||||||
|
|
||||||
/// 获取 Claude Code 配置状态
|
/// 获取 Claude Code 配置状态
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -16,6 +17,18 @@ pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
|||||||
|
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
fn invalid_json_format_error(error: serde_json::Error) -> String {
|
||||||
|
let lang = settings::get_settings()
|
||||||
|
.language
|
||||||
|
.unwrap_or_else(|| "zh".to_string());
|
||||||
|
|
||||||
|
match lang.as_str() {
|
||||||
|
"en" => format!("Invalid JSON format: {error}"),
|
||||||
|
"ja" => format!("JSON形式が無効です: {error}"),
|
||||||
|
_ => format!("无效的 JSON 格式: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
||||||
match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||||
@@ -29,6 +42,15 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
|||||||
|
|
||||||
Ok(ConfigStatus { exists, path })
|
Ok(ConfigStatus { exists, path })
|
||||||
}
|
}
|
||||||
|
AppType::Gemini => {
|
||||||
|
let env_path = crate::gemini_config::get_gemini_env_path();
|
||||||
|
let exists = env_path.exists();
|
||||||
|
let path = crate::gemini_config::get_gemini_dir()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(ConfigStatus { exists, path })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +66,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
|
|||||||
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||||
AppType::Claude => config::get_claude_config_dir(),
|
AppType::Claude => config::get_claude_config_dir(),
|
||||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||||
|
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(dir.to_string_lossy().to_string())
|
Ok(dir.to_string_lossy().to_string())
|
||||||
@@ -55,16 +78,17 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
|
|||||||
let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||||
AppType::Claude => config::get_claude_config_dir(),
|
AppType::Claude => config::get_claude_config_dir(),
|
||||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||||
|
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if !config_dir.exists() {
|
if !config_dir.exists() {
|
||||||
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?;
|
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {e}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
handle
|
handle
|
||||||
.opener()
|
.opener()
|
||||||
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
|
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
|
||||||
.map_err(|e| format!("打开文件夹失败: {}", e))?;
|
.map_err(|e| format!("打开文件夹失败: {e}"))?;
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
@@ -87,14 +111,14 @@ pub async fn pick_directory(
|
|||||||
builder.blocking_pick_folder()
|
builder.blocking_pick_folder()
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("弹出目录选择器失败: {}", e))?;
|
.map_err(|e| format!("弹出目录选择器失败: {e}"))?;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Some(file_path) => {
|
Some(file_path) => {
|
||||||
let resolved = file_path
|
let resolved = file_path
|
||||||
.simplified()
|
.simplified()
|
||||||
.into_path()
|
.into_path()
|
||||||
.map_err(|e| format!("解析选择的目录失败: {}", e))?;
|
.map_err(|e| format!("解析选择的目录失败: {e}"))?;
|
||||||
Ok(Some(resolved.to_string_lossy().to_string()))
|
Ok(Some(resolved.to_string_lossy().to_string()))
|
||||||
}
|
}
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
@@ -114,13 +138,124 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
|
|||||||
let config_dir = config::get_app_config_dir();
|
let config_dir = config::get_app_config_dir();
|
||||||
|
|
||||||
if !config_dir.exists() {
|
if !config_dir.exists() {
|
||||||
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {}", e))?;
|
std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建目录失败: {e}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
handle
|
handle
|
||||||
.opener()
|
.opener()
|
||||||
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
|
.open_path(config_dir.to_string_lossy().to_string(), None::<String>)
|
||||||
.map_err(|e| format!("打开文件夹失败: {}", e))?;
|
.map_err(|e| format!("打开文件夹失败: {e}"))?;
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取 Claude 通用配置片段(已废弃,使用 get_common_config_snippet)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_claude_common_config_snippet(
|
||||||
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.get_config_snippet("claude")
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置 Claude 通用配置片段(已废弃,使用 set_common_config_snippet)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_claude_common_config_snippet(
|
||||||
|
snippet: String,
|
||||||
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// 验证是否为有效的 JSON(如果不为空)
|
||||||
|
if !snippet.trim().is_empty() {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = if snippet.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(snippet)
|
||||||
|
};
|
||||||
|
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.set_config_snippet("claude", value)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取通用配置片段(统一接口)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_common_config_snippet(
|
||||||
|
app_type: String,
|
||||||
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.get_config_snippet(&app_type)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置通用配置片段(统一接口)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_common_config_snippet(
|
||||||
|
app_type: String,
|
||||||
|
snippet: String,
|
||||||
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// 验证格式(根据应用类型)
|
||||||
|
if !snippet.trim().is_empty() {
|
||||||
|
match app_type.as_str() {
|
||||||
|
"claude" | "gemini" => {
|
||||||
|
// 验证 JSON 格式
|
||||||
|
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||||
|
.map_err(invalid_json_format_error)?;
|
||||||
|
}
|
||||||
|
"codex" => {
|
||||||
|
// TOML 格式暂不验证(或可使用 toml crate)
|
||||||
|
// 注意:TOML 验证较为复杂,暂时跳过
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = if snippet.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(snippet)
|
||||||
|
};
|
||||||
|
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.set_config_snippet(&app_type, value)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提取通用配置片段
|
||||||
|
///
|
||||||
|
/// 优先从 `settingsConfig`(编辑器当前内容)提取;若未提供,则从当前激活供应商提取。
|
||||||
|
///
|
||||||
|
/// 提取时会自动排除差异化字段(API Key、模型配置、端点等),返回可复用的通用配置片段。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn extract_common_config_snippet(
|
||||||
|
appType: String,
|
||||||
|
settingsConfig: Option<String>,
|
||||||
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let app = AppType::from_str(&appType).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if let Some(settings_config) = settingsConfig.filter(|s| !s.trim().is_empty()) {
|
||||||
|
let settings: serde_json::Value =
|
||||||
|
serde_json::from_str(&settings_config).map_err(invalid_json_format_error)?;
|
||||||
|
|
||||||
|
return crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(
|
||||||
|
app,
|
||||||
|
&settings,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::services::provider::ProviderService::extract_common_config_snippet(&state, app)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
use crate::deeplink::{
|
||||||
|
import_mcp_from_deeplink, import_prompt_from_deeplink, import_provider_from_deeplink,
|
||||||
|
import_skill_from_deeplink, parse_deeplink_url, DeepLinkImportRequest,
|
||||||
|
};
|
||||||
|
use crate::store::AppState;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
/// Parse a deep link URL and return the parsed request for frontend confirmation
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn parse_deeplink(url: String) -> Result<DeepLinkImportRequest, String> {
|
||||||
|
log::info!("Parsing deep link URL: {url}");
|
||||||
|
parse_deeplink_url(&url).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge configuration from Base64/URL into a deep link request
|
||||||
|
/// This is used by the frontend to show the complete configuration in the confirmation dialog
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn merge_deeplink_config(
|
||||||
|
request: DeepLinkImportRequest,
|
||||||
|
) -> Result<DeepLinkImportRequest, String> {
|
||||||
|
log::info!("Merging config for deep link request: {:?}", request.name);
|
||||||
|
crate::deeplink::parse_and_merge_config(&request).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a provider from a deep link request (legacy, kept for compatibility)
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn import_from_deeplink(
|
||||||
|
state: State<AppState>,
|
||||||
|
request: DeepLinkImportRequest,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
log::info!(
|
||||||
|
"Importing provider from deep link: {:?} for app {:?}",
|
||||||
|
request.name,
|
||||||
|
request.app
|
||||||
|
);
|
||||||
|
|
||||||
|
let provider_id = import_provider_from_deeplink(&state, request).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
log::info!("Successfully imported provider with ID: {provider_id}");
|
||||||
|
|
||||||
|
Ok(provider_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import resource from a deep link request (unified handler)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn import_from_deeplink_unified(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
request: DeepLinkImportRequest,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
log::info!("Importing {} resource from deep link", request.resource);
|
||||||
|
|
||||||
|
match request.resource.as_str() {
|
||||||
|
"provider" => {
|
||||||
|
let provider_id =
|
||||||
|
import_provider_from_deeplink(&state, request).map_err(|e| e.to_string())?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"type": "provider",
|
||||||
|
"id": provider_id
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
"prompt" => {
|
||||||
|
let prompt_id =
|
||||||
|
import_prompt_from_deeplink(&state, request).map_err(|e| e.to_string())?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"type": "prompt",
|
||||||
|
"id": prompt_id
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
"mcp" => {
|
||||||
|
let result = import_mcp_from_deeplink(&state, request).map_err(|e| e.to_string())?;
|
||||||
|
// Add type field to the result
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"type": "mcp",
|
||||||
|
"importedCount": result.imported_count,
|
||||||
|
"importedIds": result.imported_ids,
|
||||||
|
"failed": result.failed
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
"skill" => {
|
||||||
|
let skill_key =
|
||||||
|
import_skill_from_deeplink(&state, request).map_err(|e| e.to_string())?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"type": "skill",
|
||||||
|
"key": skill_key
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
_ => Err(format!("Unsupported resource type: {}", request.resource)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
use crate::services::env_checker::{check_env_conflicts as check_conflicts, EnvConflict};
|
||||||
|
use crate::services::env_manager::{
|
||||||
|
delete_env_vars as delete_vars, restore_from_backup, BackupInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Check environment variable conflicts for a specific app
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn check_env_conflicts(app: String) -> Result<Vec<EnvConflict>, String> {
|
||||||
|
check_conflicts(&app)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete environment variables with backup
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_env_vars(conflicts: Vec<EnvConflict>) -> Result<BackupInfo, String> {
|
||||||
|
delete_vars(conflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore environment variables from backup file
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn restore_env_backup(backup_path: String) -> Result<(), String> {
|
||||||
|
restore_from_backup(backup_path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
//! 故障转移队列命令
|
||||||
|
//!
|
||||||
|
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
|
||||||
|
|
||||||
|
use crate::database::FailoverQueueItem;
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use crate::store::AppState;
|
||||||
|
|
||||||
|
/// 获取故障转移队列
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_failover_queue(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
) -> Result<Vec<FailoverQueueItem>, String> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.get_failover_queue(&app_type)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取可添加到故障转移队列的供应商(不在队列中的)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_available_providers_for_failover(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
) -> Result<Vec<Provider>, String> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.get_available_providers_for_failover(&app_type)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加供应商到故障转移队列
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn add_to_failover_queue(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
provider_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.add_to_failover_queue(&app_type, &provider_id)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从故障转移队列移除供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn remove_from_failover_queue(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
provider_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.remove_from_failover_queue(&app_type, &provider_id)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取指定应用的自动故障转移开关状态(从 proxy_config 表读取)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_auto_failover_enabled(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.get_proxy_config_for_app(&app_type)
|
||||||
|
.await
|
||||||
|
.map(|config| config.auto_failover_enabled)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置指定应用的自动故障转移开关状态(写入 proxy_config 表)
|
||||||
|
///
|
||||||
|
/// 注意:关闭故障转移时不会清除队列,队列内容会保留供下次开启时使用
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_auto_failover_enabled(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!(
|
||||||
|
"[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 读取当前配置
|
||||||
|
let mut config = state
|
||||||
|
.db
|
||||||
|
.get_proxy_config_for_app(&app_type)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 更新 auto_failover_enabled 字段
|
||||||
|
config.auto_failover_enabled = enabled;
|
||||||
|
|
||||||
|
// 写回数据库
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.update_proxy_config_for_app(config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
@@ -6,73 +6,78 @@ use tauri::State;
|
|||||||
use tauri_plugin_dialog::DialogExt;
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::services::ConfigService;
|
use crate::services::provider::ProviderService;
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
|
|
||||||
/// 导出配置文件
|
/// 导出数据库为 SQL 备份
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn export_config_to_file(
|
pub async fn export_config_to_file(
|
||||||
#[allow(non_snake_case)] filePath: String,
|
#[allow(non_snake_case)] filePath: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
|
let db = state.db.clone();
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
let target_path = PathBuf::from(&filePath);
|
let target_path = PathBuf::from(&filePath);
|
||||||
ConfigService::export_config_to_path(&target_path)?;
|
db.export_sql(&target_path)?;
|
||||||
Ok::<_, AppError>(json!({
|
Ok::<_, AppError>(json!({
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "Configuration exported successfully",
|
"message": "SQL exported successfully",
|
||||||
"filePath": filePath
|
"filePath": filePath
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("导出配置失败: {}", e))?
|
.map_err(|e| format!("导出配置失败: {e}"))?
|
||||||
.map_err(|e: AppError| e.to_string())
|
.map_err(|e: AppError| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从文件导入配置
|
/// 从 SQL 备份导入数据库
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn import_config_from_file(
|
pub async fn import_config_from_file(
|
||||||
#[allow(non_snake_case)] filePath: String,
|
#[allow(non_snake_case)] filePath: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
let (new_config, backup_id) = tauri::async_runtime::spawn_blocking(move || {
|
let db = state.db.clone();
|
||||||
|
let db_for_state = db.clone();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
let path_buf = PathBuf::from(&filePath);
|
let path_buf = PathBuf::from(&filePath);
|
||||||
ConfigService::load_config_for_import(&path_buf)
|
let backup_id = db.import_sql(&path_buf)?;
|
||||||
|
|
||||||
|
// 导入后同步当前供应商到各自的 live 配置
|
||||||
|
let app_state = AppState::new(db_for_state);
|
||||||
|
if let Err(err) = ProviderService::sync_current_to_live(&app_state) {
|
||||||
|
log::warn!("导入后同步 live 配置失败: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新加载设置到内存缓存,确保导入的设置生效
|
||||||
|
if let Err(err) = crate::settings::reload_settings() {
|
||||||
|
log::warn!("导入后重载设置失败: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok::<_, AppError>(json!({
|
||||||
|
"success": true,
|
||||||
|
"message": "SQL imported successfully",
|
||||||
|
"backupId": backup_id
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("导入配置失败: {}", e))?
|
.map_err(|e| format!("导入配置失败: {e}"))?
|
||||||
.map_err(|e: AppError| e.to_string())?;
|
.map_err(|e: AppError| e.to_string())
|
||||||
|
|
||||||
{
|
|
||||||
let mut guard = state
|
|
||||||
.config
|
|
||||||
.write()
|
|
||||||
.map_err(|e| AppError::from(e).to_string())?;
|
|
||||||
*guard = new_config;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(json!({
|
|
||||||
"success": true,
|
|
||||||
"message": "Configuration imported successfully",
|
|
||||||
"backupId": backup_id
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 同步当前供应商配置到对应的 live 文件
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<Value, String> {
|
pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<Value, String> {
|
||||||
{
|
let db = state.db.clone();
|
||||||
let mut config_state = state
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
.config
|
let app_state = AppState::new(db);
|
||||||
.write()
|
ProviderService::sync_current_to_live(&app_state)?;
|
||||||
.map_err(|e| AppError::from(e).to_string())?;
|
Ok::<_, AppError>(json!({
|
||||||
ConfigService::sync_current_providers_to_live(&mut config_state)
|
"success": true,
|
||||||
.map_err(|e| e.to_string())?;
|
"message": "Live configuration synchronized"
|
||||||
}
|
}))
|
||||||
|
})
|
||||||
Ok(json!({
|
.await
|
||||||
"success": true,
|
.map_err(|e| format!("同步当前供应商失败: {e}"))?
|
||||||
"message": "Live configuration synchronized"
|
.map_err(|e: AppError| e.to_string())
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存文件对话框
|
/// 保存文件对话框
|
||||||
@@ -84,7 +89,7 @@ pub async fn save_file_dialog<R: tauri::Runtime>(
|
|||||||
let dialog = app.dialog();
|
let dialog = app.dialog();
|
||||||
let result = dialog
|
let result = dialog
|
||||||
.file()
|
.file()
|
||||||
.add_filter("JSON", &["json"])
|
.add_filter("SQL", &["sql"])
|
||||||
.set_file_name(&defaultName)
|
.set_file_name(&defaultName)
|
||||||
.blocking_save_file();
|
.blocking_save_file();
|
||||||
|
|
||||||
@@ -99,7 +104,7 @@ pub async fn open_file_dialog<R: tauri::Runtime>(
|
|||||||
let dialog = app.dialog();
|
let dialog = app.dialog();
|
||||||
let result = dialog
|
let result = dialog
|
||||||
.file()
|
.file()
|
||||||
.add_filter("JSON", &["json"])
|
.add_filter("SQL", &["sql"])
|
||||||
.blocking_pick_file();
|
.blocking_pick_file();
|
||||||
|
|
||||||
Ok(result.map(|p| p.to_string()))
|
Ok(result.map(|p| p.to_string()))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#![allow(non_snake_case)]
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
|
use indexmap::IndexMap;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -50,6 +51,7 @@ pub struct McpConfigResponse {
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[allow(deprecated)] // 兼容层命令,内部调用已废弃的 Service 方法
|
||||||
pub async fn get_mcp_config(
|
pub async fn get_mcp_config(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
app: String,
|
app: String,
|
||||||
@@ -66,6 +68,7 @@ pub async fn get_mcp_config(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 在 config.json 中新增或更新一个 MCP 服务器定义
|
/// 在 config.json 中新增或更新一个 MCP 服务器定义
|
||||||
|
/// [已废弃] 该命令仍然使用旧的分应用API,会转换为统一结构
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn upsert_mcp_server_in_config(
|
pub async fn upsert_mcp_server_in_config(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -74,8 +77,55 @@ pub async fn upsert_mcp_server_in_config(
|
|||||||
spec: serde_json::Value,
|
spec: serde_json::Value,
|
||||||
sync_other_side: Option<bool>,
|
sync_other_side: Option<bool>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
|
use crate::app_config::McpServer;
|
||||||
|
|
||||||
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
McpService::upsert_server(&state, app_ty, &id, spec, sync_other_side.unwrap_or(false))
|
|
||||||
|
// 读取现有的服务器(如果存在)
|
||||||
|
let existing_server = {
|
||||||
|
let servers = state.db.get_all_mcp_servers().map_err(|e| e.to_string())?;
|
||||||
|
servers.get(&id).cloned()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 构建新的统一服务器结构
|
||||||
|
let mut new_server = if let Some(mut existing) = existing_server {
|
||||||
|
// 更新现有服务器
|
||||||
|
existing.server = spec.clone();
|
||||||
|
existing.apps.set_enabled_for(&app_ty, true);
|
||||||
|
existing
|
||||||
|
} else {
|
||||||
|
// 创建新服务器
|
||||||
|
let mut apps = crate::app_config::McpApps::default();
|
||||||
|
apps.set_enabled_for(&app_ty, true);
|
||||||
|
|
||||||
|
// 尝试从 spec 中提取 name,否则使用 id
|
||||||
|
let name = spec
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(&id)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
McpServer {
|
||||||
|
id: id.clone(),
|
||||||
|
name,
|
||||||
|
server: spec,
|
||||||
|
apps,
|
||||||
|
description: None,
|
||||||
|
homepage: None,
|
||||||
|
docs: None,
|
||||||
|
tags: Vec::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果 sync_other_side 为 true,也启用其他应用
|
||||||
|
if sync_other_side.unwrap_or(false) {
|
||||||
|
new_server.apps.claude = true;
|
||||||
|
new_server.apps.codex = true;
|
||||||
|
new_server.apps.gemini = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
McpService::upsert_server(&state, new_server)
|
||||||
|
.map(|_| true)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,15 +133,15 @@ pub async fn upsert_mcp_server_in_config(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn delete_mcp_server_in_config(
|
pub async fn delete_mcp_server_in_config(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
app: String,
|
_app: String, // 参数保留用于向后兼容,但在统一结构中不再需要
|
||||||
id: String,
|
id: String,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
McpService::delete_server(&state, &id).map_err(|e| e.to_string())
|
||||||
McpService::delete_server(&state, app_ty, &id).map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置启用状态并同步到客户端配置
|
/// 设置启用状态并同步到客户端配置
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[allow(deprecated)] // 兼容层命令,内部调用已废弃的 Service 方法
|
||||||
pub async fn set_mcp_enabled(
|
pub async fn set_mcp_enabled(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
app: String,
|
app: String,
|
||||||
@@ -102,30 +152,53 @@ pub async fn set_mcp_enabled(
|
|||||||
McpService::set_enabled(&state, app_ty, &id, enabled).map_err(|e| e.to_string())
|
McpService::set_enabled(&state, app_ty, &id, enabled).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 手动同步:将启用的 MCP 投影到 ~/.claude.json
|
// ============================================================================
|
||||||
|
// v3.7.0 新增:统一 MCP 管理命令
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
use crate::app_config::McpServer;
|
||||||
|
|
||||||
|
/// 获取所有 MCP 服务器(统一结构)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn sync_enabled_mcp_to_claude(state: State<'_, AppState>) -> Result<bool, String> {
|
pub async fn get_mcp_servers(
|
||||||
McpService::sync_enabled(&state, AppType::Claude)
|
state: State<'_, AppState>,
|
||||||
.map(|_| true)
|
) -> Result<IndexMap<String, McpServer>, String> {
|
||||||
.map_err(|e| e.to_string())
|
McpService::get_all_servers(&state).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 手动同步:将启用的 MCP 投影到 ~/.codex/config.toml
|
/// 添加或更新 MCP 服务器
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn sync_enabled_mcp_to_codex(state: State<'_, AppState>) -> Result<bool, String> {
|
pub async fn upsert_mcp_server(
|
||||||
McpService::sync_enabled(&state, AppType::Codex)
|
state: State<'_, AppState>,
|
||||||
.map(|_| true)
|
server: McpServer,
|
||||||
.map_err(|e| e.to_string())
|
) -> Result<(), String> {
|
||||||
|
McpService::upsert_server(&state, server).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从 ~/.claude.json 导入 MCP 定义到 config.json
|
/// 删除 MCP 服务器
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn import_mcp_from_claude(state: State<'_, AppState>) -> Result<usize, String> {
|
pub async fn delete_mcp_server(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
||||||
McpService::import_from_claude(&state).map_err(|e| e.to_string())
|
McpService::delete_server(&state, &id).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从 ~/.codex/config.toml 导入 MCP 定义到 config.json
|
/// 切换 MCP 服务器在指定应用的启用状态
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn import_mcp_from_codex(state: State<'_, AppState>) -> Result<usize, String> {
|
pub async fn toggle_mcp_app(
|
||||||
McpService::import_from_codex(&state).map_err(|e| e.to_string())
|
state: State<'_, AppState>,
|
||||||
|
server_id: String,
|
||||||
|
app: String,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
McpService::toggle_app(&state, &server_id, app_ty, enabled).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从所有应用导入 MCP 服务器(复用已有的导入逻辑)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result<usize, String> {
|
||||||
|
let mut total = 0;
|
||||||
|
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);
|
||||||
|
Ok(total)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
#![allow(non_snake_case)]
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
use crate::init_status::InitErrorPayload;
|
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
/// 打开外部链接
|
/// 打开外部链接
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {
|
pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {
|
||||||
let url = if url.starts_with("http://") || url.starts_with("https://") {
|
let url = if url.starts_with("http://") || url.starts_with("https://") {
|
||||||
url
|
url
|
||||||
} else {
|
} else {
|
||||||
format!("https://{}", url)
|
format!("https://{url}")
|
||||||
};
|
};
|
||||||
|
|
||||||
app.opener()
|
app.opener()
|
||||||
.open_url(&url, None::<String>)
|
.open_url(&url, None::<String>)
|
||||||
.map_err(|e| format!("打开链接失败: {}", e))?;
|
.map_err(|e| format!("打开链接失败: {e}"))?;
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
@@ -29,7 +35,7 @@ pub async fn check_for_updates(handle: AppHandle) -> Result<bool, String> {
|
|||||||
"https://github.com/farion1231/cc-switch/releases/latest",
|
"https://github.com/farion1231/cc-switch/releases/latest",
|
||||||
None::<String>,
|
None::<String>,
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("打开更新页面失败: {}", e))?;
|
.map_err(|e| format!("打开更新页面失败: {e}"))?;
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
@@ -37,7 +43,7 @@ pub async fn check_for_updates(handle: AppHandle) -> Result<bool, String> {
|
|||||||
/// 判断是否为便携版(绿色版)运行
|
/// 判断是否为便携版(绿色版)运行
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn is_portable_mode() -> Result<bool, String> {
|
pub async fn is_portable_mode() -> Result<bool, String> {
|
||||||
let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {}", e))?;
|
let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {e}"))?;
|
||||||
if let Some(dir) = exe_path.parent() {
|
if let Some(dir) = exe_path.parent() {
|
||||||
Ok(dir.join("portable.ini").is_file())
|
Ok(dir.join("portable.ini").is_file())
|
||||||
} else {
|
} else {
|
||||||
@@ -51,3 +57,241 @@ pub async fn is_portable_mode() -> Result<bool, String> {
|
|||||||
pub async fn get_init_error() -> Result<Option<InitErrorPayload>, String> {
|
pub async fn get_init_error() -> Result<Option<InitErrorPayload>, String> {
|
||||||
Ok(crate::init_status::get_init_error())
|
Ok(crate::init_status::get_init_error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取 JSON→SQLite 迁移结果(若有)。
|
||||||
|
/// 只返回一次 true,之后返回 false,用于前端显示一次性 Toast 通知。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_migration_result() -> Result<bool, String> {
|
||||||
|
Ok(crate::init_status::take_migration_success())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Skills 自动导入(SSOT)迁移结果(若有)。
|
||||||
|
/// 只返回一次 Some({count}),之后返回 None,用于前端显示一次性 Toast 通知。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_skills_migration_result() -> Result<Option<SkillsMigrationPayload>, String> {
|
||||||
|
Ok(crate::init_status::take_skills_migration_result())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct ToolVersion {
|
||||||
|
name: String,
|
||||||
|
version: Option<String>,
|
||||||
|
latest_version: Option<String>, // 新增字段:最新版本
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
||||||
|
let tools = vec!["claude", "codex", "gemini"];
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
// 用于获取远程版本的 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) = {
|
||||||
|
// 先尝试直接执行
|
||||||
|
let direct_result = try_get_version(tool);
|
||||||
|
|
||||||
|
if direct_result.0.is_some() {
|
||||||
|
direct_result
|
||||||
|
} else {
|
||||||
|
// 扫描常见的 npm 全局安装路径
|
||||||
|
scan_cli_version(tool)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 获取远程最新版本
|
||||||
|
let latest_version = match tool {
|
||||||
|
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
|
||||||
|
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
|
||||||
|
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
results.push(ToolVersion {
|
||||||
|
name: tool.to_string(),
|
||||||
|
version: local_version,
|
||||||
|
latest_version,
|
||||||
|
error: local_error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to fetch latest version from npm registry
|
||||||
|
async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Option<String> {
|
||||||
|
let url = format!("https://registry.npmjs.org/{package}");
|
||||||
|
match client.get(&url).send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||||
|
json.get("dist-tags")
|
||||||
|
.and_then(|tags| tags.get("latest"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从版本输出中提取纯版本号
|
||||||
|
fn extract_version(raw: &str) -> String {
|
||||||
|
// 匹配 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 尝试直接执行命令获取版本
|
||||||
|
fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let output = {
|
||||||
|
Command::new("cmd")
|
||||||
|
.args(["/C", &format!("{tool} --version")])
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.output()
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let output = {
|
||||||
|
Command::new("sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(format!("{tool} --version"))
|
||||||
|
.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("未安装或无法执行".to_string()))
|
||||||
|
} else {
|
||||||
|
(Some(extract_version(raw)), None)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let err = if stderr.is_empty() { stdout } else { stderr };
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
Some(if err.is_empty() {
|
||||||
|
"未安装或无法执行".to_string()
|
||||||
|
} else {
|
||||||
|
err
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => (None, Some(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 扫描常见路径查找 CLI
|
||||||
|
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
let home = dirs::home_dir().unwrap_or_default();
|
||||||
|
|
||||||
|
// 常见的 npm 全局安装路径
|
||||||
|
let mut search_paths: Vec<std::path::PathBuf> = vec![
|
||||||
|
home.join(".npm-global/bin"),
|
||||||
|
home.join(".local/bin"),
|
||||||
|
home.join("n/bin"), // n version manager
|
||||||
|
];
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
search_paths.push(std::path::PathBuf::from("/opt/homebrew/bin"));
|
||||||
|
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
|
||||||
|
search_paths.push(std::path::PathBuf::from("/usr/bin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
if let Some(appdata) = dirs::data_dir() {
|
||||||
|
search_paths.push(appdata.join("npm"));
|
||||||
|
}
|
||||||
|
search_paths.push(std::path::PathBuf::from("C:\\Program Files\\nodejs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扫描 nvm 目录下的所有 node 版本
|
||||||
|
let nvm_base = home.join(".nvm/versions/node");
|
||||||
|
if nvm_base.exists() {
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&nvm_base) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let bin_path = entry.path().join("bin");
|
||||||
|
if bin_path.exists() {
|
||||||
|
search_paths.push(bin_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在每个路径中查找工具
|
||||||
|
for path in &search_paths {
|
||||||
|
let tool_path = if cfg!(target_os = "windows") {
|
||||||
|
path.join(format!("{tool}.cmd"))
|
||||||
|
} else {
|
||||||
|
path.join(tool)
|
||||||
|
};
|
||||||
|
|
||||||
|
if tool_path.exists() {
|
||||||
|
// 构建 PATH 环境变量,确保 node 可被找到
|
||||||
|
let current_path = std::env::var("PATH").unwrap_or_default();
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let new_path = format!("{};{}", path.display(), current_path);
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let new_path = format!("{}:{}", path.display(), current_path);
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let output = {
|
||||||
|
// 使用 cmd /C 包装执行,确保子进程也在隐藏的控制台中运行
|
||||||
|
Command::new("cmd")
|
||||||
|
.args(["/C", &format!("\"{}\" --version", tool_path.display())])
|
||||||
|
.env("PATH", &new_path)
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.output()
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let output = {
|
||||||
|
Command::new(&tool_path)
|
||||||
|
.arg("--version")
|
||||||
|
.env("PATH", &new_path)
|
||||||
|
.output()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(out) = output {
|
||||||
|
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() {
|
||||||
|
return (Some(extract_version(raw)), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(None, Some("未安装或无法执行".to_string()))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,33 @@
|
|||||||
#![allow(non_snake_case)]
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
|
mod deeplink;
|
||||||
|
mod env;
|
||||||
|
mod failover;
|
||||||
mod import_export;
|
mod import_export;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
mod misc;
|
mod misc;
|
||||||
mod plugin;
|
mod plugin;
|
||||||
|
mod prompt;
|
||||||
mod provider;
|
mod provider;
|
||||||
|
mod proxy;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
pub mod skill;
|
||||||
|
mod stream_check;
|
||||||
|
mod usage;
|
||||||
|
|
||||||
pub use config::*;
|
pub use config::*;
|
||||||
|
pub use deeplink::*;
|
||||||
|
pub use env::*;
|
||||||
|
pub use failover::*;
|
||||||
pub use import_export::*;
|
pub use import_export::*;
|
||||||
pub use mcp::*;
|
pub use mcp::*;
|
||||||
pub use misc::*;
|
pub use misc::*;
|
||||||
pub use plugin::*;
|
pub use plugin::*;
|
||||||
|
pub use prompt::*;
|
||||||
pub use provider::*;
|
pub use provider::*;
|
||||||
|
pub use proxy::*;
|
||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
|
pub use skill::*;
|
||||||
|
pub use stream_check::*;
|
||||||
|
pub use usage::*;
|
||||||
|
|||||||
@@ -34,3 +34,15 @@ pub async fn apply_claude_plugin_config(official: bool) -> Result<bool, String>
|
|||||||
pub async fn is_claude_plugin_applied() -> Result<bool, String> {
|
pub async fn is_claude_plugin_applied() -> Result<bool, String> {
|
||||||
crate::claude_plugin::is_claude_config_applied().map_err(|e| e.to_string())
|
crate::claude_plugin::is_claude_config_applied().map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Claude Code:跳过初次安装确认(写入 ~/.claude.json 的 hasCompletedOnboarding=true)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn apply_claude_onboarding_skip() -> Result<bool, String> {
|
||||||
|
crate::claude_mcp::set_has_completed_onboarding().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claude Code:恢复初次安装确认(删除 ~/.claude.json 的 hasCompletedOnboarding 字段)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clear_claude_onboarding_skip() -> Result<bool, String> {
|
||||||
|
crate::claude_mcp::clear_has_completed_onboarding().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
use indexmap::IndexMap;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::prompt::Prompt;
|
||||||
|
use crate::services::PromptService;
|
||||||
|
use crate::store::AppState;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_prompts(
|
||||||
|
app: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<IndexMap<String, Prompt>, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
PromptService::get_prompts(&state, app_type).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn upsert_prompt(
|
||||||
|
app: String,
|
||||||
|
id: String,
|
||||||
|
prompt: Prompt,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
PromptService::upsert_prompt(&state, app_type, &id, prompt).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_prompt(
|
||||||
|
app: String,
|
||||||
|
id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
PromptService::delete_prompt(&state, app_type, &id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn enable_prompt(
|
||||||
|
app: String,
|
||||||
|
id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
PromptService::enable_prompt(&state, app_type, &id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn import_prompt_from_file(
|
||||||
|
app: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
PromptService::import_from_file(&state, app_type).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_current_prompt_file_content(app: String) -> Result<Option<String>, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
PromptService::get_current_file_content(app_type).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::HashMap;
|
use indexmap::IndexMap;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
@@ -13,7 +13,7 @@ use std::str::FromStr;
|
|||||||
pub fn get_providers(
|
pub fn get_providers(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
app: String,
|
app: String,
|
||||||
) -> Result<HashMap<String, Provider>, String> {
|
) -> Result<IndexMap<String, Provider>, String> {
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string())
|
ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,7 @@ pub fn switch_provider(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<(), AppError> {
|
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
||||||
ProviderService::import_default_config(state, app_type)
|
ProviderService::import_default_config(state, app_type)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result
|
|||||||
pub fn import_default_config_test_hook(
|
pub fn import_default_config_test_hook(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
app_type: AppType,
|
app_type: AppType,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<bool, AppError> {
|
||||||
import_default_config_internal(state, app_type)
|
import_default_config_internal(state, app_type)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,9 +102,7 @@ pub fn import_default_config_test_hook(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {
|
pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
import_default_config_internal(&state, app_type)
|
import_default_config_internal(&state, app_type).map_err(Into::into)
|
||||||
.map(|_| true)
|
|
||||||
.map_err(Into::into)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 查询供应商用量
|
/// 查询供应商用量
|
||||||
@@ -231,3 +229,97 @@ pub fn update_providers_sort_order(
|
|||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
|
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 统一供应商(Universal Provider)命令
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
use crate::provider::UniversalProvider;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
|
/// 统一供应商同步完成事件的 payload
|
||||||
|
#[derive(Clone, serde::Serialize)]
|
||||||
|
pub struct UniversalProviderSyncedEvent {
|
||||||
|
/// 操作类型: "upsert" | "delete" | "sync"
|
||||||
|
pub action: String,
|
||||||
|
/// 统一供应商 ID
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发送统一供应商同步事件,通知前端刷新供应商列表
|
||||||
|
fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
|
||||||
|
let _ = app.emit(
|
||||||
|
"universal-provider-synced",
|
||||||
|
UniversalProviderSyncedEvent {
|
||||||
|
action: action.to_string(),
|
||||||
|
id: id.to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取所有统一供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_universal_providers(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<HashMap<String, UniversalProvider>, String> {
|
||||||
|
ProviderService::list_universal(state.inner()).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取单个统一供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_universal_provider(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: String,
|
||||||
|
) -> Result<Option<UniversalProvider>, String> {
|
||||||
|
ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加或更新统一供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn upsert_universal_provider(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
provider: UniversalProvider,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let id = provider.id.clone();
|
||||||
|
let result =
|
||||||
|
ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 发送事件通知前端刷新
|
||||||
|
emit_universal_provider_synced(&app, "upsert", &id);
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除统一供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_universal_provider(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let result =
|
||||||
|
ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 发送事件通知前端刷新
|
||||||
|
emit_universal_provider_synced(&app, "delete", &id);
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步统一供应商到各应用(手动触发)
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn sync_universal_provider(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let result =
|
||||||
|
ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 发送事件通知前端刷新
|
||||||
|
emit_universal_provider_synced(&app, "sync", &id);
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
//! 代理服务相关的 Tauri 命令
|
||||||
|
//!
|
||||||
|
//! 提供前端调用的 API 接口
|
||||||
|
|
||||||
|
use crate::proxy::types::*;
|
||||||
|
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
|
||||||
|
use crate::store::AppState;
|
||||||
|
|
||||||
|
/// 启动代理服务器(仅启动服务,不接管 Live 配置)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn start_proxy_server(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<ProxyServerInfo, String> {
|
||||||
|
state.proxy_service.start().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止代理服务器(恢复 Live 配置)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Result<(), String> {
|
||||||
|
state.proxy_service.stop_with_restore().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取各应用接管状态
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_proxy_takeover_status(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<ProxyTakeoverStatus, String> {
|
||||||
|
state.proxy_service.get_takeover_status().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 为指定应用开启/关闭接管
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_proxy_takeover_for_app(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state
|
||||||
|
.proxy_service
|
||||||
|
.set_takeover_for_app(&app_type, enabled)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取代理服务器状态
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_proxy_status(state: tauri::State<'_, AppState>) -> Result<ProxyStatus, String> {
|
||||||
|
state.proxy_service.get_status().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取代理配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_proxy_config(state: tauri::State<'_, AppState>) -> Result<ProxyConfig, String> {
|
||||||
|
state.proxy_service.get_config().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新代理配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_proxy_config(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
config: ProxyConfig,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state.proxy_service.update_config(&config).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Global & Per-App Config ====================
|
||||||
|
|
||||||
|
/// 获取全局代理配置
|
||||||
|
///
|
||||||
|
/// 返回统一的全局配置字段(代理开关、监听地址、端口、日志开关)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_global_proxy_config(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<GlobalProxyConfig, String> {
|
||||||
|
let db = &state.db;
|
||||||
|
db.get_global_proxy_config()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新全局代理配置
|
||||||
|
///
|
||||||
|
/// 更新统一的全局配置字段,会同时更新三行(claude/codex/gemini)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_global_proxy_config(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
config: GlobalProxyConfig,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let db = &state.db;
|
||||||
|
db.update_global_proxy_config(config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取指定应用的代理配置
|
||||||
|
///
|
||||||
|
/// 返回应用级配置(enabled、auto_failover、超时、熔断器等)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_proxy_config_for_app(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
) -> Result<AppProxyConfig, String> {
|
||||||
|
let db = &state.db;
|
||||||
|
db.get_proxy_config_for_app(&app_type)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新指定应用的代理配置
|
||||||
|
///
|
||||||
|
/// 更新应用级配置(enabled、auto_failover、超时、熔断器等)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_proxy_config_for_app(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
config: AppProxyConfig,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let db = &state.db;
|
||||||
|
db.update_proxy_config_for_app(config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查代理服务器是否正在运行
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
||||||
|
Ok(state.proxy_service.is_running().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否处于 Live 接管模式
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn is_live_takeover_active(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
||||||
|
state.proxy_service.is_takeover_active().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 代理模式下切换供应商(热切换)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn switch_proxy_provider(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
provider_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state
|
||||||
|
.proxy_service
|
||||||
|
.switch_proxy_target(&app_type, &provider_id)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 故障转移相关命令 ====================
|
||||||
|
|
||||||
|
/// 获取供应商健康状态
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_provider_health(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
provider_id: String,
|
||||||
|
app_type: String,
|
||||||
|
) -> Result<ProviderHealth, String> {
|
||||||
|
let db = &state.db;
|
||||||
|
db.get_provider_health(&provider_id, &app_type)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重置熔断器
|
||||||
|
///
|
||||||
|
/// 重置后会检查是否应该切回队列中优先级更高的供应商:
|
||||||
|
/// 1. 检查自动故障转移是否开启
|
||||||
|
/// 2. 如果恢复的供应商在队列中优先级更高(queue_order 更小),则自动切换
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn reset_circuit_breaker(
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
provider_id: String,
|
||||||
|
app_type: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// 1. 重置数据库健康状态
|
||||||
|
let db = &state.db;
|
||||||
|
db.update_provider_health(&provider_id, &app_type, true, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 2. 如果代理正在运行,重置内存中的熔断器状态
|
||||||
|
state
|
||||||
|
.proxy_service
|
||||||
|
.reset_provider_circuit_breaker(&provider_id, &app_type)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 3. 检查是否应该切回优先级更高的供应商(从 proxy_config 表读取)
|
||||||
|
// 只有当该应用已被代理接管(enabled=true)且开启了自动故障转移时才执行
|
||||||
|
let (app_enabled, auto_failover_enabled) = match db.get_proxy_config_for_app(&app_type).await {
|
||||||
|
Ok(config) => (config.enabled, config.auto_failover_enabled),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("[{app_type}] Failed to read proxy_config: {e}, defaulting to disabled");
|
||||||
|
(false, false)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if app_enabled && auto_failover_enabled && state.proxy_service.is_running().await {
|
||||||
|
// 获取当前供应商 ID
|
||||||
|
let current_id = db
|
||||||
|
.get_current_provider(&app_type)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if let Some(current_id) = current_id {
|
||||||
|
// 获取故障转移队列
|
||||||
|
let queue = db
|
||||||
|
.get_failover_queue(&app_type)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 找到恢复的供应商和当前供应商在队列中的位置(使用 sort_index)
|
||||||
|
let restored_order = queue
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.provider_id == provider_id)
|
||||||
|
.and_then(|item| item.sort_index);
|
||||||
|
|
||||||
|
let current_order = queue
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.provider_id == current_id)
|
||||||
|
.and_then(|item| item.sort_index);
|
||||||
|
|
||||||
|
// 如果恢复的供应商优先级更高(sort_index 更小),则切换
|
||||||
|
if let (Some(restored), Some(current)) = (restored_order, current_order) {
|
||||||
|
if restored < current {
|
||||||
|
log::info!(
|
||||||
|
"[Recovery] 供应商 {provider_id} 已恢复且优先级更高 (P{restored} vs P{current}),自动切换"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取供应商名称用于日志和事件
|
||||||
|
let provider_name = db
|
||||||
|
.get_all_providers(&app_type)
|
||||||
|
.ok()
|
||||||
|
.and_then(|providers| providers.get(&provider_id).map(|p| p.name.clone()))
|
||||||
|
.unwrap_or_else(|| provider_id.clone());
|
||||||
|
|
||||||
|
// 创建故障转移切换管理器并执行切换
|
||||||
|
let switch_manager =
|
||||||
|
crate::proxy::failover_switch::FailoverSwitchManager::new(db.clone());
|
||||||
|
if let Err(e) = switch_manager
|
||||||
|
.try_switch(Some(&app_handle), &app_type, &provider_id, &provider_name)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::error!("[Recovery] 自动切换失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取熔断器配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_circuit_breaker_config(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<CircuitBreakerConfig, String> {
|
||||||
|
let db = &state.db;
|
||||||
|
db.get_circuit_breaker_config()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新熔断器配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_circuit_breaker_config(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
config: CircuitBreakerConfig,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let db = &state.db;
|
||||||
|
|
||||||
|
// 1. 更新数据库配置
|
||||||
|
db.update_circuit_breaker_config(&config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 2. 如果代理正在运行,热更新内存中的熔断器配置
|
||||||
|
state
|
||||||
|
.proxy_service
|
||||||
|
.update_circuit_breaker_configs(config)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取熔断器统计信息(仅当代理服务器运行时)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_circuit_breaker_stats(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
provider_id: String,
|
||||||
|
app_type: String,
|
||||||
|
) -> Result<Option<CircuitBreakerStats>, String> {
|
||||||
|
// 这个功能需要访问运行中的代理服务器的内存状态
|
||||||
|
// 目前先返回 None,后续可以通过 ProxyService 暴露接口来实现
|
||||||
|
let _ = (state, provider_id, app_type);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
@@ -18,7 +18,12 @@ pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<boo
|
|||||||
/// 重启应用程序(当 app_config_dir 变更后使用)
|
/// 重启应用程序(当 app_config_dir 变更后使用)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
|
pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
|
||||||
app.restart();
|
// 在后台延迟重启,让函数有时间返回响应
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
app.restart();
|
||||||
|
});
|
||||||
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 app_config_dir 覆盖配置 (从 Store)
|
/// 获取 app_config_dir 覆盖配置 (从 Store)
|
||||||
@@ -37,3 +42,20 @@ pub async fn set_app_config_dir_override(
|
|||||||
crate::app_store::set_app_config_dir_to_store(&app, path.as_deref())?;
|
crate::app_store::set_app_config_dir_to_store(&app, path.as_deref())?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 设置开机自启
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
|
||||||
|
if enabled {
|
||||||
|
crate::auto_launch::enable_auto_launch().map_err(|e| format!("启用开机自启失败: {e}"))?;
|
||||||
|
} else {
|
||||||
|
crate::auto_launch::disable_auto_launch().map_err(|e| format!("禁用开机自启失败: {e}"))?;
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取开机自启状态
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_auto_launch_status() -> Result<bool, String> {
|
||||||
|
crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
//! Skills 命令层
|
||||||
|
//!
|
||||||
|
//! v3.10.0+ 统一管理架构:
|
||||||
|
//! - 支持三应用开关(Claude/Codex/Gemini)
|
||||||
|
//! - SSOT 存储在 ~/.cc-switch/skills/
|
||||||
|
|
||||||
|
use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill};
|
||||||
|
use crate::error::format_skill_error;
|
||||||
|
use crate::services::skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
||||||
|
use crate::store::AppState;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
/// SkillService 状态包装
|
||||||
|
pub struct SkillServiceState(pub Arc<SkillService>);
|
||||||
|
|
||||||
|
/// 解析 app 参数为 AppType
|
||||||
|
fn parse_app_type(app: &str) -> Result<AppType, String> {
|
||||||
|
match app.to_lowercase().as_str() {
|
||||||
|
"claude" => Ok(AppType::Claude),
|
||||||
|
"codex" => Ok(AppType::Codex),
|
||||||
|
"gemini" => Ok(AppType::Gemini),
|
||||||
|
_ => Err(format!("不支持的 app 类型: {app}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 统一管理命令 ==========
|
||||||
|
|
||||||
|
/// 获取所有已安装的 Skills
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_installed_skills(app_state: State<'_, AppState>) -> Result<Vec<InstalledSkill>, String> {
|
||||||
|
SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 安装 Skill(新版统一安装)
|
||||||
|
///
|
||||||
|
/// 参数:
|
||||||
|
/// - skill: 从发现列表获取的技能信息
|
||||||
|
/// - current_app: 当前选中的应用,安装后默认启用该应用
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn install_skill_unified(
|
||||||
|
skill: DiscoverableSkill,
|
||||||
|
current_app: String,
|
||||||
|
service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<InstalledSkill, String> {
|
||||||
|
let app_type = parse_app_type(¤t_app)?;
|
||||||
|
|
||||||
|
service
|
||||||
|
.0
|
||||||
|
.install(&app_state.db, &skill, &app_type)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 卸载 Skill(新版统一卸载)
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn uninstall_skill_unified(id: String, app_state: State<'_, AppState>) -> Result<bool, String> {
|
||||||
|
SkillService::uninstall(&app_state.db, &id).map_err(|e| e.to_string())?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换 Skill 的应用启用状态
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn toggle_skill_app(
|
||||||
|
id: String,
|
||||||
|
app: String,
|
||||||
|
enabled: bool,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_type = parse_app_type(&app)?;
|
||||||
|
SkillService::toggle_app(&app_state.db, &id, &app_type, enabled).map_err(|e| e.to_string())?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 扫描未管理的 Skills
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn scan_unmanaged_skills(
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<UnmanagedSkill>, String> {
|
||||||
|
SkillService::scan_unmanaged(&app_state.db).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从应用目录导入 Skills
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn import_skills_from_apps(
|
||||||
|
directories: Vec<String>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<InstalledSkill>, String> {
|
||||||
|
SkillService::import_from_apps(&app_state.db, directories).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 发现功能命令 ==========
|
||||||
|
|
||||||
|
/// 发现可安装的 Skills(从仓库获取)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn discover_available_skills(
|
||||||
|
service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<DiscoverableSkill>, String> {
|
||||||
|
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
||||||
|
service
|
||||||
|
.0
|
||||||
|
.discover_available(repos)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 兼容旧 API 的命令 ==========
|
||||||
|
|
||||||
|
/// 获取技能列表(兼容旧 API)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_skills(
|
||||||
|
service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<Skill>, String> {
|
||||||
|
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
||||||
|
service
|
||||||
|
.0
|
||||||
|
.list_skills(repos, &app_state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取指定应用的技能列表(兼容旧 API)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_skills_for_app(
|
||||||
|
app: String,
|
||||||
|
service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<Skill>, String> {
|
||||||
|
// 新版本不再区分应用,统一返回所有技能
|
||||||
|
let _ = parse_app_type(&app)?; // 验证 app 参数有效
|
||||||
|
get_skills(service, app_state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 安装技能(兼容旧 API)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn install_skill(
|
||||||
|
directory: String,
|
||||||
|
service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
install_skill_for_app("claude".to_string(), directory, service, app_state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 安装指定应用的技能(兼容旧 API)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn install_skill_for_app(
|
||||||
|
app: String,
|
||||||
|
directory: String,
|
||||||
|
service: State<'_, SkillServiceState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_type = parse_app_type(&app)?;
|
||||||
|
|
||||||
|
// 先获取技能信息
|
||||||
|
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
||||||
|
let skills = service
|
||||||
|
.0
|
||||||
|
.discover_available(repos)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let skill = skills
|
||||||
|
.into_iter()
|
||||||
|
.find(|s| {
|
||||||
|
let install_name = std::path::Path::new(&s.directory)
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| s.directory.clone());
|
||||||
|
install_name.eq_ignore_ascii_case(&directory)
|
||||||
|
|| s.directory.eq_ignore_ascii_case(&directory)
|
||||||
|
})
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format_skill_error(
|
||||||
|
"SKILL_NOT_FOUND",
|
||||||
|
&[("directory", &directory)],
|
||||||
|
Some("checkRepoUrl"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
service
|
||||||
|
.0
|
||||||
|
.install(&app_state.db, &skill, &app_type)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 卸载技能(兼容旧 API)
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn uninstall_skill(directory: String, app_state: State<'_, AppState>) -> Result<bool, String> {
|
||||||
|
uninstall_skill_for_app("claude".to_string(), directory, app_state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 卸载指定应用的技能(兼容旧 API)
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn uninstall_skill_for_app(
|
||||||
|
app: String,
|
||||||
|
directory: String,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let _ = parse_app_type(&app)?; // 验证参数
|
||||||
|
|
||||||
|
// 通过 directory 找到对应的 skill id
|
||||||
|
let skills = SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let skill = skills
|
||||||
|
.into_iter()
|
||||||
|
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
|
||||||
|
.ok_or_else(|| format!("未找到已安装的 Skill: {directory}"))?;
|
||||||
|
|
||||||
|
SkillService::uninstall(&app_state.db, &skill.id).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 仓库管理命令 ==========
|
||||||
|
|
||||||
|
/// 获取技能仓库列表
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_skill_repos(app_state: State<'_, AppState>) -> Result<Vec<SkillRepo>, String> {
|
||||||
|
app_state.db.get_skill_repos().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加技能仓库
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn add_skill_repo(repo: SkillRepo, app_state: State<'_, AppState>) -> Result<bool, String> {
|
||||||
|
app_state
|
||||||
|
.db
|
||||||
|
.save_skill_repo(&repo)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除技能仓库
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn remove_skill_repo(
|
||||||
|
owner: String,
|
||||||
|
name: String,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
app_state
|
||||||
|
.db
|
||||||
|
.delete_skill_repo(&owner, &name)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
//! 流式健康检查命令
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::services::stream_check::{
|
||||||
|
HealthStatus, StreamCheckConfig, StreamCheckResult, StreamCheckService,
|
||||||
|
};
|
||||||
|
use crate::store::AppState;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
/// 流式健康检查(单个供应商)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn stream_check_provider(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app_type: AppType,
|
||||||
|
provider_id: String,
|
||||||
|
) -> Result<StreamCheckResult, AppError> {
|
||||||
|
let config = state.db.get_stream_check_config()?;
|
||||||
|
|
||||||
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
|
let provider = providers
|
||||||
|
.get(&provider_id)
|
||||||
|
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
|
||||||
|
|
||||||
|
let result = StreamCheckService::check_with_retry(&app_type, provider, &config).await?;
|
||||||
|
|
||||||
|
// 记录日志
|
||||||
|
let _ =
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.save_stream_check_log(&provider_id, &provider.name, app_type.as_str(), &result);
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量流式健康检查
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn stream_check_all_providers(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app_type: AppType,
|
||||||
|
proxy_targets_only: bool,
|
||||||
|
) -> Result<Vec<(String, StreamCheckResult)>, AppError> {
|
||||||
|
let config = state.db.get_stream_check_config()?;
|
||||||
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let allowed_ids: Option<HashSet<String>> = if proxy_targets_only {
|
||||||
|
let mut ids = HashSet::new();
|
||||||
|
if let Ok(Some(current_id)) = state.db.get_current_provider(app_type.as_str()) {
|
||||||
|
ids.insert(current_id);
|
||||||
|
}
|
||||||
|
if let Ok(queue) = state.db.get_failover_queue(app_type.as_str()) {
|
||||||
|
for item in queue {
|
||||||
|
ids.insert(item.provider_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(ids)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
for (id, provider) in providers {
|
||||||
|
if let Some(ids) = &allowed_ids {
|
||||||
|
if !ids.contains(&id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = StreamCheckService::check_with_retry(&app_type, &provider, &config)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| StreamCheckResult {
|
||||||
|
status: HealthStatus::Failed,
|
||||||
|
success: false,
|
||||||
|
message: e.to_string(),
|
||||||
|
response_time_ms: None,
|
||||||
|
http_status: None,
|
||||||
|
model_used: String::new(),
|
||||||
|
tested_at: chrono::Utc::now().timestamp(),
|
||||||
|
retry_count: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = state
|
||||||
|
.db
|
||||||
|
.save_stream_check_log(&id, &provider.name, app_type.as_str(), &result);
|
||||||
|
|
||||||
|
results.push((id, result));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取流式检查配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_stream_check_config(state: State<'_, AppState>) -> Result<StreamCheckConfig, AppError> {
|
||||||
|
state.db.get_stream_check_config()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存流式检查配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_stream_check_config(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
config: StreamCheckConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
state.db.save_stream_check_config(&config)
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
//! 使用统计相关命令
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::services::usage_stats::*;
|
||||||
|
use crate::store::AppState;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
/// 获取使用量汇总
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_usage_summary(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
start_date: Option<i64>,
|
||||||
|
end_date: Option<i64>,
|
||||||
|
) -> Result<UsageSummary, AppError> {
|
||||||
|
state.db.get_usage_summary(start_date, end_date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取每日趋势
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_usage_trends(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
start_date: Option<i64>,
|
||||||
|
end_date: Option<i64>,
|
||||||
|
) -> Result<Vec<DailyStats>, AppError> {
|
||||||
|
state.db.get_daily_trends(start_date, end_date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Provider 统计
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_provider_stats(state: State<'_, AppState>) -> Result<Vec<ProviderStats>, AppError> {
|
||||||
|
state.db.get_provider_stats()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取模型统计
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_model_stats(state: State<'_, AppState>) -> Result<Vec<ModelStats>, AppError> {
|
||||||
|
state.db.get_model_stats()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取请求日志列表
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_request_logs(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
filters: LogFilters,
|
||||||
|
page: u32,
|
||||||
|
page_size: u32,
|
||||||
|
) -> Result<PaginatedLogs, AppError> {
|
||||||
|
state.db.get_request_logs(&filters, page, page_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取单个请求详情
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_request_detail(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
request_id: String,
|
||||||
|
) -> Result<Option<RequestLogDetail>, AppError> {
|
||||||
|
state.db.get_request_detail(&request_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取模型定价列表
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_model_pricing(state: State<'_, AppState>) -> Result<Vec<ModelPricingInfo>, AppError> {
|
||||||
|
log::info!("获取模型定价列表");
|
||||||
|
state.db.ensure_model_pricing_seeded()?;
|
||||||
|
|
||||||
|
let db = state.db.clone();
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
|
||||||
|
// 检查表是否存在
|
||||||
|
let table_exists: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='model_pricing'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0).map(|count| count > 0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !table_exists {
|
||||||
|
log::error!("model_pricing 表不存在,可能需要重启应用以触发数据库迁移");
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT model_id, display_name, input_cost_per_million, output_cost_per_million,
|
||||||
|
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||||
|
FROM model_pricing
|
||||||
|
ORDER BY display_name",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
Ok(ModelPricingInfo {
|
||||||
|
model_id: row.get(0)?,
|
||||||
|
display_name: row.get(1)?,
|
||||||
|
input_cost_per_million: row.get(2)?,
|
||||||
|
output_cost_per_million: row.get(3)?,
|
||||||
|
cache_read_cost_per_million: row.get(4)?,
|
||||||
|
cache_creation_cost_per_million: row.get(5)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut pricing = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
pricing.push(row?);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("成功获取 {} 条模型定价数据", pricing.len());
|
||||||
|
Ok(pricing)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新模型定价
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_model_pricing(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
model_id: String,
|
||||||
|
display_name: String,
|
||||||
|
input_cost: String,
|
||||||
|
output_cost: String,
|
||||||
|
cache_read_cost: String,
|
||||||
|
cache_creation_cost: String,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let db = state.db.clone();
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO model_pricing (
|
||||||
|
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
||||||
|
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
|
rusqlite::params![
|
||||||
|
model_id,
|
||||||
|
display_name,
|
||||||
|
input_cost,
|
||||||
|
output_cost,
|
||||||
|
cache_read_cost,
|
||||||
|
cache_creation_cost
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(format!("更新模型定价失败: {e}")))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查 Provider 使用限额
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn check_provider_limits(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
provider_id: String,
|
||||||
|
app_type: String,
|
||||||
|
) -> Result<crate::services::usage_stats::ProviderLimitStatus, AppError> {
|
||||||
|
state.db.check_provider_limits(&provider_id, &app_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除模型定价
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_model_pricing(state: State<'_, AppState>, model_id: String) -> Result<(), AppError> {
|
||||||
|
let db = state.db.clone();
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM model_pricing WHERE model_id = ?1",
|
||||||
|
rusqlite::params![model_id],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(format!("删除模型定价失败: {e}")))?;
|
||||||
|
|
||||||
|
log::info!("已删除模型定价: {model_id}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模型定价信息
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ModelPricingInfo {
|
||||||
|
pub model_id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub input_cost_per_million: String,
|
||||||
|
pub output_cost_per_million: String,
|
||||||
|
pub cache_read_cost_per_million: String,
|
||||||
|
pub cache_creation_cost_per_million: String,
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let parent = dir.parent().unwrap_or_else(|| Path::new(""));
|
let parent = dir.parent().unwrap_or_else(|| Path::new(""));
|
||||||
Some(parent.join(format!("{}.json", file_name)))
|
Some(parent.join(format!("{file_name}.json")))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 Claude MCP 配置文件路径,若设置了目录覆盖则与覆盖目录同级
|
/// 获取 Claude MCP 配置文件路径,若设置了目录覆盖则与覆盖目录同级
|
||||||
@@ -79,6 +79,7 @@ pub fn get_app_config_path() -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 清理供应商名称,确保文件名安全
|
/// 清理供应商名称,确保文件名安全
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn sanitize_provider_name(name: &str) -> String {
|
pub fn sanitize_provider_name(name: &str) -> String {
|
||||||
name.chars()
|
name.chars()
|
||||||
.map(|c| match c {
|
.map(|c| match c {
|
||||||
@@ -90,12 +91,13 @@ pub fn sanitize_provider_name(name: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 获取供应商配置文件路径
|
/// 获取供应商配置文件路径
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) -> PathBuf {
|
pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) -> PathBuf {
|
||||||
let base_name = provider_name
|
let base_name = provider_name
|
||||||
.map(sanitize_provider_name)
|
.map(sanitize_provider_name)
|
||||||
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
.unwrap_or_else(|| sanitize_provider_name(provider_id));
|
||||||
|
|
||||||
get_claude_config_dir().join(format!("settings-{}.json", base_name))
|
get_claude_config_dir().join(format!("settings-{base_name}.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读取 JSON 配置文件
|
/// 读取 JSON 配置文件
|
||||||
@@ -149,7 +151,7 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> {
|
|||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_nanos();
|
.as_nanos();
|
||||||
tmp.push(format!("{}.tmp.{}", file_name, ts));
|
tmp.push(format!("{file_name}.tmp.{ts}"));
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?;
|
let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?;
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
//! 数据库备份和恢复
|
||||||
|
//!
|
||||||
|
//! 提供 SQL 导出/导入和二进制快照备份功能。
|
||||||
|
|
||||||
|
use super::{lock_conn, Database, DB_BACKUP_RETAIN};
|
||||||
|
use crate::config::get_app_config_dir;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use chrono::Utc;
|
||||||
|
use rusqlite::backup::Backup;
|
||||||
|
use rusqlite::types::ValueRef;
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
/// 导出为 SQLite 兼容的 SQL 文本
|
||||||
|
pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
|
||||||
|
let snapshot = self.snapshot_to_memory()?;
|
||||||
|
let dump = Self::dump_sql(&snapshot)?;
|
||||||
|
|
||||||
|
if let Some(parent) = target_path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::config::atomic_write(target_path, dump.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 SQL 文件导入,返回生成的备份 ID(若无备份则为空字符串)
|
||||||
|
pub fn import_sql(&self, source_path: &Path) -> Result<String, AppError> {
|
||||||
|
if !source_path.exists() {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"SQL 文件不存在: {}",
|
||||||
|
source_path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?;
|
||||||
|
let sql_content = sql_raw.trim_start_matches('\u{feff}');
|
||||||
|
Self::validate_cc_switch_sql_export(sql_content)?;
|
||||||
|
|
||||||
|
// 导入前备份现有数据库
|
||||||
|
let backup_path = self.backup_database_file()?;
|
||||||
|
|
||||||
|
// 在临时数据库执行导入,确保失败不会污染主库
|
||||||
|
let temp_file = NamedTempFile::new().map_err(|e| AppError::IoContext {
|
||||||
|
context: "创建临时数据库文件失败".to_string(),
|
||||||
|
source: e,
|
||||||
|
})?;
|
||||||
|
let temp_path = temp_file.path().to_path_buf();
|
||||||
|
let temp_conn =
|
||||||
|
Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
temp_conn
|
||||||
|
.execute_batch(sql_content)
|
||||||
|
.map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?;
|
||||||
|
|
||||||
|
// 补齐缺失表/索引并进行基础校验
|
||||||
|
Self::create_tables_on_conn(&temp_conn)?;
|
||||||
|
Self::apply_schema_migrations_on_conn(&temp_conn)?;
|
||||||
|
Self::validate_basic_state(&temp_conn)?;
|
||||||
|
|
||||||
|
// 使用 Backup 将临时库原子写回主库
|
||||||
|
{
|
||||||
|
let mut main_conn = lock_conn!(self.conn);
|
||||||
|
let backup = Backup::new(&temp_conn, &mut main_conn)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
backup
|
||||||
|
.step(-1)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let backup_id = backup_path
|
||||||
|
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(backup_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建内存快照以避免长时间持有数据库锁
|
||||||
|
pub(crate) fn snapshot_to_memory(&self) -> Result<Connection, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let mut snapshot =
|
||||||
|
Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let backup =
|
||||||
|
Backup::new(&conn, &mut snapshot).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
backup
|
||||||
|
.step(-1)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_cc_switch_sql_export(sql: &str) -> Result<(), AppError> {
|
||||||
|
let trimmed = sql.trim_start();
|
||||||
|
if trimmed.starts_with(CC_SWITCH_SQL_EXPORT_HEADER) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AppError::localized(
|
||||||
|
"backup.sql.invalid_format",
|
||||||
|
"仅支持导入由 CC Switch 导出的 SQL 备份文件。",
|
||||||
|
"Only SQL backups exported by CC Switch are supported.",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
|
||||||
|
fn backup_database_file(&self) -> Result<Option<PathBuf>, AppError> {
|
||||||
|
let db_path = get_app_config_dir().join("cc-switch.db");
|
||||||
|
if !db_path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let backup_dir = db_path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| AppError::Config("无效的数据库路径".to_string()))?
|
||||||
|
.join("backups");
|
||||||
|
|
||||||
|
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
|
||||||
|
|
||||||
|
let base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
|
||||||
|
let mut backup_id = base_id.clone();
|
||||||
|
let mut backup_path = backup_dir.join(format!("{backup_id}.db"));
|
||||||
|
let mut counter = 1;
|
||||||
|
while backup_path.exists() {
|
||||||
|
backup_id = format!("{base_id}_{counter}");
|
||||||
|
backup_path = backup_dir.join(format!("{backup_id}.db"));
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let mut dest_conn =
|
||||||
|
Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
let backup = Backup::new(&conn, &mut dest_conn)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
backup
|
||||||
|
.step(-1)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::cleanup_db_backups(&backup_dir)?;
|
||||||
|
Ok(Some(backup_path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清理旧的数据库备份,保留最新的 N 个
|
||||||
|
fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> {
|
||||||
|
let entries = match fs::read_dir(dir) {
|
||||||
|
Ok(iter) => iter
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.filter(|entry| {
|
||||||
|
entry
|
||||||
|
.path()
|
||||||
|
.extension()
|
||||||
|
.map(|ext| ext == "db")
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
Err(_) => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if entries.len() <= DB_BACKUP_RETAIN {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let remove_count = entries.len().saturating_sub(DB_BACKUP_RETAIN);
|
||||||
|
let mut sorted = entries;
|
||||||
|
sorted.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok());
|
||||||
|
|
||||||
|
for entry in sorted.into_iter().take(remove_count) {
|
||||||
|
if let Err(err) = fs::remove_file(entry.path()) {
|
||||||
|
log::warn!("删除旧数据库备份失败 {}: {}", entry.path().display(), err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 基础状态校验
|
||||||
|
fn validate_basic_state(conn: &Connection) -> Result<(), AppError> {
|
||||||
|
let provider_count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
let mcp_count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0))
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
if provider_count == 0 && mcp_count == 0 {
|
||||||
|
return Err(AppError::Config(
|
||||||
|
"导入的 SQL 未包含有效的供应商或 MCP 数据".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 导出数据库为 SQL 文本
|
||||||
|
fn dump_sql(conn: &Connection) -> Result<String, AppError> {
|
||||||
|
let mut output = String::new();
|
||||||
|
let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||||
|
let user_version: i64 = conn
|
||||||
|
.query_row("PRAGMA user_version;", [], |row| row.get(0))
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
output.push_str(&format!(
|
||||||
|
"-- CC Switch SQLite 导出\n-- 生成时间: {timestamp}\n-- user_version: {user_version}\n"
|
||||||
|
));
|
||||||
|
output.push_str("PRAGMA foreign_keys=OFF;\n");
|
||||||
|
output.push_str(&format!("PRAGMA user_version={user_version};\n"));
|
||||||
|
output.push_str("BEGIN TRANSACTION;\n");
|
||||||
|
|
||||||
|
// 导出 schema
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT type, name, tbl_name, sql
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE sql NOT NULL AND type IN ('table','index','trigger','view')
|
||||||
|
ORDER BY type='table' DESC, name",
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut tables = Vec::new();
|
||||||
|
let mut rows = stmt
|
||||||
|
.query([])
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
||||||
|
let obj_type: String = row.get(0).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
let name: String = row.get(1).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
let sql: String = row.get(3).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 跳过 SQLite 内部对象(如 sqlite_sequence)
|
||||||
|
if name.starts_with("sqlite_") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push_str(&sql);
|
||||||
|
output.push_str(";\n");
|
||||||
|
|
||||||
|
if obj_type == "table" && !name.starts_with("sqlite_") {
|
||||||
|
tables.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出数据
|
||||||
|
for table in tables {
|
||||||
|
let columns = Self::get_table_columns(conn, &table)?;
|
||||||
|
if columns.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(&format!("SELECT * FROM \"{table}\""))
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
let mut rows = stmt
|
||||||
|
.query([])
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
||||||
|
let mut values = Vec::with_capacity(columns.len());
|
||||||
|
for idx in 0..columns.len() {
|
||||||
|
let value = row
|
||||||
|
.get_ref(idx)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
values.push(Self::format_sql_value(value)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cols = columns
|
||||||
|
.iter()
|
||||||
|
.map(|c| format!("\"{c}\""))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
output.push_str(&format!(
|
||||||
|
"INSERT INTO \"{table}\" ({cols}) VALUES ({});\n",
|
||||||
|
values.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push_str("COMMIT;\nPRAGMA foreign_keys=ON;\n");
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取表的列名列表
|
||||||
|
fn get_table_columns(conn: &Connection, table: &str) -> Result<Vec<String>, AppError> {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(&format!("PRAGMA table_info(\"{table}\")"))
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
let iter = stmt
|
||||||
|
.query_map([], |row| row.get::<_, String>(1))
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut columns = Vec::new();
|
||||||
|
for col in iter {
|
||||||
|
columns.push(col.map_err(|e| AppError::Database(e.to_string()))?);
|
||||||
|
}
|
||||||
|
Ok(columns)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 格式化 SQL 值
|
||||||
|
fn format_sql_value(value: ValueRef<'_>) -> Result<String, AppError> {
|
||||||
|
match value {
|
||||||
|
ValueRef::Null => Ok("NULL".to_string()),
|
||||||
|
ValueRef::Integer(i) => Ok(i.to_string()),
|
||||||
|
ValueRef::Real(f) => Ok(f.to_string()),
|
||||||
|
ValueRef::Text(t) => {
|
||||||
|
let text = std::str::from_utf8(t)
|
||||||
|
.map_err(|e| AppError::Database(format!("文本字段不是有效的 UTF-8: {e}")))?;
|
||||||
|
let escaped = text.replace('\'', "''");
|
||||||
|
Ok(format!("'{escaped}'"))
|
||||||
|
}
|
||||||
|
ValueRef::Blob(bytes) => {
|
||||||
|
let mut s = String::from("X'");
|
||||||
|
for b in bytes {
|
||||||
|
use std::fmt::Write;
|
||||||
|
let _ = write!(&mut s, "{b:02X}");
|
||||||
|
}
|
||||||
|
s.push('\'');
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
//! 故障转移队列 DAO
|
||||||
|
//!
|
||||||
|
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
|
||||||
|
|
||||||
|
use crate::database::{lock_conn, Database};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// 故障转移队列条目(简化版,用于前端展示)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FailoverQueueItem {
|
||||||
|
pub provider_id: String,
|
||||||
|
pub provider_name: String,
|
||||||
|
pub sort_index: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
/// 获取故障转移队列(按 sort_index 排序)
|
||||||
|
pub fn get_failover_queue(&self, app_type: &str) -> Result<Vec<FailoverQueueItem>, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, name, sort_index
|
||||||
|
FROM providers
|
||||||
|
WHERE app_type = ?1 AND in_failover_queue = 1
|
||||||
|
ORDER BY COALESCE(sort_index, 999999), id ASC",
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let items = stmt
|
||||||
|
.query_map([app_type], |row| {
|
||||||
|
Ok(FailoverQueueItem {
|
||||||
|
provider_id: row.get(0)?,
|
||||||
|
provider_name: row.get(1)?,
|
||||||
|
sort_index: row.get(2)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取故障转移队列中的供应商(完整 Provider 信息,按顺序)
|
||||||
|
pub fn get_failover_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
||||||
|
let all_providers = self.get_all_providers(app_type)?;
|
||||||
|
|
||||||
|
let result: Vec<Provider> = all_providers
|
||||||
|
.into_values()
|
||||||
|
.filter(|p| p.in_failover_queue)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加供应商到故障转移队列
|
||||||
|
pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE providers SET in_failover_queue = 1 WHERE id = ?1 AND app_type = ?2",
|
||||||
|
rusqlite::params![provider_id, app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从故障转移队列中移除供应商
|
||||||
|
pub fn remove_from_failover_queue(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
provider_id: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
// 1. 从队列中移除
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE providers SET in_failover_queue = 0 WHERE id = ?1 AND app_type = ?2",
|
||||||
|
rusqlite::params![provider_id, app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 2. 清除该供应商的健康状态(退出队列后不再需要健康监控)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2",
|
||||||
|
rusqlite::params![provider_id, app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
log::info!("已从故障转移队列移除供应商 {provider_id} ({app_type}), 并清除其健康状态");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空故障转移队列
|
||||||
|
pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE providers SET in_failover_queue = 0 WHERE app_type = ?1",
|
||||||
|
[app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查供应商是否在故障转移队列中
|
||||||
|
pub fn is_in_failover_queue(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
provider_id: &str,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
let in_queue: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||||
|
rusqlite::params![provider_id, app_type],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
Ok(in_queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取可添加到故障转移队列的供应商(不在队列中的)
|
||||||
|
pub fn get_available_providers_for_failover(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
) -> Result<Vec<Provider>, AppError> {
|
||||||
|
let all_providers = self.get_all_providers(app_type)?;
|
||||||
|
|
||||||
|
let available: Vec<Provider> = all_providers
|
||||||
|
.into_values()
|
||||||
|
.filter(|p| !p.in_failover_queue)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(available)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//! MCP 服务器数据访问对象
|
||||||
|
//!
|
||||||
|
//! 提供 MCP 服务器的 CRUD 操作。
|
||||||
|
|
||||||
|
use crate::app_config::{McpApps, McpServer};
|
||||||
|
use crate::database::{lock_conn, Database};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use rusqlite::params;
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
/// 获取所有 MCP 服务器
|
||||||
|
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
|
||||||
|
FROM mcp_servers
|
||||||
|
ORDER BY name ASC, id ASC"
|
||||||
|
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let server_iter = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
let id: String = row.get(0)?;
|
||||||
|
let name: String = row.get(1)?;
|
||||||
|
let server_config_str: String = row.get(2)?;
|
||||||
|
let description: Option<String> = row.get(3)?;
|
||||||
|
let homepage: Option<String> = row.get(4)?;
|
||||||
|
let docs: Option<String> = row.get(5)?;
|
||||||
|
let tags_str: String = row.get(6)?;
|
||||||
|
let enabled_claude: bool = row.get(7)?;
|
||||||
|
let enabled_codex: bool = row.get(8)?;
|
||||||
|
let enabled_gemini: bool = row.get(9)?;
|
||||||
|
|
||||||
|
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
|
||||||
|
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
id.clone(),
|
||||||
|
McpServer {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
server,
|
||||||
|
apps: McpApps {
|
||||||
|
claude: enabled_claude,
|
||||||
|
codex: enabled_codex,
|
||||||
|
gemini: enabled_gemini,
|
||||||
|
},
|
||||||
|
description,
|
||||||
|
homepage,
|
||||||
|
docs,
|
||||||
|
tags,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut servers = IndexMap::new();
|
||||||
|
for server_res in server_iter {
|
||||||
|
let (id, server) = server_res.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
servers.insert(id, server);
|
||||||
|
}
|
||||||
|
Ok(servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存 MCP 服务器
|
||||||
|
pub fn save_mcp_server(&self, server: &McpServer) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO mcp_servers (
|
||||||
|
id, name, server_config, description, homepage, docs, tags,
|
||||||
|
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).unwrap(),
|
||||||
|
server.description,
|
||||||
|
server.homepage,
|
||||||
|
server.docs,
|
||||||
|
serde_json::to_string(&server.tags).unwrap(),
|
||||||
|
server.apps.claude,
|
||||||
|
server.apps.codex,
|
||||||
|
server.apps.gemini,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除 MCP 服务器
|
||||||
|
pub fn delete_mcp_server(&self, id: &str) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.execute("DELETE FROM mcp_servers WHERE id = ?1", params![id])
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//! Data Access Object layer
|
||||||
|
//!
|
||||||
|
//! Database access operations for each domain
|
||||||
|
|
||||||
|
pub mod failover;
|
||||||
|
pub mod mcp;
|
||||||
|
pub mod prompts;
|
||||||
|
pub mod providers;
|
||||||
|
pub mod proxy;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod skills;
|
||||||
|
pub mod stream_check;
|
||||||
|
pub mod universal_providers;
|
||||||
|
|
||||||
|
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||||
|
// 导出 FailoverQueueItem 供外部使用
|
||||||
|
pub use failover::FailoverQueueItem;
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//! 提示词数据访问对象
|
||||||
|
//!
|
||||||
|
//! 提供提示词(Prompt)的 CRUD 操作。
|
||||||
|
|
||||||
|
use crate::database::{lock_conn, Database};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::prompt::Prompt;
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use rusqlite::params;
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
/// 获取指定应用类型的所有提示词
|
||||||
|
pub fn get_prompts(&self, app_type: &str) -> Result<IndexMap<String, Prompt>, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, name, content, description, enabled, created_at, updated_at
|
||||||
|
FROM prompts WHERE app_type = ?1
|
||||||
|
ORDER BY created_at ASC, id ASC",
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let prompt_iter = stmt
|
||||||
|
.query_map(params![app_type], |row| {
|
||||||
|
let id: String = row.get(0)?;
|
||||||
|
let name: String = row.get(1)?;
|
||||||
|
let content: String = row.get(2)?;
|
||||||
|
let description: Option<String> = row.get(3)?;
|
||||||
|
let enabled: bool = row.get(4)?;
|
||||||
|
let created_at: Option<i64> = row.get(5)?;
|
||||||
|
let updated_at: Option<i64> = row.get(6)?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
id.clone(),
|
||||||
|
Prompt {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
content,
|
||||||
|
description,
|
||||||
|
enabled,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut prompts = IndexMap::new();
|
||||||
|
for prompt_res in prompt_iter {
|
||||||
|
let (id, prompt) = prompt_res.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
prompts.insert(id, prompt);
|
||||||
|
}
|
||||||
|
Ok(prompts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存提示词
|
||||||
|
pub fn save_prompt(&self, app_type: &str, prompt: &Prompt) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO prompts (
|
||||||
|
id, app_type, name, content, description, enabled, created_at, updated_at
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||||
|
params![
|
||||||
|
prompt.id,
|
||||||
|
app_type,
|
||||||
|
prompt.name,
|
||||||
|
prompt.content,
|
||||||
|
prompt.description,
|
||||||
|
prompt.enabled,
|
||||||
|
prompt.created_at,
|
||||||
|
prompt.updated_at,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除提示词
|
||||||
|
pub fn delete_prompt(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM prompts WHERE id = ?1 AND app_type = ?2",
|
||||||
|
params![id, app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
//! 供应商数据访问对象
|
||||||
|
//!
|
||||||
|
//! 提供供应商(Provider)的 CRUD 操作。
|
||||||
|
|
||||||
|
use crate::database::{lock_conn, Database};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::provider::{Provider, ProviderMeta};
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use rusqlite::params;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
/// 获取指定应用类型的所有供应商
|
||||||
|
pub fn get_all_providers(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
) -> Result<IndexMap<String, Provider>, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
|
||||||
|
FROM providers WHERE app_type = ?1
|
||||||
|
ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC"
|
||||||
|
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let provider_iter = stmt
|
||||||
|
.query_map(params![app_type], |row| {
|
||||||
|
let id: String = row.get(0)?;
|
||||||
|
let name: String = row.get(1)?;
|
||||||
|
let settings_config_str: String = row.get(2)?;
|
||||||
|
let website_url: Option<String> = row.get(3)?;
|
||||||
|
let category: Option<String> = row.get(4)?;
|
||||||
|
let created_at: Option<i64> = row.get(5)?;
|
||||||
|
let sort_index: Option<usize> = row.get(6)?;
|
||||||
|
let notes: Option<String> = row.get(7)?;
|
||||||
|
let icon: Option<String> = row.get(8)?;
|
||||||
|
let icon_color: Option<String> = row.get(9)?;
|
||||||
|
let meta_str: String = row.get(10)?;
|
||||||
|
let in_failover_queue: bool = row.get(11)?;
|
||||||
|
|
||||||
|
let settings_config =
|
||||||
|
serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
||||||
|
let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
id,
|
||||||
|
Provider {
|
||||||
|
id: "".to_string(), // Placeholder, set below
|
||||||
|
name,
|
||||||
|
settings_config,
|
||||||
|
website_url,
|
||||||
|
category,
|
||||||
|
created_at,
|
||||||
|
sort_index,
|
||||||
|
notes,
|
||||||
|
meta: Some(meta),
|
||||||
|
icon,
|
||||||
|
icon_color,
|
||||||
|
in_failover_queue,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut providers = IndexMap::new();
|
||||||
|
for provider_res in provider_iter {
|
||||||
|
let (id, mut provider) = provider_res.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
provider.id = id.clone();
|
||||||
|
|
||||||
|
// 加载 endpoints
|
||||||
|
let mut stmt_endpoints = conn.prepare(
|
||||||
|
"SELECT url, added_at FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 ORDER BY added_at ASC, url ASC"
|
||||||
|
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let endpoints_iter = stmt_endpoints
|
||||||
|
.query_map(params![id, app_type], |row| {
|
||||||
|
let url: String = row.get(0)?;
|
||||||
|
let added_at: Option<i64> = row.get(1)?;
|
||||||
|
Ok((
|
||||||
|
url,
|
||||||
|
crate::settings::CustomEndpoint {
|
||||||
|
url: "".to_string(),
|
||||||
|
added_at: added_at.unwrap_or(0),
|
||||||
|
last_used: None,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut custom_endpoints = HashMap::new();
|
||||||
|
for ep_res in endpoints_iter {
|
||||||
|
let (url, mut ep) = ep_res.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
ep.url = url.clone();
|
||||||
|
custom_endpoints.insert(url, ep);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(meta) = &mut provider.meta {
|
||||||
|
meta.custom_endpoints = custom_endpoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
providers.insert(id, provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(providers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前激活的供应商 ID
|
||||||
|
pub fn get_current_provider(&self, app_type: &str) -> Result<Option<String>, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT id FROM providers WHERE app_type = ?1 AND is_current = 1 LIMIT 1")
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut rows = stmt
|
||||||
|
.query(params![app_type])
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
||||||
|
Ok(Some(
|
||||||
|
row.get(0).map_err(|e| AppError::Database(e.to_string()))?,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据 ID 获取单个供应商
|
||||||
|
pub fn get_provider_by_id(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
app_type: &str,
|
||||||
|
) -> Result<Option<Provider>, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let result = conn.query_row(
|
||||||
|
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
|
||||||
|
FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||||
|
params![id, app_type],
|
||||||
|
|row| {
|
||||||
|
let name: String = row.get(0)?;
|
||||||
|
let settings_config_str: String = row.get(1)?;
|
||||||
|
let website_url: Option<String> = row.get(2)?;
|
||||||
|
let category: Option<String> = row.get(3)?;
|
||||||
|
let created_at: Option<i64> = row.get(4)?;
|
||||||
|
let sort_index: Option<usize> = row.get(5)?;
|
||||||
|
let notes: Option<String> = row.get(6)?;
|
||||||
|
let icon: Option<String> = row.get(7)?;
|
||||||
|
let icon_color: Option<String> = row.get(8)?;
|
||||||
|
let meta_str: String = row.get(9)?;
|
||||||
|
let in_failover_queue: bool = row.get(10)?;
|
||||||
|
|
||||||
|
let settings_config = serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
||||||
|
let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(Provider {
|
||||||
|
id: id.to_string(),
|
||||||
|
name,
|
||||||
|
settings_config,
|
||||||
|
website_url,
|
||||||
|
category,
|
||||||
|
created_at,
|
||||||
|
sort_index,
|
||||||
|
notes,
|
||||||
|
meta: Some(meta),
|
||||||
|
icon,
|
||||||
|
icon_color,
|
||||||
|
in_failover_queue,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(provider) => Ok(Some(provider)),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存供应商(新增或更新)
|
||||||
|
///
|
||||||
|
/// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理
|
||||||
|
/// (add_custom_endpoint / remove_custom_endpoint),避免覆盖用户的修改。
|
||||||
|
pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> {
|
||||||
|
let mut conn = lock_conn!(self.conn);
|
||||||
|
let tx = conn
|
||||||
|
.transaction()
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 处理 meta:取出 endpoints 以便单独处理
|
||||||
|
let mut meta_clone = provider.meta.clone().unwrap_or_default();
|
||||||
|
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
|
||||||
|
|
||||||
|
// 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue)
|
||||||
|
let existing: Option<(bool, bool)> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||||
|
params![provider.id, app_type],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
let is_update = existing.is_some();
|
||||||
|
let (is_current, in_failover_queue) =
|
||||||
|
existing.unwrap_or((false, provider.in_failover_queue));
|
||||||
|
|
||||||
|
if is_update {
|
||||||
|
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE providers SET
|
||||||
|
name = ?1,
|
||||||
|
settings_config = ?2,
|
||||||
|
website_url = ?3,
|
||||||
|
category = ?4,
|
||||||
|
created_at = ?5,
|
||||||
|
sort_index = ?6,
|
||||||
|
notes = ?7,
|
||||||
|
icon = ?8,
|
||||||
|
icon_color = ?9,
|
||||||
|
meta = ?10,
|
||||||
|
is_current = ?11,
|
||||||
|
in_failover_queue = ?12
|
||||||
|
WHERE id = ?13 AND app_type = ?14",
|
||||||
|
params![
|
||||||
|
provider.name,
|
||||||
|
serde_json::to_string(&provider.settings_config).unwrap(),
|
||||||
|
provider.website_url,
|
||||||
|
provider.category,
|
||||||
|
provider.created_at,
|
||||||
|
provider.sort_index,
|
||||||
|
provider.notes,
|
||||||
|
provider.icon,
|
||||||
|
provider.icon_color,
|
||||||
|
serde_json::to_string(&meta_clone).unwrap(),
|
||||||
|
is_current,
|
||||||
|
in_failover_queue,
|
||||||
|
provider.id,
|
||||||
|
app_type,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
} else {
|
||||||
|
// 新增模式:使用 INSERT
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO providers (
|
||||||
|
id, app_type, name, settings_config, website_url, category,
|
||||||
|
created_at, sort_index, notes, icon, icon_color, meta, is_current, in_failover_queue
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||||
|
params![
|
||||||
|
provider.id,
|
||||||
|
app_type,
|
||||||
|
provider.name,
|
||||||
|
serde_json::to_string(&provider.settings_config).unwrap(),
|
||||||
|
provider.website_url,
|
||||||
|
provider.category,
|
||||||
|
provider.created_at,
|
||||||
|
provider.sort_index,
|
||||||
|
provider.notes,
|
||||||
|
provider.icon,
|
||||||
|
provider.icon_color,
|
||||||
|
serde_json::to_string(&meta_clone).unwrap(),
|
||||||
|
is_current,
|
||||||
|
in_failover_queue,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 只有新增时才同步 endpoints
|
||||||
|
for (url, endpoint) in endpoints {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
params![provider.id, app_type, url, endpoint.added_at],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除供应商
|
||||||
|
pub fn delete_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||||
|
params![id, app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置当前供应商
|
||||||
|
pub fn set_current_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
||||||
|
let mut conn = lock_conn!(self.conn);
|
||||||
|
let tx = conn
|
||||||
|
.transaction()
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 重置所有为 0
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE providers SET is_current = 0 WHERE app_type = ?1",
|
||||||
|
params![app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 设置新的当前供应商
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2",
|
||||||
|
params![id, app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新供应商的 settings_config(仅更新配置,不改变其他字段)
|
||||||
|
pub fn update_provider_settings_config(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
provider_id: &str,
|
||||||
|
settings_config: &serde_json::Value,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3",
|
||||||
|
params![
|
||||||
|
serde_json::to_string(settings_config).unwrap(),
|
||||||
|
provider_id,
|
||||||
|
app_type
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加自定义端点
|
||||||
|
pub fn add_custom_endpoint(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
provider_id: &str,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let added_at = chrono::Utc::now().timestamp_millis();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
params![provider_id, app_type, url, added_at],
|
||||||
|
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 移除自定义端点
|
||||||
|
pub fn remove_custom_endpoint(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
provider_id: &str,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 AND url = ?3",
|
||||||
|
params![provider_id, app_type, url],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,617 @@
|
|||||||
|
//! 代理功能数据访问层
|
||||||
|
//!
|
||||||
|
//! 处理代理配置、Provider健康状态和使用统计的数据库操作
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::proxy::types::*;
|
||||||
|
|
||||||
|
use super::super::{lock_conn, Database};
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
// ==================== Global Proxy Config ====================
|
||||||
|
|
||||||
|
/// 获取全局代理配置(统一字段)
|
||||||
|
///
|
||||||
|
/// 从 claude 行读取(三行镜像一致)
|
||||||
|
pub async fn get_global_proxy_config(&self) -> Result<GlobalProxyConfig, AppError> {
|
||||||
|
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
|
||||||
|
let result = {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT proxy_enabled, listen_address, listen_port, enable_logging
|
||||||
|
FROM proxy_config WHERE app_type = 'claude'",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
Ok(GlobalProxyConfig {
|
||||||
|
proxy_enabled: row.get::<_, i32>(0)? != 0,
|
||||||
|
listen_address: row.get(1)?,
|
||||||
|
listen_port: row.get::<_, i32>(2)? as u16,
|
||||||
|
enable_logging: row.get::<_, i32>(3)? != 0,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// conn 已在 block 结束时释放
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(config) => Ok(config),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||||
|
// 如果不存在,创建默认配置
|
||||||
|
self.init_proxy_config_rows().await?;
|
||||||
|
Ok(GlobalProxyConfig {
|
||||||
|
proxy_enabled: false,
|
||||||
|
listen_address: "127.0.0.1".to_string(),
|
||||||
|
listen_port: 5000,
|
||||||
|
enable_logging: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新全局代理配置(镜像写三行)
|
||||||
|
pub async fn update_global_proxy_config(
|
||||||
|
&self,
|
||||||
|
config: GlobalProxyConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE proxy_config SET
|
||||||
|
proxy_enabled = ?1,
|
||||||
|
listen_address = ?2,
|
||||||
|
listen_port = ?3,
|
||||||
|
enable_logging = ?4,
|
||||||
|
updated_at = datetime('now')",
|
||||||
|
rusqlite::params![
|
||||||
|
if config.proxy_enabled { 1 } else { 0 },
|
||||||
|
config.listen_address,
|
||||||
|
config.listen_port as i32,
|
||||||
|
if config.enable_logging { 1 } else { 0 },
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取应用级代理配置
|
||||||
|
pub async fn get_proxy_config_for_app(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
) -> Result<AppProxyConfig, AppError> {
|
||||||
|
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
|
||||||
|
let app_type_owned = app_type.to_string();
|
||||||
|
let result = {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT app_type, enabled, auto_failover_enabled,
|
||||||
|
max_retries, streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||||
|
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||||
|
circuit_error_rate_threshold, circuit_min_requests
|
||||||
|
FROM proxy_config WHERE app_type = ?1",
|
||||||
|
[app_type],
|
||||||
|
|row| {
|
||||||
|
Ok(AppProxyConfig {
|
||||||
|
app_type: row.get(0)?,
|
||||||
|
enabled: row.get::<_, i32>(1)? != 0,
|
||||||
|
auto_failover_enabled: row.get::<_, i32>(2)? != 0,
|
||||||
|
max_retries: row.get::<_, i32>(3)? as u32,
|
||||||
|
streaming_first_byte_timeout: row.get::<_, i32>(4)? as u32,
|
||||||
|
streaming_idle_timeout: row.get::<_, i32>(5)? as u32,
|
||||||
|
non_streaming_timeout: row.get::<_, i32>(6)? as u32,
|
||||||
|
circuit_failure_threshold: row.get::<_, i32>(7)? as u32,
|
||||||
|
circuit_success_threshold: row.get::<_, i32>(8)? as u32,
|
||||||
|
circuit_timeout_seconds: row.get::<_, i32>(9)? as u32,
|
||||||
|
circuit_error_rate_threshold: row.get(10)?,
|
||||||
|
circuit_min_requests: row.get::<_, i32>(11)? as u32,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// conn 已在 block 结束时释放
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(config) => Ok(config),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||||
|
// 如果不存在,创建默认配置
|
||||||
|
self.init_proxy_config_rows().await?;
|
||||||
|
Ok(AppProxyConfig {
|
||||||
|
app_type: app_type_owned,
|
||||||
|
enabled: false,
|
||||||
|
auto_failover_enabled: false,
|
||||||
|
max_retries: 3,
|
||||||
|
streaming_first_byte_timeout: 60,
|
||||||
|
streaming_idle_timeout: 120,
|
||||||
|
non_streaming_timeout: 600,
|
||||||
|
circuit_failure_threshold: 4,
|
||||||
|
circuit_success_threshold: 2,
|
||||||
|
circuit_timeout_seconds: 60,
|
||||||
|
circuit_error_rate_threshold: 0.6,
|
||||||
|
circuit_min_requests: 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新应用级代理配置
|
||||||
|
pub async fn update_proxy_config_for_app(
|
||||||
|
&self,
|
||||||
|
config: AppProxyConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE proxy_config SET
|
||||||
|
enabled = ?2,
|
||||||
|
auto_failover_enabled = ?3,
|
||||||
|
max_retries = ?4,
|
||||||
|
streaming_first_byte_timeout = ?5,
|
||||||
|
streaming_idle_timeout = ?6,
|
||||||
|
non_streaming_timeout = ?7,
|
||||||
|
circuit_failure_threshold = ?8,
|
||||||
|
circuit_success_threshold = ?9,
|
||||||
|
circuit_timeout_seconds = ?10,
|
||||||
|
circuit_error_rate_threshold = ?11,
|
||||||
|
circuit_min_requests = ?12,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE app_type = ?1",
|
||||||
|
rusqlite::params![
|
||||||
|
config.app_type,
|
||||||
|
if config.enabled { 1 } else { 0 },
|
||||||
|
if config.auto_failover_enabled { 1 } else { 0 },
|
||||||
|
config.max_retries as i32,
|
||||||
|
config.streaming_first_byte_timeout as i32,
|
||||||
|
config.streaming_idle_timeout as i32,
|
||||||
|
config.non_streaming_timeout as i32,
|
||||||
|
config.circuit_failure_threshold as i32,
|
||||||
|
config.circuit_success_threshold as i32,
|
||||||
|
config.circuit_timeout_seconds as i32,
|
||||||
|
config.circuit_error_rate_threshold,
|
||||||
|
config.circuit_min_requests as i32,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始化 proxy_config 表的三行数据
|
||||||
|
async fn init_proxy_config_rows(&self) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
for app_type in &["claude", "codex", "gemini"] {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES (?1)",
|
||||||
|
[app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Legacy Proxy Config (兼容旧代码) ====================
|
||||||
|
|
||||||
|
/// 获取代理配置(兼容旧接口,返回 claude 行的配置)
|
||||||
|
pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> {
|
||||||
|
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
|
||||||
|
let result = {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT listen_address, listen_port, max_retries,
|
||||||
|
enable_logging,
|
||||||
|
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout
|
||||||
|
FROM proxy_config WHERE app_type = 'claude'",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
Ok(ProxyConfig {
|
||||||
|
listen_address: row.get(0)?,
|
||||||
|
listen_port: row.get::<_, i32>(1)? as u16,
|
||||||
|
max_retries: row.get::<_, i32>(2)? as u8,
|
||||||
|
request_timeout: 600, // 废弃字段,返回默认值
|
||||||
|
enable_logging: row.get::<_, i32>(3)? != 0,
|
||||||
|
live_takeover_active: false, // 废弃字段
|
||||||
|
streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(60) as u64,
|
||||||
|
streaming_idle_timeout: row.get::<_, i32>(5).unwrap_or(120) as u64,
|
||||||
|
non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(600) as u64,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// conn 已在 block 结束时释放
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(config) => Ok(config),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||||
|
// 如果不存在,初始化默认配置
|
||||||
|
self.init_proxy_config_rows().await?;
|
||||||
|
Ok(ProxyConfig::default())
|
||||||
|
}
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新代理配置(兼容旧接口,更新所有三行的公共字段)
|
||||||
|
pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
// 更新所有三行的公共字段
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE proxy_config SET
|
||||||
|
listen_address = ?1,
|
||||||
|
listen_port = ?2,
|
||||||
|
max_retries = ?3,
|
||||||
|
enable_logging = ?4,
|
||||||
|
streaming_first_byte_timeout = ?5,
|
||||||
|
streaming_idle_timeout = ?6,
|
||||||
|
non_streaming_timeout = ?7,
|
||||||
|
updated_at = datetime('now')",
|
||||||
|
rusqlite::params![
|
||||||
|
config.listen_address,
|
||||||
|
config.listen_port as i32,
|
||||||
|
config.max_retries as i32,
|
||||||
|
if config.enable_logging { 1 } else { 0 },
|
||||||
|
config.streaming_first_byte_timeout as i32,
|
||||||
|
config.streaming_idle_timeout as i32,
|
||||||
|
config.non_streaming_timeout as i32,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置 Live 接管状态(兼容旧版本,更新 enabled 字段)
|
||||||
|
pub async fn set_live_takeover_active(&self, _active: bool) -> Result<(), AppError> {
|
||||||
|
// 不再使用此字段,由 enabled 字段替代
|
||||||
|
// 保留空实现以兼容旧代码
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否处于 Live 接管模式
|
||||||
|
///
|
||||||
|
/// 检查是否有任一 app 的 enabled = true
|
||||||
|
pub async fn is_live_takeover_active(&self) -> Result<bool, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM proxy_config WHERE enabled = 1",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Provider Health ====================
|
||||||
|
|
||||||
|
/// 获取Provider健康状态
|
||||||
|
pub async fn get_provider_health(
|
||||||
|
&self,
|
||||||
|
provider_id: &str,
|
||||||
|
app_type: &str,
|
||||||
|
) -> Result<ProviderHealth, AppError> {
|
||||||
|
let result = {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT provider_id, app_type, is_healthy, consecutive_failures,
|
||||||
|
last_success_at, last_failure_at, last_error, updated_at
|
||||||
|
FROM provider_health
|
||||||
|
WHERE provider_id = ?1 AND app_type = ?2",
|
||||||
|
rusqlite::params![provider_id, app_type],
|
||||||
|
|row| {
|
||||||
|
Ok(ProviderHealth {
|
||||||
|
provider_id: row.get(0)?,
|
||||||
|
app_type: row.get(1)?,
|
||||||
|
is_healthy: row.get::<_, i64>(2)? != 0,
|
||||||
|
consecutive_failures: row.get::<_, i64>(3)? as u32,
|
||||||
|
last_success_at: row.get(4)?,
|
||||||
|
last_failure_at: row.get(5)?,
|
||||||
|
last_error: row.get(6)?,
|
||||||
|
updated_at: row.get(7)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(health) => Ok(health),
|
||||||
|
// 缺少记录时视为健康(关闭后清空状态,再次打开时默认正常)
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(ProviderHealth {
|
||||||
|
provider_id: provider_id.to_string(),
|
||||||
|
app_type: app_type.to_string(),
|
||||||
|
is_healthy: true,
|
||||||
|
consecutive_failures: 0,
|
||||||
|
last_success_at: None,
|
||||||
|
last_failure_at: None,
|
||||||
|
last_error: None,
|
||||||
|
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
}),
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新Provider健康状态
|
||||||
|
///
|
||||||
|
/// 使用默认阈值(5)判断是否健康,建议使用 `update_provider_health_with_threshold` 传入配置的阈值
|
||||||
|
pub async fn update_provider_health(
|
||||||
|
&self,
|
||||||
|
provider_id: &str,
|
||||||
|
app_type: &str,
|
||||||
|
success: bool,
|
||||||
|
error_msg: Option<String>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
// 默认阈值与 CircuitBreakerConfig::default() 保持一致
|
||||||
|
self.update_provider_health_with_threshold(provider_id, app_type, success, error_msg, 5)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新Provider健康状态(带阈值参数)
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `failure_threshold` - 连续失败多少次后标记为不健康
|
||||||
|
pub async fn update_provider_health_with_threshold(
|
||||||
|
&self,
|
||||||
|
provider_id: &str,
|
||||||
|
app_type: &str,
|
||||||
|
success: bool,
|
||||||
|
error_msg: Option<String>,
|
||||||
|
failure_threshold: u32,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
// 先查询当前状态
|
||||||
|
let current = conn.query_row(
|
||||||
|
"SELECT consecutive_failures FROM provider_health
|
||||||
|
WHERE provider_id = ?1 AND app_type = ?2",
|
||||||
|
rusqlite::params![provider_id, app_type],
|
||||||
|
|row| Ok(row.get::<_, i64>(0)? as u32),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (is_healthy, consecutive_failures) = if success {
|
||||||
|
// 成功:重置失败计数
|
||||||
|
(1, 0)
|
||||||
|
} else {
|
||||||
|
// 失败:增加失败计数
|
||||||
|
let failures = current.unwrap_or(0) + 1;
|
||||||
|
// 使用传入的阈值而非硬编码
|
||||||
|
let healthy = if failures >= failure_threshold { 0 } else { 1 };
|
||||||
|
(healthy, failures)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (last_success_at, last_failure_at) = if success {
|
||||||
|
(Some(now.clone()), None)
|
||||||
|
} else {
|
||||||
|
(None, Some(now.clone()))
|
||||||
|
};
|
||||||
|
|
||||||
|
// UPSERT
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO provider_health
|
||||||
|
(provider_id, app_type, is_healthy, consecutive_failures,
|
||||||
|
last_success_at, last_failure_at, last_error, updated_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4,
|
||||||
|
COALESCE(?5, (SELECT last_success_at FROM provider_health
|
||||||
|
WHERE provider_id = ?1 AND app_type = ?2)),
|
||||||
|
COALESCE(?6, (SELECT last_failure_at FROM provider_health
|
||||||
|
WHERE provider_id = ?1 AND app_type = ?2)),
|
||||||
|
?7, ?8)",
|
||||||
|
rusqlite::params![
|
||||||
|
provider_id,
|
||||||
|
app_type,
|
||||||
|
is_healthy,
|
||||||
|
consecutive_failures as i64,
|
||||||
|
last_success_at,
|
||||||
|
last_failure_at,
|
||||||
|
error_msg,
|
||||||
|
&now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重置Provider健康状态
|
||||||
|
pub async fn reset_provider_health(
|
||||||
|
&self,
|
||||||
|
provider_id: &str,
|
||||||
|
app_type: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2",
|
||||||
|
rusqlite::params![provider_id, app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
log::debug!("Reset health status for provider {provider_id} (app: {app_type})");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空指定应用的健康状态(关闭单个代理时使用)
|
||||||
|
pub async fn clear_provider_health_for_app(&self, app_type: &str) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM provider_health WHERE app_type = ?1",
|
||||||
|
[app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
log::debug!("Cleared provider health records for app {app_type}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空所有Provider健康状态(代理停止时调用)
|
||||||
|
pub async fn clear_all_provider_health(&self) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.execute("DELETE FROM provider_health", [])
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
log::debug!("Cleared all provider health records");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Circuit Breaker Config (Legacy Compatibility) ====================
|
||||||
|
|
||||||
|
/// 获取熔断器配置(兼容旧接口,从 claude 行读取)
|
||||||
|
///
|
||||||
|
/// 熔断器配置已合并到 proxy_config 表,每 app 独立
|
||||||
|
/// 此方法保留用于兼容旧代码,建议使用 get_proxy_config_for_app
|
||||||
|
pub async fn get_circuit_breaker_config(
|
||||||
|
&self,
|
||||||
|
) -> Result<crate::proxy::circuit_breaker::CircuitBreakerConfig, AppError> {
|
||||||
|
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
|
||||||
|
let result = {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||||
|
circuit_error_rate_threshold, circuit_min_requests
|
||||||
|
FROM proxy_config WHERE app_type = 'claude'",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig {
|
||||||
|
failure_threshold: row.get::<_, i32>(0)? as u32,
|
||||||
|
success_threshold: row.get::<_, i32>(1)? as u32,
|
||||||
|
timeout_seconds: row.get::<_, i64>(2)? as u64,
|
||||||
|
error_rate_threshold: row.get(3)?,
|
||||||
|
min_requests: row.get::<_, i32>(4)? as u32,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// conn 已在 block 结束时释放
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(config) => Ok(config),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||||
|
// 如果不存在,初始化默认配置
|
||||||
|
self.init_proxy_config_rows().await?;
|
||||||
|
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig::default())
|
||||||
|
}
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新熔断器配置(兼容旧接口,更新所有三行)
|
||||||
|
///
|
||||||
|
/// 熔断器配置已合并到 proxy_config 表
|
||||||
|
/// 此方法保留用于兼容旧代码,建议使用 update_proxy_config_for_app
|
||||||
|
pub async fn update_circuit_breaker_config(
|
||||||
|
&self,
|
||||||
|
config: &crate::proxy::circuit_breaker::CircuitBreakerConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
// 更新所有三行的熔断器配置
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE proxy_config SET
|
||||||
|
circuit_failure_threshold = ?1,
|
||||||
|
circuit_success_threshold = ?2,
|
||||||
|
circuit_timeout_seconds = ?3,
|
||||||
|
circuit_error_rate_threshold = ?4,
|
||||||
|
circuit_min_requests = ?5,
|
||||||
|
updated_at = datetime('now')",
|
||||||
|
rusqlite::params![
|
||||||
|
config.failure_threshold as i32,
|
||||||
|
config.success_threshold as i32,
|
||||||
|
config.timeout_seconds as i64,
|
||||||
|
config.error_rate_threshold,
|
||||||
|
config.min_requests as i32,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Live Backup ====================
|
||||||
|
|
||||||
|
/// 保存 Live 配置备份
|
||||||
|
pub async fn save_live_backup(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
config_json: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO proxy_live_backup (app_type, original_config, backed_up_at)
|
||||||
|
VALUES (?1, ?2, ?3)",
|
||||||
|
rusqlite::params![app_type, config_json, now],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
log::info!("已备份 {app_type} Live 配置");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否存在任意 Live 配置备份
|
||||||
|
pub async fn has_any_live_backup(&self) -> Result<bool, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM proxy_live_backup", [], |row| {
|
||||||
|
row.get(0)
|
||||||
|
})
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Live 配置备份
|
||||||
|
pub async fn get_live_backup(&self, app_type: &str) -> Result<Option<LiveBackup>, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
let result = conn.query_row(
|
||||||
|
"SELECT app_type, original_config, backed_up_at FROM proxy_live_backup WHERE app_type = ?1",
|
||||||
|
rusqlite::params![app_type],
|
||||||
|
|row| {
|
||||||
|
Ok(LiveBackup {
|
||||||
|
app_type: row.get(0)?,
|
||||||
|
original_config: row.get(1)?,
|
||||||
|
backed_up_at: row.get(2)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(backup) => Ok(Some(backup)),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除 Live 配置备份
|
||||||
|
pub async fn delete_live_backup(&self, app_type: &str) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM proxy_live_backup WHERE app_type = ?1",
|
||||||
|
rusqlite::params![app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
log::info!("已删除 {app_type} Live 配置备份");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除所有 Live 配置备份
|
||||||
|
pub async fn delete_all_live_backups(&self) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.execute("DELETE FROM proxy_live_backup", [])
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
log::info!("已删除所有 Live 配置备份");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
//! 通用设置数据访问对象
|
||||||
|
//!
|
||||||
|
//! 提供键值对形式的通用设置存储。
|
||||||
|
|
||||||
|
use crate::database::{lock_conn, Database};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use rusqlite::params;
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
/// 获取设置值
|
||||||
|
pub fn get_setting(&self, key: &str) -> Result<Option<String>, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT value FROM settings WHERE key = ?1")
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut rows = stmt
|
||||||
|
.query(params![key])
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
||||||
|
Ok(Some(
|
||||||
|
row.get(0).map_err(|e| AppError::Database(e.to_string()))?,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置值
|
||||||
|
pub fn set_setting(&self, key: &str, value: &str) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||||
|
params![key, value],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Config Snippets 辅助方法 ---
|
||||||
|
|
||||||
|
/// 获取通用配置片段
|
||||||
|
pub fn get_config_snippet(&self, app_type: &str) -> Result<Option<String>, AppError> {
|
||||||
|
self.get_setting(&format!("common_config_{app_type}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置通用配置片段
|
||||||
|
pub fn set_config_snippet(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
snippet: Option<String>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let key = format!("common_config_{app_type}");
|
||||||
|
if let Some(value) = snippet {
|
||||||
|
self.set_setting(&key, &value)
|
||||||
|
} else {
|
||||||
|
// 如果为 None 则删除
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)---
|
||||||
|
|
||||||
|
/// 获取指定应用的代理接管状态
|
||||||
|
///
|
||||||
|
/// **已废弃**: 请使用 `proxy_config.enabled` 字段替代
|
||||||
|
/// 此方法仅用于数据库迁移时读取旧数据
|
||||||
|
#[deprecated(since = "3.9.0", note = "使用 get_proxy_config_for_app().enabled 替代")]
|
||||||
|
pub fn get_proxy_takeover_enabled(&self, app_type: &str) -> Result<bool, AppError> {
|
||||||
|
let key = format!("proxy_takeover_{app_type}");
|
||||||
|
match self.get_setting(&key)? {
|
||||||
|
Some(value) => Ok(value == "true"),
|
||||||
|
None => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置指定应用的代理接管状态
|
||||||
|
///
|
||||||
|
/// **已废弃**: 请使用 `proxy_config.enabled` 字段替代
|
||||||
|
#[deprecated(
|
||||||
|
since = "3.9.0",
|
||||||
|
note = "使用 update_proxy_config_for_app() 修改 enabled 字段"
|
||||||
|
)]
|
||||||
|
pub fn set_proxy_takeover_enabled(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let key = format!("proxy_takeover_{app_type}");
|
||||||
|
let value = if enabled { "true" } else { "false" };
|
||||||
|
self.set_setting(&key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否有任一应用开启了代理接管
|
||||||
|
///
|
||||||
|
/// **已废弃**: 请使用 `is_live_takeover_active()` 替代
|
||||||
|
#[deprecated(since = "3.9.0", note = "使用 is_live_takeover_active() 替代")]
|
||||||
|
pub fn has_any_proxy_takeover(&self) -> Result<bool, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM settings WHERE key LIKE 'proxy_takeover_%' AND value = 'true'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清除所有代理接管状态(将所有 proxy_takeover_* 设置为 false)
|
||||||
|
///
|
||||||
|
/// **已废弃**: settings 表不再用于存储代理状态
|
||||||
|
#[deprecated(
|
||||||
|
since = "3.9.0",
|
||||||
|
note = "使用 update_proxy_config_for_app() 清除各应用的 enabled 字段"
|
||||||
|
)]
|
||||||
|
pub fn clear_all_proxy_takeover(&self) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE settings SET value = 'false' WHERE key LIKE 'proxy_takeover_%'",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
log::info!("已清除所有代理接管状态");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
//! Skills 数据访问对象
|
||||||
|
//!
|
||||||
|
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
|
||||||
|
//!
|
||||||
|
//! v3.10.0+ 统一管理架构:
|
||||||
|
//! - Skills 使用统一的 id 主键,支持三应用启用标志
|
||||||
|
//! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录
|
||||||
|
|
||||||
|
use crate::app_config::{InstalledSkill, SkillApps};
|
||||||
|
use crate::database::{lock_conn, Database};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::services::skill::SkillRepo;
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use rusqlite::params;
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
// ========== InstalledSkill CRUD ==========
|
||||||
|
|
||||||
|
/// 获取所有已安装的 Skills
|
||||||
|
pub fn get_all_installed_skills(&self) -> Result<IndexMap<String, InstalledSkill>, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||||
|
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
|
||||||
|
FROM skills ORDER BY name ASC",
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let skill_iter = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(InstalledSkill {
|
||||||
|
id: row.get(0)?,
|
||||||
|
name: row.get(1)?,
|
||||||
|
description: row.get(2)?,
|
||||||
|
directory: row.get(3)?,
|
||||||
|
repo_owner: row.get(4)?,
|
||||||
|
repo_name: row.get(5)?,
|
||||||
|
repo_branch: row.get(6)?,
|
||||||
|
readme_url: row.get(7)?,
|
||||||
|
apps: SkillApps {
|
||||||
|
claude: row.get(8)?,
|
||||||
|
codex: row.get(9)?,
|
||||||
|
gemini: row.get(10)?,
|
||||||
|
},
|
||||||
|
installed_at: row.get(11)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut skills = IndexMap::new();
|
||||||
|
for skill_res in skill_iter {
|
||||||
|
let skill = skill_res.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
skills.insert(skill.id.clone(), skill);
|
||||||
|
}
|
||||||
|
Ok(skills)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取单个已安装的 Skill
|
||||||
|
pub fn get_installed_skill(&self, id: &str) -> Result<Option<InstalledSkill>, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
|
||||||
|
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
|
||||||
|
FROM skills WHERE id = ?1",
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let result = stmt.query_row([id], |row| {
|
||||||
|
Ok(InstalledSkill {
|
||||||
|
id: row.get(0)?,
|
||||||
|
name: row.get(1)?,
|
||||||
|
description: row.get(2)?,
|
||||||
|
directory: row.get(3)?,
|
||||||
|
repo_owner: row.get(4)?,
|
||||||
|
repo_name: row.get(5)?,
|
||||||
|
repo_branch: row.get(6)?,
|
||||||
|
readme_url: row.get(7)?,
|
||||||
|
apps: SkillApps {
|
||||||
|
claude: row.get(8)?,
|
||||||
|
codex: row.get(9)?,
|
||||||
|
gemini: row.get(10)?,
|
||||||
|
},
|
||||||
|
installed_at: row.get(11)?,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(skill) => Ok(Some(skill)),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存 Skill(添加或更新)
|
||||||
|
pub fn save_skill(&self, skill: &InstalledSkill) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
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, installed_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||||
|
params![
|
||||||
|
skill.id,
|
||||||
|
skill.name,
|
||||||
|
skill.description,
|
||||||
|
skill.directory,
|
||||||
|
skill.repo_owner,
|
||||||
|
skill.repo_name,
|
||||||
|
skill.repo_branch,
|
||||||
|
skill.readme_url,
|
||||||
|
skill.apps.claude,
|
||||||
|
skill.apps.codex,
|
||||||
|
skill.apps.gemini,
|
||||||
|
skill.installed_at,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除 Skill
|
||||||
|
pub fn delete_skill(&self, id: &str) -> Result<bool, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let affected = conn
|
||||||
|
.execute("DELETE FROM skills WHERE id = ?1", params![id])
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空所有 Skills(用于迁移)
|
||||||
|
pub fn clear_skills(&self) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.execute("DELETE FROM skills", [])
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新 Skill 的应用启用状态
|
||||||
|
pub fn update_skill_apps(&self, id: &str, apps: &SkillApps) -> Result<bool, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let affected = conn
|
||||||
|
.execute(
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== SkillRepo CRUD(保持原有) ==========
|
||||||
|
|
||||||
|
/// 获取所有 Skill 仓库
|
||||||
|
pub fn get_skill_repos(&self) -> Result<Vec<SkillRepo>, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT owner, name, branch, enabled FROM skill_repos ORDER BY owner ASC, name ASC",
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let repo_iter = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(SkillRepo {
|
||||||
|
owner: row.get(0)?,
|
||||||
|
name: row.get(1)?,
|
||||||
|
branch: row.get(2)?,
|
||||||
|
enabled: row.get(3)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut repos = Vec::new();
|
||||||
|
for repo_res in repo_iter {
|
||||||
|
repos.push(repo_res.map_err(|e| AppError::Database(e.to_string()))?);
|
||||||
|
}
|
||||||
|
Ok(repos)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存 Skill 仓库
|
||||||
|
pub fn save_skill_repo(&self, repo: &SkillRepo) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled) VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
params![repo.owner, repo.name, repo.branch, repo.enabled],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除 Skill 仓库
|
||||||
|
pub fn delete_skill_repo(&self, owner: &str, name: &str) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM skill_repos WHERE owner = ?1 AND name = ?2",
|
||||||
|
params![owner, name],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始化默认的 Skill 仓库(首次启动时调用)
|
||||||
|
pub fn init_default_skill_repos(&self) -> Result<usize, AppError> {
|
||||||
|
// 检查是否已有仓库
|
||||||
|
let existing = self.get_skill_repos()?;
|
||||||
|
if !existing.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取默认仓库列表
|
||||||
|
let default_store = crate::services::skill::SkillStore::default();
|
||||||
|
let mut count = 0;
|
||||||
|
|
||||||
|
for repo in &default_store.repos {
|
||||||
|
self.save_skill_repo(repo)?;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("初始化默认 Skill 仓库完成,共 {count} 个");
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
//! 流式健康检查日志 DAO
|
||||||
|
|
||||||
|
use crate::database::{lock_conn, Database};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::services::stream_check::{StreamCheckConfig, StreamCheckResult};
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
/// 保存流式检查日志
|
||||||
|
pub fn save_stream_check_log(
|
||||||
|
&self,
|
||||||
|
provider_id: &str,
|
||||||
|
provider_name: &str,
|
||||||
|
app_type: &str,
|
||||||
|
result: &StreamCheckResult,
|
||||||
|
) -> Result<i64, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO stream_check_logs
|
||||||
|
(provider_id, provider_name, app_type, status, success, message,
|
||||||
|
response_time_ms, http_status, model_used, retry_count, tested_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||||
|
rusqlite::params![
|
||||||
|
provider_id,
|
||||||
|
provider_name,
|
||||||
|
app_type,
|
||||||
|
format!("{:?}", result.status).to_lowercase(),
|
||||||
|
result.success,
|
||||||
|
result.message,
|
||||||
|
result.response_time_ms.map(|t| t as i64),
|
||||||
|
result.http_status.map(|s| s as i64),
|
||||||
|
result.model_used,
|
||||||
|
result.retry_count as i64,
|
||||||
|
result.tested_at,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(conn.last_insert_rowid())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取流式检查配置
|
||||||
|
pub fn get_stream_check_config(&self) -> Result<StreamCheckConfig, AppError> {
|
||||||
|
match self.get_setting("stream_check_config")? {
|
||||||
|
Some(json) => serde_json::from_str(&json)
|
||||||
|
.map_err(|e| AppError::Message(format!("解析配置失败: {e}"))),
|
||||||
|
None => Ok(StreamCheckConfig::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存流式检查配置
|
||||||
|
pub fn save_stream_check_config(&self, config: &StreamCheckConfig) -> Result<(), AppError> {
|
||||||
|
let json = serde_json::to_string(config)
|
||||||
|
.map_err(|e| AppError::Message(format!("序列化配置失败: {e}")))?;
|
||||||
|
self.set_setting("stream_check_config", &json)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
//! 统一供应商 (Universal Provider) DAO
|
||||||
|
//!
|
||||||
|
//! 提供统一供应商的 CRUD 操作。
|
||||||
|
|
||||||
|
use crate::database::{lock_conn, to_json_string, Database};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::provider::UniversalProvider;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// 统一供应商的 Settings Key
|
||||||
|
const UNIVERSAL_PROVIDERS_KEY: &str = "universal_providers";
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
/// 获取所有统一供应商
|
||||||
|
pub fn get_all_universal_providers(
|
||||||
|
&self,
|
||||||
|
) -> Result<HashMap<String, UniversalProvider>, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT value FROM settings WHERE key = ?")
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let result: Option<String> = stmt
|
||||||
|
.query_row([UNIVERSAL_PROVIDERS_KEY], |row| row.get(0))
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Some(json) => serde_json::from_str(&json)
|
||||||
|
.map_err(|e| AppError::Database(format!("解析统一供应商数据失败: {e}"))),
|
||||||
|
None => Ok(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取单个统一供应商
|
||||||
|
pub fn get_universal_provider(&self, id: &str) -> Result<Option<UniversalProvider>, AppError> {
|
||||||
|
let providers = self.get_all_universal_providers()?;
|
||||||
|
Ok(providers.get(id).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存统一供应商(添加或更新)
|
||||||
|
pub fn save_universal_provider(&self, provider: &UniversalProvider) -> Result<(), AppError> {
|
||||||
|
let mut providers = self.get_all_universal_providers()?;
|
||||||
|
providers.insert(provider.id.clone(), provider.clone());
|
||||||
|
self.save_all_universal_providers(&providers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除统一供应商
|
||||||
|
pub fn delete_universal_provider(&self, id: &str) -> Result<bool, AppError> {
|
||||||
|
let mut providers = self.get_all_universal_providers()?;
|
||||||
|
let existed = providers.remove(id).is_some();
|
||||||
|
if existed {
|
||||||
|
self.save_all_universal_providers(&providers)?;
|
||||||
|
}
|
||||||
|
Ok(existed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存所有统一供应商(内部方法)
|
||||||
|
fn save_all_universal_providers(
|
||||||
|
&self,
|
||||||
|
providers: &HashMap<String, UniversalProvider>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let json = to_json_string(providers)?;
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
|
||||||
|
[UNIVERSAL_PROVIDERS_KEY, &json],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
//! JSON → SQLite 数据迁移
|
||||||
|
//!
|
||||||
|
//! 将旧版 config.json (MultiAppConfig) 数据迁移到 SQLite 数据库。
|
||||||
|
|
||||||
|
use super::{lock_conn, to_json_string, Database};
|
||||||
|
use crate::app_config::MultiAppConfig;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
/// 从 MultiAppConfig 迁移数据到数据库
|
||||||
|
pub fn migrate_from_json(&self, config: &MultiAppConfig) -> Result<(), AppError> {
|
||||||
|
let mut conn = lock_conn!(self.conn);
|
||||||
|
let tx = conn
|
||||||
|
.transaction()
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Self::migrate_from_json_tx(&tx, config)?;
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
|
.map_err(|e| AppError::Database(format!("Commit migration failed: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 运行迁移的 dry-run 模式(在内存数据库中验证,不写入磁盘)
|
||||||
|
///
|
||||||
|
/// 用于部署前验证迁移逻辑是否正确。
|
||||||
|
pub fn migrate_from_json_dry_run(config: &MultiAppConfig) -> Result<(), AppError> {
|
||||||
|
let mut conn =
|
||||||
|
Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Self::create_tables_on_conn(&conn)?;
|
||||||
|
Self::apply_schema_migrations_on_conn(&conn)?;
|
||||||
|
|
||||||
|
let tx = conn
|
||||||
|
.transaction()
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Self::migrate_from_json_tx(&tx, config)?;
|
||||||
|
|
||||||
|
// 显式 drop transaction 而不提交(内存数据库会被丢弃)
|
||||||
|
drop(tx);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在事务中执行迁移
|
||||||
|
fn migrate_from_json_tx(
|
||||||
|
tx: &rusqlite::Transaction<'_>,
|
||||||
|
config: &MultiAppConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
// 1. 迁移 Providers
|
||||||
|
Self::migrate_providers(tx, config)?;
|
||||||
|
|
||||||
|
// 2. 迁移 MCP Servers
|
||||||
|
Self::migrate_mcp_servers(tx, config)?;
|
||||||
|
|
||||||
|
// 3. 迁移 Prompts
|
||||||
|
Self::migrate_prompts(tx, config)?;
|
||||||
|
|
||||||
|
// 4. 迁移 Skills
|
||||||
|
Self::migrate_skills(tx, config)?;
|
||||||
|
|
||||||
|
// 5. 迁移 Common Config
|
||||||
|
Self::migrate_common_config(tx, config)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 迁移供应商数据
|
||||||
|
fn migrate_providers(
|
||||||
|
tx: &rusqlite::Transaction<'_>,
|
||||||
|
config: &MultiAppConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
for (app_key, manager) in &config.apps {
|
||||||
|
let app_type = app_key;
|
||||||
|
let current_id = &manager.current;
|
||||||
|
|
||||||
|
for (id, provider) in &manager.providers {
|
||||||
|
let is_current = if id == current_id { 1 } else { 0 };
|
||||||
|
|
||||||
|
// 处理 meta 和 endpoints
|
||||||
|
let mut meta_clone = provider.meta.clone().unwrap_or_default();
|
||||||
|
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
|
||||||
|
|
||||||
|
tx.execute(
|
||||||
|
"INSERT OR REPLACE INTO providers (
|
||||||
|
id, app_type, name, settings_config, website_url, category,
|
||||||
|
created_at, sort_index, notes, icon, icon_color, meta, is_current
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||||
|
params![
|
||||||
|
id,
|
||||||
|
app_type,
|
||||||
|
provider.name,
|
||||||
|
to_json_string(&provider.settings_config)?,
|
||||||
|
provider.website_url,
|
||||||
|
provider.category,
|
||||||
|
provider.created_at,
|
||||||
|
provider.sort_index,
|
||||||
|
provider.notes,
|
||||||
|
provider.icon,
|
||||||
|
provider.icon_color,
|
||||||
|
to_json_string(&meta_clone)?,
|
||||||
|
is_current,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(format!("Migrate provider failed: {e}")))?;
|
||||||
|
|
||||||
|
// 迁移 Endpoints
|
||||||
|
for (url, endpoint) in endpoints {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
params![id, app_type, url, endpoint.added_at],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(format!("Migrate endpoint failed: {e}")))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 迁移 MCP 服务器数据
|
||||||
|
fn migrate_mcp_servers(
|
||||||
|
tx: &rusqlite::Transaction<'_>,
|
||||||
|
config: &MultiAppConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if let Some(servers) = &config.mcp.servers {
|
||||||
|
for (id, server) in servers {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT OR REPLACE INTO mcp_servers (
|
||||||
|
id, name, server_config, description, homepage, docs, tags,
|
||||||
|
enabled_claude, enabled_codex, enabled_gemini
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||||
|
params![
|
||||||
|
id,
|
||||||
|
server.name,
|
||||||
|
to_json_string(&server.server)?,
|
||||||
|
server.description,
|
||||||
|
server.homepage,
|
||||||
|
server.docs,
|
||||||
|
to_json_string(&server.tags)?,
|
||||||
|
server.apps.claude,
|
||||||
|
server.apps.codex,
|
||||||
|
server.apps.gemini,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(format!("Migrate mcp server failed: {e}")))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 迁移提示词数据
|
||||||
|
fn migrate_prompts(
|
||||||
|
tx: &rusqlite::Transaction<'_>,
|
||||||
|
config: &MultiAppConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let migrate_app_prompts = |prompts_map: &std::collections::HashMap<
|
||||||
|
String,
|
||||||
|
crate::prompt::Prompt,
|
||||||
|
>,
|
||||||
|
app_type: &str|
|
||||||
|
-> Result<(), AppError> {
|
||||||
|
for (id, prompt) in prompts_map {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT OR REPLACE INTO prompts (
|
||||||
|
id, app_type, name, content, description, enabled, created_at, updated_at
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||||
|
params![
|
||||||
|
id,
|
||||||
|
app_type,
|
||||||
|
prompt.name,
|
||||||
|
prompt.content,
|
||||||
|
prompt.description,
|
||||||
|
prompt.enabled,
|
||||||
|
prompt.created_at,
|
||||||
|
prompt.updated_at,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(format!("Migrate prompt failed: {e}")))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
|
||||||
|
migrate_app_prompts(&config.prompts.claude.prompts, "claude")?;
|
||||||
|
migrate_app_prompts(&config.prompts.codex.prompts, "codex")?;
|
||||||
|
migrate_app_prompts(&config.prompts.gemini.prompts, "gemini")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 迁移 Skills 数据
|
||||||
|
fn migrate_skills(
|
||||||
|
tx: &rusqlite::Transaction<'_>,
|
||||||
|
config: &MultiAppConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
// v3.10.0+:Skills 的 SSOT 已迁移到文件系统(~/.cc-switch/skills/)+ 数据库统一结构。
|
||||||
|
//
|
||||||
|
// 旧版 config.json 里的 `skills.skills` 仅记录“安装状态”,但不包含完整元数据,
|
||||||
|
// 且无法保证 SSOT 目录中一定存在对应的 skill 文件。
|
||||||
|
//
|
||||||
|
// 因此这里不再直接把旧的安装状态写入新 skills 表,避免产生“数据库显示已安装但文件缺失”的不一致。
|
||||||
|
// 迁移后可通过:
|
||||||
|
// - 前端「导入已有」(扫描各应用的 skills 目录并复制到 SSOT)
|
||||||
|
// - 或后续启动时的自动扫描逻辑
|
||||||
|
// 来重建已安装技能记录。
|
||||||
|
|
||||||
|
for repo in &config.skills.repos {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled) VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
params![repo.owner, repo.name, repo.branch, repo.enabled],
|
||||||
|
).map_err(|e| AppError::Database(format!("Migrate skill repo failed: {e}")))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 迁移通用配置片段
|
||||||
|
fn migrate_common_config(
|
||||||
|
tx: &rusqlite::Transaction<'_>,
|
||||||
|
config: &MultiAppConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if let Some(snippet) = &config.common_config_snippets.claude {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||||
|
params!["common_config_claude", snippet],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
||||||
|
}
|
||||||
|
if let Some(snippet) = &config.common_config_snippets.codex {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||||
|
params!["common_config_codex", snippet],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
||||||
|
}
|
||||||
|
if let Some(snippet) = &config.common_config_snippets.gemini {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||||
|
params!["common_config_gemini", snippet],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
//! 数据库模块 - SQLite 数据持久化
|
||||||
|
//!
|
||||||
|
//! 此模块提供应用的核心数据存储功能,包括:
|
||||||
|
//! - 供应商配置管理
|
||||||
|
//! - MCP 服务器配置
|
||||||
|
//! - 提示词管理
|
||||||
|
//! - Skills 管理
|
||||||
|
//! - 通用设置存储
|
||||||
|
//!
|
||||||
|
//! ## 架构设计
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! database/
|
||||||
|
//! ├── mod.rs - Database 结构体 + 初始化
|
||||||
|
//! ├── schema.rs - 表结构定义 + Schema 迁移
|
||||||
|
//! ├── backup.rs - SQL 导入导出 + 快照备份
|
||||||
|
//! ├── migration.rs - JSON → SQLite 数据迁移
|
||||||
|
//! └── dao/ - 数据访问对象
|
||||||
|
//! ├── providers.rs
|
||||||
|
//! ├── mcp.rs
|
||||||
|
//! ├── prompts.rs
|
||||||
|
//! ├── skills.rs
|
||||||
|
//! └── settings.rs
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
mod backup;
|
||||||
|
mod dao;
|
||||||
|
mod migration;
|
||||||
|
mod schema;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
// DAO 类型导出供外部使用
|
||||||
|
pub use dao::FailoverQueueItem;
|
||||||
|
|
||||||
|
use crate::config::get_app_config_dir;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
// DAO 方法通过 impl Database 提供,无需额外导出
|
||||||
|
|
||||||
|
/// 数据库备份保留数量
|
||||||
|
const DB_BACKUP_RETAIN: usize = 10;
|
||||||
|
|
||||||
|
/// 当前 Schema 版本号
|
||||||
|
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||||
|
pub(crate) const SCHEMA_VERSION: i32 = 3;
|
||||||
|
|
||||||
|
/// 安全地序列化 JSON,避免 unwrap panic
|
||||||
|
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||||
|
serde_json::to_string(value)
|
||||||
|
.map_err(|e| AppError::Config(format!("JSON serialization failed: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 安全地获取 Mutex 锁,避免 unwrap panic
|
||||||
|
macro_rules! lock_conn {
|
||||||
|
($mutex:expr) => {
|
||||||
|
$mutex
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| AppError::Database(format!("Mutex lock failed: {}", e)))?
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出宏供子模块使用
|
||||||
|
pub(crate) use lock_conn;
|
||||||
|
|
||||||
|
/// 数据库连接封装
|
||||||
|
///
|
||||||
|
/// 使用 Mutex 包装 Connection 以支持在多线程环境(如 Tauri State)中共享。
|
||||||
|
/// rusqlite::Connection 本身不是 Sync 的,因此需要这层包装。
|
||||||
|
pub struct Database {
|
||||||
|
pub(crate) conn: Mutex<Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
/// 初始化数据库连接并创建表
|
||||||
|
///
|
||||||
|
/// 数据库文件位于 `~/.cc-switch/cc-switch.db`
|
||||||
|
pub fn init() -> Result<Self, AppError> {
|
||||||
|
let db_path = get_app_config_dir().join("cc-switch.db");
|
||||||
|
|
||||||
|
// 确保父目录存在
|
||||||
|
if let Some(parent) = db_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let conn = Connection::open(&db_path).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 启用外键约束
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON;", [])
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let db = Self {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
};
|
||||||
|
db.create_tables()?;
|
||||||
|
db.apply_schema_migrations()?;
|
||||||
|
db.ensure_model_pricing_seeded()?;
|
||||||
|
|
||||||
|
Ok(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建内存数据库(用于测试)
|
||||||
|
pub fn memory() -> Result<Self, AppError> {
|
||||||
|
let conn = Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 启用外键约束
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON;", [])
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let db = Self {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
};
|
||||||
|
db.create_tables()?;
|
||||||
|
db.ensure_model_pricing_seeded()?;
|
||||||
|
|
||||||
|
Ok(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查 MCP 服务器表是否为空
|
||||||
|
pub fn is_mcp_table_empty(&self) -> Result<bool, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0))
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(count == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查提示词表是否为空
|
||||||
|
pub fn is_prompts_table_empty(&self) -> Result<bool, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM prompts", [], |row| row.get(0))
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(count == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
//! 数据库模块测试
|
||||||
|
//!
|
||||||
|
//! 包含 Schema 迁移和基本功能的测试。
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::app_config::MultiAppConfig;
|
||||||
|
use crate::provider::{Provider, ProviderManager};
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
const LEGACY_SCHEMA_SQL: &str = r#"
|
||||||
|
CREATE TABLE providers (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
app_type TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
settings_config TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (id, app_type)
|
||||||
|
);
|
||||||
|
CREATE TABLE provider_endpoints (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
provider_id TEXT NOT NULL,
|
||||||
|
app_type TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE mcp_servers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
server_config TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE prompts (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
app_type TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (id, app_type)
|
||||||
|
);
|
||||||
|
CREATE TABLE skills (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
installed BOOLEAN NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE skill_repos (
|
||||||
|
owner TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (owner, name)
|
||||||
|
);
|
||||||
|
CREATE TABLE settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
"#;
|
||||||
|
|
||||||
|
// v3.8.x(schema v1)的真实表结构快照:用于验证从 v3.8.* 升级到当前版本的迁移链路
|
||||||
|
// 参考:tag v3.8.3 的 src-tauri/src/database/schema.rs
|
||||||
|
const V3_8_SCHEMA_V1_SQL: &str = r#"
|
||||||
|
CREATE TABLE providers (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
app_type TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
settings_config TEXT NOT NULL,
|
||||||
|
website_url TEXT,
|
||||||
|
category TEXT,
|
||||||
|
created_at INTEGER,
|
||||||
|
sort_index INTEGER,
|
||||||
|
notes TEXT,
|
||||||
|
icon TEXT,
|
||||||
|
icon_color TEXT,
|
||||||
|
meta TEXT NOT NULL DEFAULT '{}',
|
||||||
|
is_current BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (id, app_type)
|
||||||
|
);
|
||||||
|
CREATE TABLE provider_endpoints (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
provider_id TEXT NOT NULL,
|
||||||
|
app_type TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
added_at INTEGER,
|
||||||
|
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE TABLE mcp_servers (
|
||||||
|
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
|
||||||
|
);
|
||||||
|
CREATE TABLE prompts (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
app_type TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||||
|
created_at INTEGER,
|
||||||
|
updated_at INTEGER,
|
||||||
|
PRIMARY KEY (id, app_type)
|
||||||
|
);
|
||||||
|
CREATE TABLE skills (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
installed BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
installed_at INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE skill_repos (
|
||||||
|
owner TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
branch TEXT NOT NULL DEFAULT 'main',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (owner, name)
|
||||||
|
);
|
||||||
|
CREATE TABLE settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ColumnInfo {
|
||||||
|
r#type: String,
|
||||||
|
notnull: i64,
|
||||||
|
default: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_column_info(conn: &Connection, table: &str, column: &str) -> ColumnInfo {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(&format!("PRAGMA table_info(\"{table}\");"))
|
||||||
|
.expect("prepare pragma");
|
||||||
|
let mut rows = stmt.query([]).expect("query pragma");
|
||||||
|
while let Some(row) = rows.next().expect("read row") {
|
||||||
|
let column_name: String = row.get(1).expect("name");
|
||||||
|
if column_name.eq_ignore_ascii_case(column) {
|
||||||
|
return ColumnInfo {
|
||||||
|
r#type: row.get::<_, String>(2).expect("type"),
|
||||||
|
notnull: row.get::<_, i64>(3).expect("notnull"),
|
||||||
|
default: row.get::<_, Option<String>>(4).ok().flatten(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("column {table}.{column} not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_default(default: &Option<String>) -> Option<String> {
|
||||||
|
default
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.trim_matches('\'').trim_matches('"').to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_sets_user_version_when_missing() {
|
||||||
|
let conn = Connection::open_in_memory().expect("open memory db");
|
||||||
|
|
||||||
|
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||||
|
assert_eq!(
|
||||||
|
Database::get_user_version(&conn).expect("read version before"),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
Database::apply_schema_migrations_on_conn(&conn).expect("apply migration");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Database::get_user_version(&conn).expect("read version after"),
|
||||||
|
SCHEMA_VERSION
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_rejects_future_version() {
|
||||||
|
let conn = Connection::open_in_memory().expect("open memory db");
|
||||||
|
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||||
|
Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version");
|
||||||
|
|
||||||
|
let err =
|
||||||
|
Database::apply_schema_migrations_on_conn(&conn).expect_err("should reject higher version");
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("数据库版本过新"),
|
||||||
|
"unexpected error: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_adds_missing_columns_for_providers() {
|
||||||
|
let conn = Connection::open_in_memory().expect("open memory db");
|
||||||
|
|
||||||
|
// 创建旧版 providers 表,缺少新增列
|
||||||
|
conn.execute_batch(LEGACY_SCHEMA_SQL)
|
||||||
|
.expect("seed old schema");
|
||||||
|
|
||||||
|
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
|
||||||
|
|
||||||
|
// 验证关键新增列已补齐
|
||||||
|
for (table, column) in [
|
||||||
|
("providers", "meta"),
|
||||||
|
("providers", "is_current"),
|
||||||
|
("provider_endpoints", "added_at"),
|
||||||
|
("mcp_servers", "enabled_gemini"),
|
||||||
|
("prompts", "updated_at"),
|
||||||
|
("skills", "installed_at"),
|
||||||
|
("skill_repos", "enabled"),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
Database::has_column(&conn, table, column).expect("check column"),
|
||||||
|
"{table}.{column} should exist after migration"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 meta 列约束保持一致
|
||||||
|
let meta = get_column_info(&conn, "providers", "meta");
|
||||||
|
assert_eq!(meta.notnull, 1, "meta should be NOT NULL");
|
||||||
|
assert_eq!(
|
||||||
|
normalize_default(&meta.default).as_deref(),
|
||||||
|
Some("{}"),
|
||||||
|
"meta default should be '{{}}'"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Database::get_user_version(&conn).expect("version after migration"),
|
||||||
|
SCHEMA_VERSION
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_aligns_column_defaults_and_types() {
|
||||||
|
let conn = Connection::open_in_memory().expect("open memory db");
|
||||||
|
conn.execute_batch(LEGACY_SCHEMA_SQL)
|
||||||
|
.expect("seed old schema");
|
||||||
|
|
||||||
|
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
|
||||||
|
|
||||||
|
let is_current = get_column_info(&conn, "providers", "is_current");
|
||||||
|
assert_eq!(is_current.r#type, "BOOLEAN");
|
||||||
|
assert_eq!(is_current.notnull, 1);
|
||||||
|
assert_eq!(normalize_default(&is_current.default).as_deref(), Some("0"));
|
||||||
|
|
||||||
|
let tags = get_column_info(&conn, "mcp_servers", "tags");
|
||||||
|
assert_eq!(tags.r#type, "TEXT");
|
||||||
|
assert_eq!(tags.notnull, 1);
|
||||||
|
assert_eq!(normalize_default(&tags.default).as_deref(), Some("[]"));
|
||||||
|
|
||||||
|
let enabled = get_column_info(&conn, "prompts", "enabled");
|
||||||
|
assert_eq!(enabled.r#type, "BOOLEAN");
|
||||||
|
assert_eq!(enabled.notnull, 1);
|
||||||
|
assert_eq!(normalize_default(&enabled.default).as_deref(), Some("1"));
|
||||||
|
|
||||||
|
let installed_at = get_column_info(&conn, "skills", "installed_at");
|
||||||
|
assert_eq!(installed_at.r#type, "INTEGER");
|
||||||
|
assert_eq!(installed_at.notnull, 1);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_default(&installed_at.default).as_deref(),
|
||||||
|
Some("0")
|
||||||
|
);
|
||||||
|
|
||||||
|
let branch = get_column_info(&conn, "skill_repos", "branch");
|
||||||
|
assert_eq!(branch.r#type, "TEXT");
|
||||||
|
assert_eq!(normalize_default(&branch.default).as_deref(), Some("main"));
|
||||||
|
|
||||||
|
let skill_repo_enabled = get_column_info(&conn, "skill_repos", "enabled");
|
||||||
|
assert_eq!(skill_repo_enabled.r#type, "BOOLEAN");
|
||||||
|
assert_eq!(skill_repo_enabled.notnull, 1);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_default(&skill_repo_enabled.default).as_deref(),
|
||||||
|
Some("1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
|
||||||
|
let conn = Connection::open_in_memory().expect("open memory db");
|
||||||
|
|
||||||
|
// 模拟测试版 v2:user_version=2,但 proxy_config 仍是单例结构(无 app_type)
|
||||||
|
Database::set_user_version(&conn, 2).expect("set user_version");
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE proxy_config (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||||
|
listen_port INTEGER NOT NULL DEFAULT 5000,
|
||||||
|
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||||
|
request_timeout INTEGER NOT NULL DEFAULT 300,
|
||||||
|
enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||||
|
target_app TEXT NOT NULL DEFAULT 'claude',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
INSERT INTO proxy_config (id, enabled) VALUES (1, 1);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("seed legacy proxy_config");
|
||||||
|
|
||||||
|
Database::create_tables_on_conn(&conn).expect("create tables should repair proxy_config");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
Database::has_column(&conn, "proxy_config", "app_type").expect("check app_type"),
|
||||||
|
"proxy_config should be migrated to per-app structure"
|
||||||
|
);
|
||||||
|
|
||||||
|
let count: i32 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
|
||||||
|
.expect("count rows");
|
||||||
|
assert_eq!(count, 3, "per-app proxy_config should have 3 rows");
|
||||||
|
|
||||||
|
// 新结构下应能按 app_type 查询
|
||||||
|
let _: i32 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM proxy_config WHERE app_type = 'claude'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.expect("query by app_type");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
|
||||||
|
let conn = Connection::open_in_memory().expect("open memory db");
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON;", [])
|
||||||
|
.expect("enable foreign keys");
|
||||||
|
|
||||||
|
// 模拟 v3.8.* 用户的数据库(schema v1)
|
||||||
|
conn.execute_batch(V3_8_SCHEMA_V1_SQL)
|
||||||
|
.expect("seed v3.8 schema v1");
|
||||||
|
Database::set_user_version(&conn, 1).expect("set user_version=1");
|
||||||
|
|
||||||
|
// 插入一条旧版 Provider + Skill(用于验证迁移不会破坏既有数据)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO providers (
|
||||||
|
id, app_type, name, settings_config, website_url, category,
|
||||||
|
created_at, sort_index, notes, icon, icon_color, meta, is_current
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||||
|
params![
|
||||||
|
"p1",
|
||||||
|
"claude",
|
||||||
|
"Test Provider",
|
||||||
|
serde_json::to_string(&json!({ "anthropicApiKey": "sk-test" })).unwrap(),
|
||||||
|
Option::<String>::None,
|
||||||
|
Option::<String>::None,
|
||||||
|
Option::<i64>::None,
|
||||||
|
Option::<usize>::None,
|
||||||
|
Option::<String>::None,
|
||||||
|
Option::<String>::None,
|
||||||
|
Option::<String>::None,
|
||||||
|
"{}",
|
||||||
|
1,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.expect("seed provider");
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
|
||||||
|
params!["claude:demo-skill", 1, 1700000000i64],
|
||||||
|
)
|
||||||
|
.expect("seed legacy skill");
|
||||||
|
|
||||||
|
// 按应用启动流程:先 create_tables(补齐新增表),再 apply_schema_migrations(按 user_version 迁移)
|
||||||
|
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||||
|
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Database::get_user_version(&conn).expect("user_version after migration"),
|
||||||
|
SCHEMA_VERSION
|
||||||
|
);
|
||||||
|
|
||||||
|
// v1 -> v2:providers 新增字段必须补齐
|
||||||
|
for column in [
|
||||||
|
"cost_multiplier",
|
||||||
|
"limit_daily_usd",
|
||||||
|
"limit_monthly_usd",
|
||||||
|
"provider_type",
|
||||||
|
"in_failover_queue",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
Database::has_column(&conn, "providers", column).expect("check column"),
|
||||||
|
"providers.{column} should exist after migration"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 旧 provider 不应丢失,且新增字段应有默认值
|
||||||
|
let provider_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM providers WHERE id = 'p1' AND app_type = 'claude'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.expect("count providers");
|
||||||
|
assert_eq!(provider_count, 1);
|
||||||
|
|
||||||
|
let cost_multiplier: String = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT cost_multiplier FROM providers WHERE id = 'p1' AND app_type = 'claude'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.expect("read cost_multiplier");
|
||||||
|
assert_eq!(cost_multiplier, "1.0");
|
||||||
|
|
||||||
|
// v2 -> v3:skills 表重建为统一结构,并设置 pending 标记(后续由启动时扫描文件系统重建数据)
|
||||||
|
assert!(
|
||||||
|
Database::has_column(&conn, "skills", "enabled_claude").expect("check skills v3 column"),
|
||||||
|
"skills table should be migrated to v3 structure"
|
||||||
|
);
|
||||||
|
let skills_count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM skills", [], |r| r.get(0))
|
||||||
|
.expect("count skills");
|
||||||
|
assert_eq!(skills_count, 0, "skills table should be rebuilt empty");
|
||||||
|
|
||||||
|
let pending: Option<String> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT value FROM settings WHERE key = 'skills_ssot_migration_pending'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
assert!(
|
||||||
|
matches!(pending.as_deref(), Some("true") | Some("1")),
|
||||||
|
"skills_ssot_migration_pending should be set after v2->v3 migration"
|
||||||
|
);
|
||||||
|
|
||||||
|
// v3.9+ 新增:proxy_config 三行 seed 必须存在(否则 UI 会查不到默认值)
|
||||||
|
let proxy_rows: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
|
||||||
|
.expect("count proxy_config rows");
|
||||||
|
assert_eq!(proxy_rows, 3);
|
||||||
|
|
||||||
|
// model_pricing 应具备默认数据(迁移时会 seed)
|
||||||
|
let pricing_rows: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM model_pricing", [], |r| r.get(0))
|
||||||
|
.expect("count model_pricing rows");
|
||||||
|
assert!(pricing_rows > 0, "model_pricing should be seeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dry_run_does_not_write_to_disk() {
|
||||||
|
// Create minimal valid config for migration
|
||||||
|
let mut apps = HashMap::new();
|
||||||
|
apps.insert("claude".to_string(), ProviderManager::default());
|
||||||
|
|
||||||
|
let config = MultiAppConfig {
|
||||||
|
version: 2,
|
||||||
|
apps,
|
||||||
|
mcp: Default::default(),
|
||||||
|
prompts: Default::default(),
|
||||||
|
skills: Default::default(),
|
||||||
|
common_config_snippets: Default::default(),
|
||||||
|
claude_common_config_snippet: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dry-run should succeed without any file I/O errors
|
||||||
|
let result = Database::migrate_from_json_dry_run(&config);
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"Dry-run should succeed with valid config: {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dry_run_validates_schema_compatibility() {
|
||||||
|
// Create config with actual provider data
|
||||||
|
let mut providers = IndexMap::new();
|
||||||
|
providers.insert(
|
||||||
|
"test-provider".to_string(),
|
||||||
|
Provider {
|
||||||
|
id: "test-provider".to_string(),
|
||||||
|
name: "Test Provider".to_string(),
|
||||||
|
settings_config: json!({
|
||||||
|
"anthropicApiKey": "sk-test-123",
|
||||||
|
}),
|
||||||
|
website_url: None,
|
||||||
|
category: None,
|
||||||
|
created_at: Some(1234567890),
|
||||||
|
sort_index: None,
|
||||||
|
notes: None,
|
||||||
|
meta: None,
|
||||||
|
icon: None,
|
||||||
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let manager = ProviderManager {
|
||||||
|
providers,
|
||||||
|
current: "test-provider".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut apps = HashMap::new();
|
||||||
|
apps.insert("claude".to_string(), manager);
|
||||||
|
|
||||||
|
let config = MultiAppConfig {
|
||||||
|
version: 2,
|
||||||
|
apps,
|
||||||
|
mcp: Default::default(),
|
||||||
|
prompts: Default::default(),
|
||||||
|
skills: Default::default(),
|
||||||
|
common_config_snippets: Default::default(),
|
||||||
|
claude_common_config_snippet: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dry-run should validate the full migration path
|
||||||
|
let result = Database::migrate_from_json_dry_run(&config);
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"Dry-run should succeed with provider data: {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_pricing_is_seeded_on_init() {
|
||||||
|
let db = Database::memory().expect("create memory db");
|
||||||
|
|
||||||
|
let conn = db.conn.lock().expect("lock conn");
|
||||||
|
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))
|
||||||
|
.expect("count pricing");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
count > 0,
|
||||||
|
"模型定价数据应该在初始化时自动填充,实际数量: {}",
|
||||||
|
count
|
||||||
|
);
|
||||||
|
|
||||||
|
// 验证包含 Claude 模型
|
||||||
|
let claude_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'claude-%'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.expect("check claude");
|
||||||
|
assert!(
|
||||||
|
claude_count > 0,
|
||||||
|
"应该包含 Claude 模型定价,实际数量: {}",
|
||||||
|
claude_count
|
||||||
|
);
|
||||||
|
|
||||||
|
// 验证包含 GPT 模型
|
||||||
|
let gpt_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'gpt-%'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.expect("check gpt");
|
||||||
|
assert!(
|
||||||
|
gpt_count > 0,
|
||||||
|
"应该包含 GPT 模型定价,实际数量: {}",
|
||||||
|
gpt_count
|
||||||
|
);
|
||||||
|
|
||||||
|
// 验证包含 Gemini 模型
|
||||||
|
let gemini_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'gemini-%'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.expect("check gemini");
|
||||||
|
assert!(
|
||||||
|
gemini_count > 0,
|
||||||
|
"应该包含 Gemini 模型定价,实际数量: {}",
|
||||||
|
gemini_count
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
//! MCP server import from deep link
|
||||||
|
//!
|
||||||
|
//! Handles batch import of MCP server configurations via ccswitch:// URLs.
|
||||||
|
|
||||||
|
use super::utils::decode_base64_param;
|
||||||
|
use super::DeepLinkImportRequest;
|
||||||
|
use crate::app_config::{McpApps, McpServer};
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::services::McpService;
|
||||||
|
use crate::store::AppState;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// MCP import result
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct McpImportResult {
|
||||||
|
/// Number of successfully imported MCP servers
|
||||||
|
pub imported_count: usize,
|
||||||
|
/// IDs of successfully imported MCP servers
|
||||||
|
pub imported_ids: Vec<String>,
|
||||||
|
/// Failed imports with error messages
|
||||||
|
pub failed: Vec<McpImportError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MCP import error
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct McpImportError {
|
||||||
|
/// MCP server ID
|
||||||
|
pub id: String,
|
||||||
|
/// Error message
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import MCP servers from deep link request
|
||||||
|
///
|
||||||
|
/// This function handles batch import of MCP servers from standard MCP JSON format.
|
||||||
|
/// If a server already exists, only the apps flags are merged (existing config preserved).
|
||||||
|
pub fn import_mcp_from_deeplink(
|
||||||
|
state: &AppState,
|
||||||
|
request: DeepLinkImportRequest,
|
||||||
|
) -> Result<McpImportResult, AppError> {
|
||||||
|
// Verify this is an MCP request
|
||||||
|
if request.resource != "mcp" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Expected mcp resource, got '{}'",
|
||||||
|
request.resource
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and validate apps parameter
|
||||||
|
let apps_str = request
|
||||||
|
.apps
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'apps' parameter for MCP".to_string()))?;
|
||||||
|
|
||||||
|
// Parse apps into McpApps struct
|
||||||
|
let target_apps = parse_mcp_apps(apps_str)?;
|
||||||
|
|
||||||
|
// Extract config
|
||||||
|
let config_b64 = request
|
||||||
|
.config
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'config' parameter for MCP".to_string()))?;
|
||||||
|
|
||||||
|
// Decode Base64 config
|
||||||
|
let decoded = decode_base64_param("config", config_b64)?;
|
||||||
|
|
||||||
|
let config_str = String::from_utf8(decoded)
|
||||||
|
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in config: {e}")))?;
|
||||||
|
|
||||||
|
// Parse JSON
|
||||||
|
let config_json: Value = serde_json::from_str(&config_str)
|
||||||
|
.map_err(|e| AppError::InvalidInput(format!("Invalid JSON in MCP config: {e}")))?;
|
||||||
|
|
||||||
|
// Extract mcpServers object
|
||||||
|
let mcp_servers = config_json
|
||||||
|
.get("mcpServers")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::InvalidInput("MCP config must contain 'mcpServers' object".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if mcp_servers.is_empty() {
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"No MCP servers found in config".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing servers to check for duplicates
|
||||||
|
let existing_servers = state.db.get_all_mcp_servers()?;
|
||||||
|
|
||||||
|
// Import each MCP server
|
||||||
|
let mut imported_ids = Vec::new();
|
||||||
|
let mut failed = Vec::new();
|
||||||
|
|
||||||
|
for (id, server_spec) in mcp_servers.iter() {
|
||||||
|
// Check if server already exists
|
||||||
|
let server = if let Some(existing) = existing_servers.get(id) {
|
||||||
|
// Server exists - merge apps only, keep other fields unchanged
|
||||||
|
log::info!("MCP server '{id}' already exists, merging apps only");
|
||||||
|
|
||||||
|
let mut merged_apps = existing.apps.clone();
|
||||||
|
// Merge new apps into existing apps
|
||||||
|
if target_apps.claude {
|
||||||
|
merged_apps.claude = true;
|
||||||
|
}
|
||||||
|
if target_apps.codex {
|
||||||
|
merged_apps.codex = true;
|
||||||
|
}
|
||||||
|
if target_apps.gemini {
|
||||||
|
merged_apps.gemini = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
McpServer {
|
||||||
|
id: existing.id.clone(),
|
||||||
|
name: existing.name.clone(),
|
||||||
|
server: existing.server.clone(), // Keep existing server config
|
||||||
|
apps: merged_apps, // Merged apps
|
||||||
|
description: existing.description.clone(),
|
||||||
|
homepage: existing.homepage.clone(),
|
||||||
|
docs: existing.docs.clone(),
|
||||||
|
tags: existing.tags.clone(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// New server - create with provided config
|
||||||
|
log::info!("Creating new MCP server: {id}");
|
||||||
|
McpServer {
|
||||||
|
id: id.clone(),
|
||||||
|
name: id.clone(),
|
||||||
|
server: server_spec.clone(),
|
||||||
|
apps: target_apps.clone(),
|
||||||
|
description: None,
|
||||||
|
homepage: None,
|
||||||
|
docs: None,
|
||||||
|
tags: vec!["imported".to_string()],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match McpService::upsert_server(state, server) {
|
||||||
|
Ok(_) => {
|
||||||
|
imported_ids.push(id.clone());
|
||||||
|
log::info!("Successfully imported/updated MCP server: {id}");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
failed.push(McpImportError {
|
||||||
|
id: id.clone(),
|
||||||
|
error: format!("{e}"),
|
||||||
|
});
|
||||||
|
log::warn!("Failed to import MCP server '{id}': {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(McpImportResult {
|
||||||
|
imported_count: imported_ids.len(),
|
||||||
|
imported_ids,
|
||||||
|
failed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse apps string into McpApps struct
|
||||||
|
pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
||||||
|
let mut apps = McpApps {
|
||||||
|
claude: false,
|
||||||
|
codex: false,
|
||||||
|
gemini: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for app in apps_str.split(',') {
|
||||||
|
match app.trim() {
|
||||||
|
"claude" => apps.claude = true,
|
||||||
|
"codex" => apps.codex = true,
|
||||||
|
"gemini" => apps.gemini = true,
|
||||||
|
other => {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Invalid app in 'apps': {other}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if apps.is_empty() {
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"At least one app must be specified in 'apps'".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(apps)
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
//! Deep link import functionality for CC Switch
|
||||||
|
//!
|
||||||
|
//! This module implements the ccswitch:// protocol for importing configurations
|
||||||
|
//! via deep links. Supports importing:
|
||||||
|
//! - Provider configurations (Claude/Codex/Gemini)
|
||||||
|
//! - MCP server configurations
|
||||||
|
//! - Prompts
|
||||||
|
//! - Skills
|
||||||
|
//!
|
||||||
|
//! See docs/ccswitch-deeplink-design.md for detailed design.
|
||||||
|
|
||||||
|
mod mcp;
|
||||||
|
mod parser;
|
||||||
|
mod prompt;
|
||||||
|
mod provider;
|
||||||
|
mod skill;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// Re-export public API
|
||||||
|
pub use mcp::import_mcp_from_deeplink;
|
||||||
|
pub use parser::parse_deeplink_url;
|
||||||
|
pub use prompt::import_prompt_from_deeplink;
|
||||||
|
pub use provider::{import_provider_from_deeplink, parse_and_merge_config};
|
||||||
|
pub use skill::import_skill_from_deeplink;
|
||||||
|
|
||||||
|
/// Deep link import request model
|
||||||
|
///
|
||||||
|
/// Represents a parsed ccswitch:// URL ready for processing.
|
||||||
|
/// This struct contains all possible fields for all resource types.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DeepLinkImportRequest {
|
||||||
|
/// Protocol version (e.g., "v1")
|
||||||
|
pub version: String,
|
||||||
|
/// Resource type to import: "provider" | "prompt" | "mcp" | "skill"
|
||||||
|
pub resource: String,
|
||||||
|
|
||||||
|
// ============ Common fields ============
|
||||||
|
/// Target application (claude/codex/gemini) - for provider, prompt, skill
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub app: Option<String>,
|
||||||
|
/// Resource name
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Whether to enable after import (default: false)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
|
||||||
|
// ============ Provider-specific fields ============
|
||||||
|
/// Provider homepage URL
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub homepage: Option<String>,
|
||||||
|
/// API endpoint/base URL
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub endpoint: Option<String>,
|
||||||
|
/// API key
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
/// Optional provider icon name (maps to built-in SVG)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub icon: Option<String>,
|
||||||
|
/// Optional model name
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model: Option<String>,
|
||||||
|
/// Optional notes/description
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub notes: Option<String>,
|
||||||
|
/// Optional Haiku model (Claude only, v3.7.1+)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub haiku_model: Option<String>,
|
||||||
|
/// Optional Sonnet model (Claude only, v3.7.1+)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sonnet_model: Option<String>,
|
||||||
|
/// Optional Opus model (Claude only, v3.7.1+)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub opus_model: Option<String>,
|
||||||
|
|
||||||
|
// ============ Prompt-specific fields ============
|
||||||
|
/// Base64 encoded Markdown content
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub content: Option<String>,
|
||||||
|
/// Prompt description
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
|
||||||
|
// ============ MCP-specific fields ============
|
||||||
|
/// Target applications for MCP (comma-separated: "claude,codex,gemini")
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub apps: Option<String>,
|
||||||
|
|
||||||
|
// ============ Skill-specific fields ============
|
||||||
|
/// GitHub repository (format: "owner/name")
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub repo: Option<String>,
|
||||||
|
/// Skill directory name
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub directory: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub branch: Option<String>,
|
||||||
|
|
||||||
|
// ============ Config file fields (v3.8+) ============
|
||||||
|
/// Base64 encoded config content
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub config: Option<String>,
|
||||||
|
/// Config format (json/toml)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub config_format: Option<String>,
|
||||||
|
/// Remote config URL
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub config_url: Option<String>,
|
||||||
|
|
||||||
|
// ============ Usage script fields (v3.9+) ============
|
||||||
|
/// Whether to enable usage query (default: true if usage_script is provided)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage_enabled: Option<bool>,
|
||||||
|
/// Base64 encoded usage query script code
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage_script: Option<String>,
|
||||||
|
/// Usage query API key (if different from provider API key)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage_api_key: Option<String>,
|
||||||
|
/// Usage query base URL (if different from provider endpoint)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage_base_url: Option<String>,
|
||||||
|
/// Usage query access token (for NewAPI template)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage_access_token: Option<String>,
|
||||||
|
/// Usage query user ID (for NewAPI template)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage_user_id: Option<String>,
|
||||||
|
/// Auto query interval in minutes (0 to disable)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage_auto_interval: Option<u64>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
//! Deep link URL parser
|
||||||
|
//!
|
||||||
|
//! Parses ccswitch:// URLs into DeepLinkImportRequest structures.
|
||||||
|
|
||||||
|
use super::utils::validate_url;
|
||||||
|
use super::DeepLinkImportRequest;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
/// Parse a ccswitch:// URL into a DeepLinkImportRequest
|
||||||
|
///
|
||||||
|
/// Expected format:
|
||||||
|
/// ccswitch://v1/import?resource={type}&...
|
||||||
|
pub fn parse_deeplink_url(url_str: &str) -> Result<DeepLinkImportRequest, AppError> {
|
||||||
|
// Parse URL
|
||||||
|
let url = Url::parse(url_str)
|
||||||
|
.map_err(|e| AppError::InvalidInput(format!("Invalid deep link URL: {e}")))?;
|
||||||
|
|
||||||
|
// Validate scheme
|
||||||
|
let scheme = url.scheme();
|
||||||
|
if scheme != "ccswitch" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Invalid scheme: expected 'ccswitch', got '{scheme}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract version from host
|
||||||
|
let version = url
|
||||||
|
.host_str()
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing version in URL host".to_string()))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Validate version
|
||||||
|
if version != "v1" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Unsupported protocol version: {version}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract path (should be "/import")
|
||||||
|
let path = url.path();
|
||||||
|
if path != "/import" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Invalid path: expected '/import', got '{path}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse query parameters
|
||||||
|
let params: HashMap<String, String> = url.query_pairs().into_owned().collect();
|
||||||
|
|
||||||
|
// Extract and validate resource type
|
||||||
|
let resource = params
|
||||||
|
.get("resource")
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'resource' parameter".to_string()))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
// Dispatch to appropriate parser based on resource type
|
||||||
|
match resource.as_str() {
|
||||||
|
"provider" => parse_provider_deeplink(¶ms, version, resource),
|
||||||
|
"prompt" => parse_prompt_deeplink(¶ms, version, resource),
|
||||||
|
"mcp" => parse_mcp_deeplink(¶ms, version, resource),
|
||||||
|
"skill" => parse_skill_deeplink(¶ms, version, resource),
|
||||||
|
_ => Err(AppError::InvalidInput(format!(
|
||||||
|
"Unsupported resource type: {resource}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse provider deep link parameters
|
||||||
|
fn parse_provider_deeplink(
|
||||||
|
params: &HashMap<String, String>,
|
||||||
|
version: String,
|
||||||
|
resource: String,
|
||||||
|
) -> Result<DeepLinkImportRequest, AppError> {
|
||||||
|
let app = params
|
||||||
|
.get("app")
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'app' parameter".to_string()))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
// Validate app type
|
||||||
|
if app != "claude" && app != "codex" && app != "gemini" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Invalid app type: must be 'claude', 'codex', or 'gemini', got '{app}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = params
|
||||||
|
.get("name")
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'name' parameter".to_string()))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
// Make these optional for config file auto-fill (v3.8+)
|
||||||
|
let homepage = params.get("homepage").cloned();
|
||||||
|
let endpoint = params.get("endpoint").cloned();
|
||||||
|
let api_key = params.get("apiKey").cloned();
|
||||||
|
|
||||||
|
// Validate URLs only if provided
|
||||||
|
if let Some(ref hp) = homepage {
|
||||||
|
if !hp.is_empty() {
|
||||||
|
validate_url(hp, "homepage")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref ep) = endpoint {
|
||||||
|
if !ep.is_empty() {
|
||||||
|
validate_url(ep, "endpoint")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract optional fields
|
||||||
|
let model = params.get("model").cloned();
|
||||||
|
let notes = params.get("notes").cloned();
|
||||||
|
let haiku_model = params.get("haikuModel").cloned();
|
||||||
|
let sonnet_model = params.get("sonnetModel").cloned();
|
||||||
|
let opus_model = params.get("opusModel").cloned();
|
||||||
|
let icon = params
|
||||||
|
.get("icon")
|
||||||
|
.map(|v| v.trim().to_lowercase())
|
||||||
|
.filter(|v| !v.is_empty());
|
||||||
|
let config = params.get("config").cloned();
|
||||||
|
let config_format = params.get("configFormat").cloned();
|
||||||
|
let config_url = params.get("configUrl").cloned();
|
||||||
|
let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok());
|
||||||
|
|
||||||
|
// Extract usage script fields (v3.9+)
|
||||||
|
let usage_enabled = params
|
||||||
|
.get("usageEnabled")
|
||||||
|
.and_then(|v| v.parse::<bool>().ok());
|
||||||
|
let usage_script = params.get("usageScript").cloned();
|
||||||
|
let usage_api_key = params.get("usageApiKey").cloned();
|
||||||
|
let usage_base_url = params.get("usageBaseUrl").cloned();
|
||||||
|
let usage_access_token = params.get("usageAccessToken").cloned();
|
||||||
|
let usage_user_id = params.get("usageUserId").cloned();
|
||||||
|
let usage_auto_interval = params
|
||||||
|
.get("usageAutoInterval")
|
||||||
|
.and_then(|v| v.parse::<u64>().ok());
|
||||||
|
|
||||||
|
Ok(DeepLinkImportRequest {
|
||||||
|
version,
|
||||||
|
resource,
|
||||||
|
app: Some(app),
|
||||||
|
name: Some(name),
|
||||||
|
enabled,
|
||||||
|
homepage,
|
||||||
|
endpoint,
|
||||||
|
api_key,
|
||||||
|
icon,
|
||||||
|
model,
|
||||||
|
notes,
|
||||||
|
haiku_model,
|
||||||
|
sonnet_model,
|
||||||
|
opus_model,
|
||||||
|
content: None,
|
||||||
|
description: None,
|
||||||
|
apps: None,
|
||||||
|
repo: None,
|
||||||
|
directory: None,
|
||||||
|
branch: None,
|
||||||
|
config,
|
||||||
|
config_format,
|
||||||
|
config_url,
|
||||||
|
usage_enabled,
|
||||||
|
usage_script,
|
||||||
|
usage_api_key,
|
||||||
|
usage_base_url,
|
||||||
|
usage_access_token,
|
||||||
|
usage_user_id,
|
||||||
|
usage_auto_interval,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse prompt deep link parameters
|
||||||
|
fn parse_prompt_deeplink(
|
||||||
|
params: &HashMap<String, String>,
|
||||||
|
version: String,
|
||||||
|
resource: String,
|
||||||
|
) -> Result<DeepLinkImportRequest, AppError> {
|
||||||
|
let app = params
|
||||||
|
.get("app")
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'app' parameter for prompt".to_string()))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
// Validate app type
|
||||||
|
if app != "claude" && app != "codex" && app != "gemini" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Invalid app type: must be 'claude', 'codex', or 'gemini', got '{app}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = params
|
||||||
|
.get("name")
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'name' parameter for prompt".to_string()))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let content = params
|
||||||
|
.get("content")
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::InvalidInput("Missing 'content' parameter for prompt".to_string())
|
||||||
|
})?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let description = params.get("description").cloned();
|
||||||
|
let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok());
|
||||||
|
|
||||||
|
Ok(DeepLinkImportRequest {
|
||||||
|
version,
|
||||||
|
resource,
|
||||||
|
app: Some(app),
|
||||||
|
name: Some(name),
|
||||||
|
enabled,
|
||||||
|
content: Some(content),
|
||||||
|
description,
|
||||||
|
icon: None,
|
||||||
|
homepage: None,
|
||||||
|
endpoint: None,
|
||||||
|
api_key: None,
|
||||||
|
model: None,
|
||||||
|
notes: None,
|
||||||
|
haiku_model: None,
|
||||||
|
sonnet_model: None,
|
||||||
|
opus_model: None,
|
||||||
|
apps: None,
|
||||||
|
repo: None,
|
||||||
|
directory: None,
|
||||||
|
branch: None,
|
||||||
|
config: None,
|
||||||
|
config_format: None,
|
||||||
|
config_url: None,
|
||||||
|
usage_enabled: None,
|
||||||
|
usage_script: None,
|
||||||
|
usage_api_key: None,
|
||||||
|
usage_base_url: None,
|
||||||
|
usage_access_token: None,
|
||||||
|
usage_user_id: None,
|
||||||
|
usage_auto_interval: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse MCP deep link parameters
|
||||||
|
fn parse_mcp_deeplink(
|
||||||
|
params: &HashMap<String, String>,
|
||||||
|
version: String,
|
||||||
|
resource: String,
|
||||||
|
) -> Result<DeepLinkImportRequest, AppError> {
|
||||||
|
let apps = params
|
||||||
|
.get("apps")
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'apps' parameter for MCP".to_string()))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
// Validate apps format
|
||||||
|
for app in apps.split(',') {
|
||||||
|
let trimmed = app.trim();
|
||||||
|
if trimmed != "claude" && trimmed != "codex" && trimmed != "gemini" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Invalid app in 'apps': must be 'claude', 'codex', or 'gemini', got '{trimmed}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = params
|
||||||
|
.get("config")
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'config' parameter for MCP".to_string()))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok());
|
||||||
|
|
||||||
|
Ok(DeepLinkImportRequest {
|
||||||
|
version,
|
||||||
|
resource,
|
||||||
|
apps: Some(apps),
|
||||||
|
enabled,
|
||||||
|
config: Some(config),
|
||||||
|
config_format: Some("json".to_string()), // MCP config is always JSON
|
||||||
|
app: None,
|
||||||
|
name: None,
|
||||||
|
icon: None,
|
||||||
|
homepage: None,
|
||||||
|
endpoint: None,
|
||||||
|
api_key: None,
|
||||||
|
model: None,
|
||||||
|
notes: None,
|
||||||
|
haiku_model: None,
|
||||||
|
sonnet_model: None,
|
||||||
|
opus_model: None,
|
||||||
|
content: None,
|
||||||
|
description: None,
|
||||||
|
repo: None,
|
||||||
|
directory: None,
|
||||||
|
branch: None,
|
||||||
|
config_url: None,
|
||||||
|
usage_enabled: None,
|
||||||
|
usage_script: None,
|
||||||
|
usage_api_key: None,
|
||||||
|
usage_base_url: None,
|
||||||
|
usage_access_token: None,
|
||||||
|
usage_user_id: None,
|
||||||
|
usage_auto_interval: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse skill deep link parameters
|
||||||
|
fn parse_skill_deeplink(
|
||||||
|
params: &HashMap<String, String>,
|
||||||
|
version: String,
|
||||||
|
resource: String,
|
||||||
|
) -> Result<DeepLinkImportRequest, AppError> {
|
||||||
|
let repo = params
|
||||||
|
.get("repo")
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'repo' parameter for skill".to_string()))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
// Validate repo format (should be "owner/name")
|
||||||
|
if !repo.contains('/') || repo.split('/').count() != 2 {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Invalid repo format: expected 'owner/name', got '{repo}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let directory = params.get("directory").cloned();
|
||||||
|
let branch = params.get("branch").cloned();
|
||||||
|
|
||||||
|
Ok(DeepLinkImportRequest {
|
||||||
|
version,
|
||||||
|
resource,
|
||||||
|
repo: Some(repo),
|
||||||
|
directory,
|
||||||
|
branch,
|
||||||
|
icon: None,
|
||||||
|
app: Some("claude".to_string()), // Skills are Claude-only
|
||||||
|
name: None,
|
||||||
|
enabled: None,
|
||||||
|
homepage: None,
|
||||||
|
endpoint: None,
|
||||||
|
api_key: None,
|
||||||
|
model: None,
|
||||||
|
notes: None,
|
||||||
|
haiku_model: None,
|
||||||
|
sonnet_model: None,
|
||||||
|
opus_model: None,
|
||||||
|
content: None,
|
||||||
|
description: None,
|
||||||
|
apps: None,
|
||||||
|
config: None,
|
||||||
|
config_format: None,
|
||||||
|
config_url: None,
|
||||||
|
usage_enabled: None,
|
||||||
|
usage_script: None,
|
||||||
|
usage_api_key: None,
|
||||||
|
usage_base_url: None,
|
||||||
|
usage_access_token: None,
|
||||||
|
usage_user_id: None,
|
||||||
|
usage_auto_interval: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
//! Prompt import from deep link
|
||||||
|
//!
|
||||||
|
//! Handles importing prompt configurations via ccswitch:// URLs.
|
||||||
|
|
||||||
|
use super::utils::decode_base64_param;
|
||||||
|
use super::DeepLinkImportRequest;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::prompt::Prompt;
|
||||||
|
use crate::services::PromptService;
|
||||||
|
use crate::store::AppState;
|
||||||
|
use crate::AppType;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
/// Import a prompt from deep link request
|
||||||
|
pub fn import_prompt_from_deeplink(
|
||||||
|
state: &AppState,
|
||||||
|
request: DeepLinkImportRequest,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
// Verify this is a prompt request
|
||||||
|
if request.resource != "prompt" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Expected prompt resource, got '{}'",
|
||||||
|
request.resource
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract required fields
|
||||||
|
let app_str = request
|
||||||
|
.app
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'app' field for prompt".to_string()))?;
|
||||||
|
|
||||||
|
let name = request
|
||||||
|
.name
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'name' field for prompt".to_string()))?;
|
||||||
|
|
||||||
|
// Parse app type
|
||||||
|
let app_type = AppType::from_str(app_str)
|
||||||
|
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?;
|
||||||
|
|
||||||
|
// Decode content
|
||||||
|
let content_b64 = request
|
||||||
|
.content
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'content' field for prompt".to_string()))?;
|
||||||
|
|
||||||
|
let content = decode_base64_param("content", content_b64)?;
|
||||||
|
let content = String::from_utf8(content)
|
||||||
|
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in content: {e}")))?;
|
||||||
|
|
||||||
|
// Generate ID
|
||||||
|
let timestamp = chrono::Utc::now().timestamp_millis();
|
||||||
|
let sanitized_name = name
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
|
||||||
|
.collect::<String>()
|
||||||
|
.to_lowercase();
|
||||||
|
let id = format!("{sanitized_name}-{timestamp}");
|
||||||
|
|
||||||
|
// Check if we should enable this prompt
|
||||||
|
let should_enable = request.enabled.unwrap_or(false);
|
||||||
|
|
||||||
|
// Create Prompt (initially disabled)
|
||||||
|
let prompt = Prompt {
|
||||||
|
id: id.clone(),
|
||||||
|
name: name.clone(),
|
||||||
|
content,
|
||||||
|
description: request.description,
|
||||||
|
enabled: false, // Always start as disabled, will be enabled later if needed
|
||||||
|
created_at: Some(timestamp),
|
||||||
|
updated_at: Some(timestamp),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save using PromptService
|
||||||
|
PromptService::upsert_prompt(state, app_type.clone(), &id, prompt)?;
|
||||||
|
|
||||||
|
// If enabled flag is set, enable this prompt (which will disable others)
|
||||||
|
if should_enable {
|
||||||
|
PromptService::enable_prompt(state, app_type, &id)?;
|
||||||
|
log::info!("Successfully imported and enabled prompt '{name}' for {app_str}");
|
||||||
|
} else {
|
||||||
|
log::info!("Successfully imported prompt '{name}' for {app_str} (disabled)");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
//! Provider import from deep link
|
||||||
|
//!
|
||||||
|
//! Handles importing provider configurations via ccswitch:// URLs.
|
||||||
|
|
||||||
|
use super::utils::{decode_base64_param, infer_homepage_from_endpoint};
|
||||||
|
use super::DeepLinkImportRequest;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::provider::{Provider, ProviderMeta, UsageScript};
|
||||||
|
use crate::services::ProviderService;
|
||||||
|
use crate::store::AppState;
|
||||||
|
use crate::AppType;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
/// Import a provider from a deep link request
|
||||||
|
///
|
||||||
|
/// This function:
|
||||||
|
/// 1. Validates the request
|
||||||
|
/// 2. Merges config file if provided (v3.8+)
|
||||||
|
/// 3. Converts it to a Provider structure
|
||||||
|
/// 4. Delegates to ProviderService for actual import
|
||||||
|
/// 5. Optionally sets as current provider if enabled=true
|
||||||
|
pub fn import_provider_from_deeplink(
|
||||||
|
state: &AppState,
|
||||||
|
request: DeepLinkImportRequest,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
// Verify this is a provider request
|
||||||
|
if request.resource != "provider" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Expected provider resource, got '{}'",
|
||||||
|
request.resource
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Merge config file if provided (v3.8+)
|
||||||
|
let merged_request = parse_and_merge_config(&request)?;
|
||||||
|
|
||||||
|
// Extract required fields (now as Option)
|
||||||
|
let app_str = merged_request
|
||||||
|
.app
|
||||||
|
.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(|| {
|
||||||
|
AppError::InvalidInput("API key is required (either in URL or config file)".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if api_key.is_empty() {
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"API key cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let endpoint = merged_request.endpoint.as_ref().ok_or_else(|| {
|
||||||
|
AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if endpoint.is_empty() {
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"Endpoint cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let homepage = merged_request.homepage.as_ref().ok_or_else(|| {
|
||||||
|
AppError::InvalidInput("Homepage is required (either in URL or config file)".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if homepage.is_empty() {
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"Homepage cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = merged_request
|
||||||
|
.name
|
||||||
|
.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)
|
||||||
|
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?;
|
||||||
|
|
||||||
|
// Build provider configuration based on app type
|
||||||
|
let mut provider = build_provider_from_request(&app_type, &merged_request)?;
|
||||||
|
|
||||||
|
// Generate a unique ID for the provider using timestamp + sanitized name
|
||||||
|
let timestamp = chrono::Utc::now().timestamp_millis();
|
||||||
|
let sanitized_name = name
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
|
||||||
|
.collect::<String>()
|
||||||
|
.to_lowercase();
|
||||||
|
provider.id = format!("{sanitized_name}-{timestamp}");
|
||||||
|
|
||||||
|
let provider_id = provider.id.clone();
|
||||||
|
|
||||||
|
// Use ProviderService to add the provider
|
||||||
|
ProviderService::add(state, app_type.clone(), provider)?;
|
||||||
|
|
||||||
|
// If enabled=true, set as current provider
|
||||||
|
if merged_request.enabled.unwrap_or(false) {
|
||||||
|
ProviderService::switch(state, app_type.clone(), &provider_id)?;
|
||||||
|
log::info!("Provider '{provider_id}' set as current for {app_type:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(provider_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a Provider structure from a deep link request
|
||||||
|
pub(crate) fn build_provider_from_request(
|
||||||
|
app_type: &AppType,
|
||||||
|
request: &DeepLinkImportRequest,
|
||||||
|
) -> Result<Provider, AppError> {
|
||||||
|
let settings_config = match app_type {
|
||||||
|
AppType::Claude => build_claude_settings(request),
|
||||||
|
AppType::Codex => build_codex_settings(request),
|
||||||
|
AppType::Gemini => build_gemini_settings(request),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build usage script configuration if provided
|
||||||
|
let meta = build_provider_meta(request)?;
|
||||||
|
|
||||||
|
let provider = Provider {
|
||||||
|
id: String::new(), // Will be generated by caller
|
||||||
|
name: request.name.clone().unwrap_or_default(),
|
||||||
|
settings_config,
|
||||||
|
website_url: request.homepage.clone(),
|
||||||
|
category: None,
|
||||||
|
created_at: None,
|
||||||
|
sort_index: None,
|
||||||
|
notes: request.notes.clone(),
|
||||||
|
meta,
|
||||||
|
icon: request.icon.clone(),
|
||||||
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
if request.usage_script.is_none()
|
||||||
|
&& request.usage_enabled.is_none()
|
||||||
|
&& request.usage_api_key.is_none()
|
||||||
|
&& request.usage_base_url.is_none()
|
||||||
|
&& request.usage_access_token.is_none()
|
||||||
|
&& request.usage_user_id.is_none()
|
||||||
|
&& request.usage_auto_interval.is_none()
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode usage script code if provided
|
||||||
|
let code = if let Some(script_b64) = &request.usage_script {
|
||||||
|
let decoded = decode_base64_param("usage_script", script_b64)?;
|
||||||
|
String::from_utf8(decoded)
|
||||||
|
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in usage_script: {e}")))?
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine enabled state: explicit param > has code > false
|
||||||
|
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
||||||
|
|
||||||
|
// Build UsageScript - use provider's API key and endpoint as defaults
|
||||||
|
let usage_script = UsageScript {
|
||||||
|
enabled,
|
||||||
|
language: "javascript".to_string(),
|
||||||
|
code,
|
||||||
|
timeout: Some(10),
|
||||||
|
api_key: request
|
||||||
|
.usage_api_key
|
||||||
|
.clone()
|
||||||
|
.or_else(|| request.api_key.clone()),
|
||||||
|
base_url: request
|
||||||
|
.usage_base_url
|
||||||
|
.clone()
|
||||||
|
.or_else(|| request.endpoint.clone()),
|
||||||
|
access_token: request.usage_access_token.clone(),
|
||||||
|
user_id: request.usage_user_id.clone(),
|
||||||
|
auto_query_interval: request.usage_auto_interval,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(ProviderMeta {
|
||||||
|
usage_script: Some(usage_script),
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build Claude settings configuration
|
||||||
|
fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||||
|
let mut env = serde_json::Map::new();
|
||||||
|
env.insert(
|
||||||
|
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
||||||
|
json!(request.api_key.clone().unwrap_or_default()),
|
||||||
|
);
|
||||||
|
env.insert(
|
||||||
|
"ANTHROPIC_BASE_URL".to_string(),
|
||||||
|
json!(request.endpoint.clone().unwrap_or_default()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add default model if provided
|
||||||
|
if let Some(model) = &request.model {
|
||||||
|
env.insert("ANTHROPIC_MODEL".to_string(), json!(model));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Claude-specific model fields (v3.7.1+)
|
||||||
|
if let Some(haiku_model) = &request.haiku_model {
|
||||||
|
env.insert(
|
||||||
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(),
|
||||||
|
json!(haiku_model),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(sonnet_model) = &request.sonnet_model {
|
||||||
|
env.insert(
|
||||||
|
"ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(),
|
||||||
|
json!(sonnet_model),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(opus_model) = &request.opus_model {
|
||||||
|
env.insert(
|
||||||
|
"ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(),
|
||||||
|
json!(opus_model),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
json!({ "env": env })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build Codex settings configuration
|
||||||
|
fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||||
|
// Generate a safe provider name identifier
|
||||||
|
let clean_provider_name = {
|
||||||
|
let raw: String = request
|
||||||
|
.name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "custom".to_string())
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !c.is_control())
|
||||||
|
.collect();
|
||||||
|
let lower = raw.to_lowercase();
|
||||||
|
let mut key: String = lower
|
||||||
|
.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'a'..='z' | '0'..='9' | '_' => c,
|
||||||
|
_ => '_',
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Remove leading/trailing underscores
|
||||||
|
while key.starts_with('_') {
|
||||||
|
key.remove(0);
|
||||||
|
}
|
||||||
|
while key.ends_with('_') {
|
||||||
|
key.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if key.is_empty() {
|
||||||
|
"custom".to_string()
|
||||||
|
} else {
|
||||||
|
key
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Model name: use deeplink model or default
|
||||||
|
let model_name = request
|
||||||
|
.model
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("gpt-5-codex")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Endpoint: normalize trailing slashes
|
||||||
|
let endpoint = request
|
||||||
|
.endpoint
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Build config.toml content
|
||||||
|
let config_toml = format!(
|
||||||
|
r#"model_provider = "{clean_provider_name}"
|
||||||
|
model = "{model_name}"
|
||||||
|
model_reasoning_effort = "high"
|
||||||
|
disable_response_storage = true
|
||||||
|
|
||||||
|
[model_providers.{clean_provider_name}]
|
||||||
|
name = "{clean_provider_name}"
|
||||||
|
base_url = "{endpoint}"
|
||||||
|
wire_api = "responses"
|
||||||
|
requires_openai_auth = true
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"auth": {
|
||||||
|
"OPENAI_API_KEY": request.api_key,
|
||||||
|
},
|
||||||
|
"config": config_toml
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build Gemini settings configuration
|
||||||
|
fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||||
|
let mut env = serde_json::Map::new();
|
||||||
|
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
|
||||||
|
env.insert(
|
||||||
|
"GOOGLE_GEMINI_BASE_URL".to_string(),
|
||||||
|
json!(request.endpoint),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add model if provided
|
||||||
|
if let Some(model) = &request.model {
|
||||||
|
env.insert("GEMINI_MODEL".to_string(), json!(model));
|
||||||
|
}
|
||||||
|
|
||||||
|
json!({ "env": env })
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Config Merge Logic
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Parse and merge configuration from Base64 encoded config or remote URL
|
||||||
|
///
|
||||||
|
/// Priority: URL params > inline config > remote config
|
||||||
|
pub fn parse_and_merge_config(
|
||||||
|
request: &DeepLinkImportRequest,
|
||||||
|
) -> Result<DeepLinkImportRequest, AppError> {
|
||||||
|
// If no config provided, return original request
|
||||||
|
if request.config.is_none() && request.config_url.is_none() {
|
||||||
|
return Ok(request.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Get config content
|
||||||
|
let config_content = if let Some(config_b64) = &request.config {
|
||||||
|
// Decode Base64 inline config
|
||||||
|
let decoded = decode_base64_param("config", config_b64)?;
|
||||||
|
String::from_utf8(decoded)
|
||||||
|
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in config: {e}")))?
|
||||||
|
} else if let Some(_config_url) = &request.config_url {
|
||||||
|
// Fetch remote config (TODO: implement remote fetching in next phase)
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"Remote config URL is not yet supported. Use inline config instead.".to_string(),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
return Ok(request.clone());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 2: Parse config based on format
|
||||||
|
let format = request.config_format.as_deref().unwrap_or("json");
|
||||||
|
let config_value: serde_json::Value = match format {
|
||||||
|
"json" => serde_json::from_str(&config_content)
|
||||||
|
.map_err(|e| AppError::InvalidInput(format!("Invalid JSON config: {e}")))?,
|
||||||
|
"toml" => {
|
||||||
|
let toml_value: toml::Value = toml::from_str(&config_content)
|
||||||
|
.map_err(|e| AppError::InvalidInput(format!("Invalid TOML config: {e}")))?;
|
||||||
|
// Convert TOML to JSON for uniform processing
|
||||||
|
serde_json::to_value(toml_value)
|
||||||
|
.map_err(|e| AppError::Message(format!("Failed to convert TOML to JSON: {e}")))?
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Unsupported config format: {format}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 3: Extract values from config based on app type and merge with URL params
|
||||||
|
let mut merged = request.clone();
|
||||||
|
|
||||||
|
// MCP, Skill and other resource types don't need config merging
|
||||||
|
if request.resource != "provider" {
|
||||||
|
return Ok(merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
match request.app.as_deref().unwrap_or("") {
|
||||||
|
"claude" => merge_claude_config(&mut merged, &config_value)?,
|
||||||
|
"codex" => merge_codex_config(&mut merged, &config_value)?,
|
||||||
|
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
|
||||||
|
"" => {
|
||||||
|
// No app specified, skip merging
|
||||||
|
return Ok(merged);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Invalid app type: {:?}",
|
||||||
|
request.app
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge Claude configuration from config file
|
||||||
|
fn merge_claude_config(
|
||||||
|
request: &mut DeepLinkImportRequest,
|
||||||
|
config: &serde_json::Value,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let env = config
|
||||||
|
.get("env")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::InvalidInput("Claude config must have 'env' object".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Auto-fill API key if not provided in URL
|
||||||
|
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.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.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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-fill model fields (URL params take priority)
|
||||||
|
if request.model.is_none() {
|
||||||
|
request.model = env
|
||||||
|
.get("ANTHROPIC_MODEL")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
if request.haiku_model.is_none() {
|
||||||
|
request.haiku_model = env
|
||||||
|
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
if request.sonnet_model.is_none() {
|
||||||
|
request.sonnet_model = env
|
||||||
|
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
if request.opus_model.is_none() {
|
||||||
|
request.opus_model = env
|
||||||
|
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge Codex configuration from config file
|
||||||
|
fn merge_codex_config(
|
||||||
|
request: &mut DeepLinkImportRequest,
|
||||||
|
config: &serde_json::Value,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
// Auto-fill API key from auth.OPENAI_API_KEY
|
||||||
|
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"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
request.api_key = Some(api_key.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-fill endpoint and model from config string
|
||||||
|
if let Some(config_str) = config.get("config").and_then(|v| v.as_str()) {
|
||||||
|
// 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.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract model
|
||||||
|
if request.model.is_none() {
|
||||||
|
if let Some(model) = toml_value.get("model").and_then(|v| v.as_str()) {
|
||||||
|
request.model = Some(model.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-fill homepage from endpoint
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge Gemini configuration from config file
|
||||||
|
fn merge_gemini_config(
|
||||||
|
request: &mut DeepLinkImportRequest,
|
||||||
|
config: &serde_json::Value,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
// Gemini uses flat env structure
|
||||||
|
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.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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.model.is_none() {
|
||||||
|
request.model = config
|
||||||
|
.get("GEMINI_MODEL")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-fill homepage from endpoint
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract base_url from Codex TOML config
|
||||||
|
fn extract_codex_base_url(toml_value: &toml::Value) -> Option<String> {
|
||||||
|
// Try to find base_url in model_providers section
|
||||||
|
if let Some(providers) = toml_value.get("model_providers").and_then(|v| v.as_table()) {
|
||||||
|
for (_key, provider) in providers.iter() {
|
||||||
|
if let Some(base_url) = provider.get("base_url").and_then(|v| v.as_str()) {
|
||||||
|
return Some(base_url.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//! Skill import from deep link
|
||||||
|
//!
|
||||||
|
//! Handles importing skill repository configurations via ccswitch:// URLs.
|
||||||
|
|
||||||
|
use super::DeepLinkImportRequest;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::services::skill::SkillRepo;
|
||||||
|
use crate::store::AppState;
|
||||||
|
|
||||||
|
/// Import a skill from deep link request
|
||||||
|
pub fn import_skill_from_deeplink(
|
||||||
|
state: &AppState,
|
||||||
|
request: DeepLinkImportRequest,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
// Verify this is a skill request
|
||||||
|
if request.resource != "skill" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Expected skill resource, got '{}'",
|
||||||
|
request.resource
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse repo
|
||||||
|
let repo_str = request
|
||||||
|
.repo
|
||||||
|
.ok_or_else(|| AppError::InvalidInput("Missing 'repo' field for skill".to_string()))?;
|
||||||
|
|
||||||
|
let parts: Vec<&str> = repo_str.split('/').collect();
|
||||||
|
if parts.len() != 2 {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Invalid repo format: expected 'owner/name', got '{repo_str}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let owner = parts[0].to_string();
|
||||||
|
let name = parts[1].to_string();
|
||||||
|
|
||||||
|
// Create SkillRepo
|
||||||
|
let repo = SkillRepo {
|
||||||
|
owner: owner.clone(),
|
||||||
|
name: name.clone(),
|
||||||
|
branch: request.branch.unwrap_or_else(|| "main".to_string()),
|
||||||
|
enabled: request.enabled.unwrap_or(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save using Database
|
||||||
|
state.db.save_skill_repo(&repo)?;
|
||||||
|
|
||||||
|
log::info!("Successfully added skill repo '{owner}/{name}'");
|
||||||
|
|
||||||
|
Ok(format!("{owner}/{name}"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
//! Deep link module tests
|
||||||
|
|
||||||
|
use super::mcp::parse_mcp_apps;
|
||||||
|
use super::parser::parse_deeplink_url;
|
||||||
|
use super::prompt::import_prompt_from_deeplink;
|
||||||
|
use super::provider::parse_and_merge_config;
|
||||||
|
use super::utils::{infer_homepage_from_endpoint, validate_url};
|
||||||
|
use super::DeepLinkImportRequest;
|
||||||
|
use crate::AppType;
|
||||||
|
use crate::{store::AppState, Database};
|
||||||
|
use base64::prelude::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Parser Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_valid_claude_deeplink() {
|
||||||
|
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test%20Provider&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test-123&icon=claude";
|
||||||
|
|
||||||
|
let request = parse_deeplink_url(url).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(request.version, "v1");
|
||||||
|
assert_eq!(request.resource, "provider");
|
||||||
|
assert_eq!(request.app, Some("claude".to_string()));
|
||||||
|
assert_eq!(request.name, Some("Test Provider".to_string()));
|
||||||
|
assert_eq!(request.homepage, Some("https://example.com".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
request.endpoint,
|
||||||
|
Some("https://api.example.com".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(request.api_key, Some("sk-test-123".to_string()));
|
||||||
|
assert_eq!(request.icon, Some("claude".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_deeplink_with_notes() {
|
||||||
|
let url = "ccswitch://v1/import?resource=provider&app=codex&name=Codex&homepage=https%3A%2F%2Fcodex.com&endpoint=https%3A%2F%2Fapi.codex.com&apiKey=key123¬es=Test%20notes";
|
||||||
|
|
||||||
|
let request = parse_deeplink_url(url).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(request.notes, Some("Test notes".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_invalid_scheme() {
|
||||||
|
let url = "https://v1/import?resource=provider&app=claude&name=Test";
|
||||||
|
|
||||||
|
let result = parse_deeplink_url(url);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("Invalid scheme"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_unsupported_version() {
|
||||||
|
let url = "ccswitch://v2/import?resource=provider&app=claude&name=Test";
|
||||||
|
|
||||||
|
let result = parse_deeplink_url(url);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("Unsupported protocol version"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_missing_required_field() {
|
||||||
|
// Name is still required even in v3.8+ (only homepage/endpoint/apiKey are optional)
|
||||||
|
let url = "ccswitch://v1/import?resource=provider&app=claude";
|
||||||
|
|
||||||
|
let result = parse_deeplink_url(url);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("Missing 'name' parameter"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Utils Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_invalid_url() {
|
||||||
|
let result = validate_url("not-a-url", "test");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_invalid_scheme() {
|
||||||
|
let result = validate_url("ftp://example.com", "test");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("must be http or https"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_infer_homepage() {
|
||||||
|
assert_eq!(
|
||||||
|
infer_homepage_from_endpoint("https://api.anthropic.com/v1"),
|
||||||
|
Some("https://anthropic.com".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
infer_homepage_from_endpoint("https://api-test.company.com/v1"),
|
||||||
|
Some("https://test.company.com".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
infer_homepage_from_endpoint("https://example.com"),
|
||||||
|
Some("https://example.com".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Provider Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_gemini_provider_with_model() {
|
||||||
|
use super::provider::build_provider_from_request;
|
||||||
|
|
||||||
|
let request = DeepLinkImportRequest {
|
||||||
|
version: "v1".to_string(),
|
||||||
|
resource: "provider".to_string(),
|
||||||
|
app: Some("gemini".to_string()),
|
||||||
|
name: Some("Test Gemini".to_string()),
|
||||||
|
homepage: Some("https://example.com".to_string()),
|
||||||
|
endpoint: Some("https://api.example.com".to_string()),
|
||||||
|
api_key: Some("test-api-key".to_string()),
|
||||||
|
icon: None,
|
||||||
|
model: Some("gemini-2.0-flash".to_string()),
|
||||||
|
notes: None,
|
||||||
|
haiku_model: None,
|
||||||
|
sonnet_model: None,
|
||||||
|
opus_model: None,
|
||||||
|
config: None,
|
||||||
|
config_format: None,
|
||||||
|
config_url: None,
|
||||||
|
apps: None,
|
||||||
|
repo: None,
|
||||||
|
directory: None,
|
||||||
|
branch: None,
|
||||||
|
content: None,
|
||||||
|
description: None,
|
||||||
|
enabled: None,
|
||||||
|
usage_enabled: None,
|
||||||
|
usage_script: None,
|
||||||
|
usage_api_key: None,
|
||||||
|
usage_base_url: None,
|
||||||
|
usage_access_token: None,
|
||||||
|
usage_user_id: None,
|
||||||
|
usage_auto_interval: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
|
||||||
|
|
||||||
|
// Verify provider basic info
|
||||||
|
assert_eq!(provider.name, "Test Gemini");
|
||||||
|
assert_eq!(
|
||||||
|
provider.website_url,
|
||||||
|
Some("https://example.com".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify settings_config structure
|
||||||
|
let env = provider.settings_config["env"].as_object().unwrap();
|
||||||
|
assert_eq!(env["GEMINI_API_KEY"], "test-api-key");
|
||||||
|
assert_eq!(env["GOOGLE_GEMINI_BASE_URL"], "https://api.example.com");
|
||||||
|
assert_eq!(env["GEMINI_MODEL"], "gemini-2.0-flash");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_gemini_provider_without_model() {
|
||||||
|
use super::provider::build_provider_from_request;
|
||||||
|
|
||||||
|
let request = DeepLinkImportRequest {
|
||||||
|
version: "v1".to_string(),
|
||||||
|
resource: "provider".to_string(),
|
||||||
|
app: Some("gemini".to_string()),
|
||||||
|
name: Some("Test Gemini".to_string()),
|
||||||
|
homepage: Some("https://example.com".to_string()),
|
||||||
|
endpoint: Some("https://api.example.com".to_string()),
|
||||||
|
api_key: Some("test-api-key".to_string()),
|
||||||
|
icon: None,
|
||||||
|
model: None,
|
||||||
|
notes: None,
|
||||||
|
haiku_model: None,
|
||||||
|
sonnet_model: None,
|
||||||
|
opus_model: None,
|
||||||
|
config: None,
|
||||||
|
config_format: None,
|
||||||
|
config_url: None,
|
||||||
|
apps: None,
|
||||||
|
repo: None,
|
||||||
|
directory: None,
|
||||||
|
branch: None,
|
||||||
|
content: None,
|
||||||
|
description: None,
|
||||||
|
enabled: None,
|
||||||
|
usage_enabled: None,
|
||||||
|
usage_script: None,
|
||||||
|
usage_api_key: None,
|
||||||
|
usage_base_url: None,
|
||||||
|
usage_access_token: None,
|
||||||
|
usage_user_id: None,
|
||||||
|
usage_auto_interval: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
|
||||||
|
|
||||||
|
let env = provider.settings_config["env"].as_object().unwrap();
|
||||||
|
assert_eq!(env["GEMINI_API_KEY"], "test-api-key");
|
||||||
|
assert_eq!(env["GOOGLE_GEMINI_BASE_URL"], "https://api.example.com");
|
||||||
|
// Model should not be present
|
||||||
|
assert!(env.get("GEMINI_MODEL").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_and_merge_config_claude() {
|
||||||
|
// Prepare Base64 encoded Claude config
|
||||||
|
let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-ant-xxx","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1","ANTHROPIC_MODEL":"claude-sonnet-4.5"}}"#;
|
||||||
|
let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes());
|
||||||
|
|
||||||
|
let request = DeepLinkImportRequest {
|
||||||
|
version: "v1".to_string(),
|
||||||
|
resource: "provider".to_string(),
|
||||||
|
app: Some("claude".to_string()),
|
||||||
|
name: Some("Test".to_string()),
|
||||||
|
homepage: None,
|
||||||
|
endpoint: None,
|
||||||
|
api_key: None,
|
||||||
|
icon: None,
|
||||||
|
model: None,
|
||||||
|
notes: None,
|
||||||
|
haiku_model: None,
|
||||||
|
sonnet_model: None,
|
||||||
|
opus_model: None,
|
||||||
|
config: Some(config_b64),
|
||||||
|
config_format: Some("json".to_string()),
|
||||||
|
config_url: None,
|
||||||
|
apps: None,
|
||||||
|
repo: None,
|
||||||
|
directory: None,
|
||||||
|
branch: None,
|
||||||
|
content: None,
|
||||||
|
description: None,
|
||||||
|
enabled: None,
|
||||||
|
usage_enabled: None,
|
||||||
|
usage_script: None,
|
||||||
|
usage_api_key: None,
|
||||||
|
usage_base_url: None,
|
||||||
|
usage_access_token: None,
|
||||||
|
usage_user_id: None,
|
||||||
|
usage_auto_interval: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let merged = parse_and_merge_config(&request).unwrap();
|
||||||
|
|
||||||
|
// Should auto-fill from config
|
||||||
|
assert_eq!(merged.api_key, Some("sk-ant-xxx".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
merged.endpoint,
|
||||||
|
Some("https://api.anthropic.com/v1".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(merged.homepage, Some("https://anthropic.com".to_string()));
|
||||||
|
assert_eq!(merged.model, Some("claude-sonnet-4.5".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_and_merge_config_url_override() {
|
||||||
|
let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-old","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1"}}"#;
|
||||||
|
let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes());
|
||||||
|
|
||||||
|
let request = DeepLinkImportRequest {
|
||||||
|
version: "v1".to_string(),
|
||||||
|
resource: "provider".to_string(),
|
||||||
|
app: Some("claude".to_string()),
|
||||||
|
name: Some("Test".to_string()),
|
||||||
|
homepage: None,
|
||||||
|
endpoint: None,
|
||||||
|
api_key: Some("sk-new".to_string()), // URL param should override
|
||||||
|
icon: None,
|
||||||
|
model: None,
|
||||||
|
notes: None,
|
||||||
|
haiku_model: None,
|
||||||
|
sonnet_model: None,
|
||||||
|
opus_model: None,
|
||||||
|
config: Some(config_b64),
|
||||||
|
config_format: Some("json".to_string()),
|
||||||
|
config_url: None,
|
||||||
|
apps: None,
|
||||||
|
repo: None,
|
||||||
|
directory: None,
|
||||||
|
branch: None,
|
||||||
|
content: None,
|
||||||
|
description: None,
|
||||||
|
enabled: None,
|
||||||
|
usage_enabled: None,
|
||||||
|
usage_script: None,
|
||||||
|
usage_api_key: None,
|
||||||
|
usage_base_url: None,
|
||||||
|
usage_access_token: None,
|
||||||
|
usage_user_id: None,
|
||||||
|
usage_auto_interval: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let merged = parse_and_merge_config(&request).unwrap();
|
||||||
|
|
||||||
|
// URL param should take priority
|
||||||
|
assert_eq!(merged.api_key, Some("sk-new".to_string()));
|
||||||
|
// Config file value should be used
|
||||||
|
assert_eq!(
|
||||||
|
merged.endpoint,
|
||||||
|
Some("https://api.anthropic.com/v1".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Prompt Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_import_prompt_allows_space_in_base64_content() {
|
||||||
|
let url = "ccswitch://v1/import?resource=prompt&app=codex&name=PromptPlus&content=Pj4+";
|
||||||
|
let request = parse_deeplink_url(url).unwrap();
|
||||||
|
|
||||||
|
// URL decoded content may have "+" become space
|
||||||
|
assert_eq!(request.content.as_deref(), Some("Pj4 "));
|
||||||
|
|
||||||
|
let db = Arc::new(Database::memory().expect("create memory db"));
|
||||||
|
let state = AppState::new(db.clone());
|
||||||
|
|
||||||
|
let prompt_id = import_prompt_from_deeplink(&state, request.clone()).expect("import prompt");
|
||||||
|
|
||||||
|
let prompts = state.db.get_prompts("codex").expect("get prompts");
|
||||||
|
let prompt = prompts.get(&prompt_id).expect("prompt saved");
|
||||||
|
|
||||||
|
assert_eq!(prompt.content, ">>>");
|
||||||
|
assert_eq!(prompt.name, request.name.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MCP Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_mcp_apps() {
|
||||||
|
let apps = parse_mcp_apps("claude,codex").unwrap();
|
||||||
|
assert!(apps.claude);
|
||||||
|
assert!(apps.codex);
|
||||||
|
assert!(!apps.gemini);
|
||||||
|
|
||||||
|
let apps = parse_mcp_apps("gemini").unwrap();
|
||||||
|
assert!(!apps.claude);
|
||||||
|
assert!(!apps.codex);
|
||||||
|
assert!(apps.gemini);
|
||||||
|
|
||||||
|
let err = parse_mcp_apps("invalid").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("Invalid app"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_prompt_deeplink() {
|
||||||
|
let content = "Hello World";
|
||||||
|
let content_b64 = BASE64_STANDARD.encode(content);
|
||||||
|
let url = format!(
|
||||||
|
"ccswitch://v1/import?resource=prompt&app=claude&name=test&content={}&description=desc&enabled=true",
|
||||||
|
content_b64
|
||||||
|
);
|
||||||
|
|
||||||
|
let request = parse_deeplink_url(&url).unwrap();
|
||||||
|
assert_eq!(request.resource, "prompt");
|
||||||
|
assert_eq!(request.app.unwrap(), "claude");
|
||||||
|
assert_eq!(request.name.unwrap(), "test");
|
||||||
|
assert_eq!(request.content.unwrap(), content_b64);
|
||||||
|
assert_eq!(request.description.unwrap(), "desc");
|
||||||
|
assert!(request.enabled.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_mcp_deeplink() {
|
||||||
|
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
|
||||||
|
let config_b64 = BASE64_STANDARD.encode(config);
|
||||||
|
let url = format!(
|
||||||
|
"ccswitch://v1/import?resource=mcp&apps=claude,codex&config={}&enabled=true",
|
||||||
|
config_b64
|
||||||
|
);
|
||||||
|
|
||||||
|
let request = parse_deeplink_url(&url).unwrap();
|
||||||
|
assert_eq!(request.resource, "mcp");
|
||||||
|
assert_eq!(request.apps.unwrap(), "claude,codex");
|
||||||
|
assert_eq!(request.config.unwrap(), config_b64);
|
||||||
|
assert!(request.enabled.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_skill_deeplink() {
|
||||||
|
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev";
|
||||||
|
let request = parse_deeplink_url(url).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(request.resource, "skill");
|
||||||
|
assert_eq!(request.repo.unwrap(), "owner/repo");
|
||||||
|
assert_eq!(request.directory.unwrap(), "skills");
|
||||||
|
assert_eq!(request.branch.unwrap(), "dev");
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
//! Deep link utility functions
|
||||||
|
//!
|
||||||
|
//! Common helpers for URL validation, Base64 decoding, etc.
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use base64::prelude::*;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
/// Validate that a string is a valid HTTP(S) URL
|
||||||
|
pub fn validate_url(url_str: &str, field_name: &str) -> Result<(), AppError> {
|
||||||
|
let url = Url::parse(url_str)
|
||||||
|
.map_err(|e| AppError::InvalidInput(format!("Invalid URL for '{field_name}': {e}")))?;
|
||||||
|
|
||||||
|
let scheme = url.scheme();
|
||||||
|
if scheme != "http" && scheme != "https" {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Invalid URL scheme for '{field_name}': must be http or https, got '{scheme}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a Base64 parameter from deep link URL
|
||||||
|
///
|
||||||
|
/// This function handles common issues with Base64 in URLs:
|
||||||
|
/// - `+` being decoded as space
|
||||||
|
/// - Missing padding `=`
|
||||||
|
/// - Both standard and URL-safe Base64 variants
|
||||||
|
pub fn decode_base64_param(field: &str, raw: &str) -> Result<Vec<u8>, AppError> {
|
||||||
|
let mut candidates: Vec<String> = Vec::new();
|
||||||
|
// Keep spaces (to restore `+`), but remove newlines
|
||||||
|
let trimmed = raw.trim_matches(|c| c == '\r' || c == '\n');
|
||||||
|
|
||||||
|
// First try restoring spaces to "+"
|
||||||
|
if trimmed.contains(' ') {
|
||||||
|
let replaced = trimmed.replace(' ', "+");
|
||||||
|
if !replaced.is_empty() && !candidates.contains(&replaced) {
|
||||||
|
candidates.push(replaced);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Original value
|
||||||
|
if !trimmed.is_empty() && !candidates.contains(&trimmed.to_string()) {
|
||||||
|
candidates.push(trimmed.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add padding variants
|
||||||
|
let existing = candidates.clone();
|
||||||
|
for candidate in existing {
|
||||||
|
let mut padded = candidate.clone();
|
||||||
|
let remainder = padded.len() % 4;
|
||||||
|
if remainder != 0 {
|
||||||
|
padded.extend(std::iter::repeat_n('=', 4 - remainder));
|
||||||
|
}
|
||||||
|
if !candidates.contains(&padded) {
|
||||||
|
candidates.push(padded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut last_error: Option<String> = None;
|
||||||
|
for candidate in candidates {
|
||||||
|
for engine in [
|
||||||
|
&BASE64_STANDARD,
|
||||||
|
&BASE64_STANDARD_NO_PAD,
|
||||||
|
&BASE64_URL_SAFE,
|
||||||
|
&BASE64_URL_SAFE_NO_PAD,
|
||||||
|
] {
|
||||||
|
match engine.decode(&candidate) {
|
||||||
|
Ok(bytes) => return Ok(bytes),
|
||||||
|
Err(err) => last_error = Some(err.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AppError::InvalidInput(format!(
|
||||||
|
"{field} 参数 Base64 解码失败:{}。请确认链接参数已用 Base64 编码并经过 URL 转义(尤其是将 '+' 编码为 %2B,或使用 URL-safe Base64)。",
|
||||||
|
last_error.unwrap_or_else(|| "未知错误".to_string())
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Infer homepage URL from API endpoint
|
||||||
|
///
|
||||||
|
/// Examples:
|
||||||
|
/// - https://api.anthropic.com/v1 → https://anthropic.com
|
||||||
|
/// - https://api.openai.com/v1 → https://openai.com
|
||||||
|
/// - https://api-test.company.com/v1 → https://company.com
|
||||||
|
pub fn infer_homepage_from_endpoint(endpoint: &str) -> Option<String> {
|
||||||
|
let url = Url::parse(endpoint).ok()?;
|
||||||
|
let host = url.host_str()?;
|
||||||
|
|
||||||
|
// Remove common API prefixes
|
||||||
|
let clean_host = host
|
||||||
|
.strip_prefix("api.")
|
||||||
|
.or_else(|| host.strip_prefix("api-"))
|
||||||
|
.unwrap_or(host);
|
||||||
|
|
||||||
|
Some(format!("https://{clean_host}"))
|
||||||
|
}
|
||||||
@@ -50,6 +50,12 @@ pub enum AppError {
|
|||||||
zh: String,
|
zh: String,
|
||||||
en: String,
|
en: String,
|
||||||
},
|
},
|
||||||
|
#[error("数据库错误: {0}")]
|
||||||
|
Database(String),
|
||||||
|
#[error("所有供应商已熔断,无可用渠道")]
|
||||||
|
AllProvidersCircuitOpen,
|
||||||
|
#[error("未配置供应商")]
|
||||||
|
NoProvidersConfigured,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppError {
|
impl AppError {
|
||||||
@@ -89,8 +95,48 @@ impl<T> From<PoisonError<T>> for AppError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<rusqlite::Error> for AppError {
|
||||||
|
fn from(err: rusqlite::Error) -> Self {
|
||||||
|
Self::Database(err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<AppError> for String {
|
impl From<AppError> for String {
|
||||||
fn from(err: AppError) -> Self {
|
fn from(err: AppError) -> Self {
|
||||||
err.to_string()
|
err.to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl serde::Serialize for AppError {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&self.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 格式化为 JSON 错误字符串,前端可解析为结构化错误
|
||||||
|
pub fn format_skill_error(
|
||||||
|
code: &str,
|
||||||
|
context: &[(&str, &str)],
|
||||||
|
suggestion: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
let mut ctx_map = serde_json::Map::new();
|
||||||
|
for (key, value) in context {
|
||||||
|
ctx_map.insert(key.to_string(), json!(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
let error_obj = json!({
|
||||||
|
"code": code,
|
||||||
|
"context": ctx_map,
|
||||||
|
"suggestion": suggestion,
|
||||||
|
});
|
||||||
|
|
||||||
|
serde_json::to_string(&error_obj).unwrap_or_else(|_| {
|
||||||
|
// 如果 JSON 序列化失败,返回简单格式
|
||||||
|
format!("ERROR:{code}")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,656 @@
|
|||||||
|
use crate::config::write_text_file;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// 获取 Gemini 配置目录路径(支持设置覆盖)
|
||||||
|
pub fn get_gemini_dir() -> PathBuf {
|
||||||
|
if let Some(custom) = crate::settings::get_gemini_override_dir() {
|
||||||
|
return custom;
|
||||||
|
}
|
||||||
|
|
||||||
|
dirs::home_dir()
|
||||||
|
.expect("无法获取用户主目录")
|
||||||
|
.join(".gemini")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Gemini .env 文件路径
|
||||||
|
pub fn get_gemini_env_path() -> PathBuf {
|
||||||
|
get_gemini_dir().join(".env")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析 .env 文件内容为键值对
|
||||||
|
///
|
||||||
|
/// 此函数宽松地解析 .env 文件,跳过无效行。
|
||||||
|
/// 对于需要严格验证的场景,请使用 `parse_env_file_strict`。
|
||||||
|
pub fn parse_env_file(content: &str) -> HashMap<String, String> {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
|
||||||
|
for line in content.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
|
||||||
|
// 跳过空行和注释
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 KEY=VALUE
|
||||||
|
if let Some((key, value)) = line.split_once('=') {
|
||||||
|
let key = key.trim().to_string();
|
||||||
|
let value = value.trim().to_string();
|
||||||
|
|
||||||
|
// 验证 key 是否有效(不为空,只包含字母、数字和下划线)
|
||||||
|
if !key.is_empty() && key.chars().all(|c| c.is_alphanumeric() || c == '_') {
|
||||||
|
map.insert(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 严格解析 .env 文件内容,返回详细的错误信息
|
||||||
|
///
|
||||||
|
/// 与 `parse_env_file` 不同,此函数在遇到无效行时会返回错误,
|
||||||
|
/// 包含行号和详细的错误信息。
|
||||||
|
///
|
||||||
|
/// # 错误
|
||||||
|
///
|
||||||
|
/// 返回 `AppError` 如果遇到以下情况:
|
||||||
|
/// - 行不包含 `=` 分隔符
|
||||||
|
/// - Key 为空或包含无效字符
|
||||||
|
/// - Key 不符合环境变量命名规范
|
||||||
|
///
|
||||||
|
/// # 使用场景
|
||||||
|
///
|
||||||
|
/// 此函数为未来的严格验证场景预留,当前运行时使用宽松的 `parse_env_file`。
|
||||||
|
/// 可用于:
|
||||||
|
/// - 配置导入验证
|
||||||
|
/// - CLI 工具的严格模式
|
||||||
|
/// - 配置文件错误诊断
|
||||||
|
///
|
||||||
|
/// 已有完整的测试覆盖,可直接使用。
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn parse_env_file_strict(content: &str) -> Result<HashMap<String, String>, AppError> {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
|
||||||
|
for (line_num, line) in content.lines().enumerate() {
|
||||||
|
let line = line.trim();
|
||||||
|
let line_number = line_num + 1; // 行号从 1 开始
|
||||||
|
|
||||||
|
// 跳过空行和注释
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否包含 =
|
||||||
|
if !line.contains('=') {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.env.parse_error.no_equals",
|
||||||
|
format!("Gemini .env 文件格式错误(第 {line_number} 行):缺少 '=' 分隔符\n行内容: {line}"),
|
||||||
|
format!("Invalid Gemini .env format (line {line_number}): missing '=' separator\nLine: {line}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 KEY=VALUE
|
||||||
|
if let Some((key, value)) = line.split_once('=') {
|
||||||
|
let key = key.trim();
|
||||||
|
let value = value.trim();
|
||||||
|
|
||||||
|
// 验证 key 不为空
|
||||||
|
if key.is_empty() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.env.parse_error.empty_key",
|
||||||
|
format!("Gemini .env 文件格式错误(第 {line_number} 行):环境变量名不能为空\n行内容: {line}"),
|
||||||
|
format!("Invalid Gemini .env format (line {line_number}): variable name cannot be empty\nLine: {line}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 key 只包含字母、数字和下划线
|
||||||
|
if !key.chars().all(|c| c.is_alphanumeric() || c == '_') {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.env.parse_error.invalid_key",
|
||||||
|
format!("Gemini .env 文件格式错误(第 {line_number} 行):环境变量名只能包含字母、数字和下划线\n变量名: {key}"),
|
||||||
|
format!("Invalid Gemini .env format (line {line_number}): variable name can only contain letters, numbers, and underscores\nVariable: {key}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
map.insert(key.to_string(), value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将键值对序列化为 .env 格式
|
||||||
|
pub fn serialize_env_file(map: &HashMap<String, String>) -> String {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
// 按键排序以保证输出稳定
|
||||||
|
let mut keys: Vec<_> = map.keys().collect();
|
||||||
|
keys.sort();
|
||||||
|
|
||||||
|
for key in keys {
|
||||||
|
if let Some(value) = map.get(key) {
|
||||||
|
lines.push(format!("{key}={value}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取 Gemini .env 文件
|
||||||
|
pub fn read_gemini_env() -> Result<HashMap<String, String>, AppError> {
|
||||||
|
let path = get_gemini_env_path();
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||||
|
|
||||||
|
Ok(parse_env_file(&content))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写入 Gemini .env 文件(原子操作)
|
||||||
|
pub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppError> {
|
||||||
|
let path = get_gemini_env_path();
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
|
|
||||||
|
// 设置目录权限为 700(仅所有者可读写执行)
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mut perms = fs::metadata(parent)
|
||||||
|
.map_err(|e| AppError::io(parent, e))?
|
||||||
|
.permissions();
|
||||||
|
perms.set_mode(0o700);
|
||||||
|
fs::set_permissions(parent, perms).map_err(|e| AppError::io(parent, e))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = serialize_env_file(map);
|
||||||
|
write_text_file(&path, &content)?;
|
||||||
|
|
||||||
|
// 设置文件权限为 600(仅所有者可读写)
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mut perms = fs::metadata(&path)
|
||||||
|
.map_err(|e| AppError::io(&path, e))?
|
||||||
|
.permissions();
|
||||||
|
perms.set_mode(0o600);
|
||||||
|
fs::set_permissions(&path, perms).map_err(|e| AppError::io(&path, e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 .env 格式转换为 Provider.settings_config (JSON Value)
|
||||||
|
pub fn env_to_json(env_map: &HashMap<String, String>) -> Value {
|
||||||
|
let mut json_map = serde_json::Map::new();
|
||||||
|
|
||||||
|
for (key, value) in env_map {
|
||||||
|
json_map.insert(key.clone(), Value::String(value.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::json!({ "env": json_map })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Provider.settings_config (JSON Value) 提取 .env 格式
|
||||||
|
pub fn json_to_env(settings: &Value) -> Result<HashMap<String, String>, AppError> {
|
||||||
|
let mut env_map = HashMap::new();
|
||||||
|
|
||||||
|
if let Some(env_obj) = settings.get("env").and_then(|v| v.as_object()) {
|
||||||
|
for (key, value) in env_obj {
|
||||||
|
if let Some(val_str) = value.as_str() {
|
||||||
|
env_map.insert(key.clone(), val_str.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(env_map)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 验证 Gemini 配置的基本结构
|
||||||
|
///
|
||||||
|
/// 此函数只验证配置的基本格式,不强制要求 GEMINI_API_KEY。
|
||||||
|
/// 这允许用户先创建供应商配置,稍后再填写 API Key。
|
||||||
|
///
|
||||||
|
/// API Key 的验证会在切换供应商时进行(通过 `validate_gemini_settings_strict`)。
|
||||||
|
pub fn validate_gemini_settings(settings: &Value) -> Result<(), AppError> {
|
||||||
|
// 只验证基本结构,不强制要求 GEMINI_API_KEY
|
||||||
|
// 如果有 env 字段,验证它是一个对象
|
||||||
|
if let Some(env) = settings.get("env") {
|
||||||
|
if !env.is_object() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.validation.invalid_env",
|
||||||
|
"Gemini 配置格式错误: env 必须是对象",
|
||||||
|
"Gemini config invalid: env must be an object",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有 config 字段,验证它是对象或 null
|
||||||
|
if let Some(config) = settings.get("config") {
|
||||||
|
if !(config.is_object() || config.is_null()) {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.validation.invalid_config",
|
||||||
|
"Gemini 配置格式错误: config 必须是对象",
|
||||||
|
"Gemini config invalid: config must be an object",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 严格验证 Gemini 配置(要求必需字段)
|
||||||
|
///
|
||||||
|
/// 此函数在切换供应商时使用,确保配置包含所有必需的字段。
|
||||||
|
/// 对于需要 API Key 的供应商(如 PackyCode),会验证 GEMINI_API_KEY 字段。
|
||||||
|
pub fn validate_gemini_settings_strict(settings: &Value) -> Result<(), AppError> {
|
||||||
|
// 先做基础格式验证(包含 env/config 类型)
|
||||||
|
validate_gemini_settings(settings)?;
|
||||||
|
|
||||||
|
let env_map = json_to_env(settings)?;
|
||||||
|
|
||||||
|
// 如果 env 为空,表示使用 OAuth(如 Google 官方),跳过验证
|
||||||
|
if env_map.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 env 不为空,检查必需字段 GEMINI_API_KEY
|
||||||
|
if !env_map.contains_key("GEMINI_API_KEY") {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.validation.missing_api_key",
|
||||||
|
"Gemini 配置缺少必需字段: GEMINI_API_KEY",
|
||||||
|
"Gemini config missing required field: GEMINI_API_KEY",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Gemini settings.json 文件路径
|
||||||
|
///
|
||||||
|
/// 返回路径:`~/.gemini/settings.json`(与 `.env` 文件同级)
|
||||||
|
pub fn get_gemini_settings_path() -> PathBuf {
|
||||||
|
get_gemini_dir().join("settings.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新 Gemini 目录 settings.json 中的 security.auth.selectedType 字段
|
||||||
|
///
|
||||||
|
/// 此函数会:
|
||||||
|
/// 1. 读取现有的 settings.json(如果存在)
|
||||||
|
/// 2. 只更新 `security.auth.selectedType` 字段,保留其他所有字段
|
||||||
|
/// 3. 原子性写入文件
|
||||||
|
///
|
||||||
|
/// # 参数
|
||||||
|
/// - `selected_type`: 要设置的 selectedType 值(如 "gemini-api-key" 或 "oauth-personal")
|
||||||
|
fn update_selected_type(selected_type: &str) -> Result<(), AppError> {
|
||||||
|
let settings_path = get_gemini_settings_path();
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
if let Some(parent) = settings_path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取现有的 settings.json(如果存在)
|
||||||
|
let mut settings_content = if settings_path.exists() {
|
||||||
|
let content =
|
||||||
|
fs::read_to_string(&settings_path).map_err(|e| AppError::io(&settings_path, e))?;
|
||||||
|
serde_json::from_str::<Value>(&content).unwrap_or_else(|_| serde_json::json!({}))
|
||||||
|
} else {
|
||||||
|
serde_json::json!({})
|
||||||
|
};
|
||||||
|
|
||||||
|
// 只更新 security.auth.selectedType 字段
|
||||||
|
if let Some(obj) = settings_content.as_object_mut() {
|
||||||
|
let security = obj
|
||||||
|
.entry("security")
|
||||||
|
.or_insert_with(|| serde_json::json!({}));
|
||||||
|
|
||||||
|
if let Some(security_obj) = security.as_object_mut() {
|
||||||
|
let auth = security_obj
|
||||||
|
.entry("auth")
|
||||||
|
.or_insert_with(|| serde_json::json!({}));
|
||||||
|
|
||||||
|
if let Some(auth_obj) = auth.as_object_mut() {
|
||||||
|
auth_obj.insert(
|
||||||
|
"selectedType".to_string(),
|
||||||
|
Value::String(selected_type.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入文件
|
||||||
|
crate::config::write_json_file(&settings_path, &settings_content)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 为 Packycode Gemini 供应商写入 settings.json
|
||||||
|
///
|
||||||
|
/// 设置 `~/.gemini/settings.json` 中的:
|
||||||
|
/// ```json
|
||||||
|
/// {
|
||||||
|
/// "security": {
|
||||||
|
/// "auth": {
|
||||||
|
/// "selectedType": "gemini-api-key"
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// 保留文件中的其他所有字段。
|
||||||
|
pub fn write_packycode_settings() -> Result<(), AppError> {
|
||||||
|
update_selected_type("gemini-api-key")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 为 Google 官方 Gemini 供应商写入 settings.json(OAuth 模式)
|
||||||
|
///
|
||||||
|
/// 设置 `~/.gemini/settings.json` 中的:
|
||||||
|
/// ```json
|
||||||
|
/// {
|
||||||
|
/// "security": {
|
||||||
|
/// "auth": {
|
||||||
|
/// "selectedType": "oauth-personal"
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// 保留文件中的其他所有字段。
|
||||||
|
pub fn write_google_oauth_settings() -> Result<(), AppError> {
|
||||||
|
update_selected_type("oauth-personal")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_env_file() {
|
||||||
|
let content = r#"
|
||||||
|
# Comment line
|
||||||
|
GOOGLE_GEMINI_BASE_URL=https://example.com
|
||||||
|
GEMINI_API_KEY=sk-test123
|
||||||
|
GEMINI_MODEL=gemini-3-pro-preview
|
||||||
|
|
||||||
|
# Another comment
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let map = parse_env_file(content);
|
||||||
|
|
||||||
|
assert_eq!(map.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
map.get("GOOGLE_GEMINI_BASE_URL"),
|
||||||
|
Some(&"https://example.com".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(map.get("GEMINI_API_KEY"), Some(&"sk-test123".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
map.get("GEMINI_MODEL"),
|
||||||
|
Some(&"gemini-3-pro-preview".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialize_env_file() {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
map.insert("GEMINI_API_KEY".to_string(), "sk-test".to_string());
|
||||||
|
map.insert(
|
||||||
|
"GEMINI_MODEL".to_string(),
|
||||||
|
"gemini-3-pro-preview".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let content = serialize_env_file(&map);
|
||||||
|
|
||||||
|
assert!(content.contains("GEMINI_API_KEY=sk-test"));
|
||||||
|
assert!(content.contains("GEMINI_MODEL=gemini-3-pro-preview"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_env_json_conversion() {
|
||||||
|
let mut env_map = HashMap::new();
|
||||||
|
env_map.insert("GEMINI_API_KEY".to_string(), "test-key".to_string());
|
||||||
|
|
||||||
|
let json = env_to_json(&env_map);
|
||||||
|
let converted = json_to_env(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
converted.get("GEMINI_API_KEY"),
|
||||||
|
Some(&"test-key".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_env_file_strict_success() {
|
||||||
|
// 测试严格模式下正常解析
|
||||||
|
let content = r#"
|
||||||
|
# Comment line
|
||||||
|
GOOGLE_GEMINI_BASE_URL=https://example.com
|
||||||
|
GEMINI_API_KEY=sk-test123
|
||||||
|
GEMINI_MODEL=gemini-3-pro-preview
|
||||||
|
|
||||||
|
# Another comment
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let result = parse_env_file_strict(content);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let map = result.unwrap();
|
||||||
|
assert_eq!(map.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
map.get("GOOGLE_GEMINI_BASE_URL"),
|
||||||
|
Some(&"https://example.com".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(map.get("GEMINI_API_KEY"), Some(&"sk-test123".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
map.get("GEMINI_MODEL"),
|
||||||
|
Some(&"gemini-3-pro-preview".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_env_file_strict_missing_equals() {
|
||||||
|
// 测试严格模式下检测缺少 = 的行
|
||||||
|
let content = "GOOGLE_GEMINI_BASE_URL=https://example.com
|
||||||
|
INVALID_LINE_WITHOUT_EQUALS
|
||||||
|
GEMINI_API_KEY=sk-test123";
|
||||||
|
|
||||||
|
let result = parse_env_file_strict(content);
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
let err_msg = format!("{err:?}");
|
||||||
|
assert!(err_msg.contains("第 2 行") || err_msg.contains("line 2"));
|
||||||
|
assert!(err_msg.contains("INVALID_LINE_WITHOUT_EQUALS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_env_file_strict_empty_key() {
|
||||||
|
// 测试严格模式下检测空 key
|
||||||
|
let content = "GOOGLE_GEMINI_BASE_URL=https://example.com
|
||||||
|
=value_without_key
|
||||||
|
GEMINI_API_KEY=sk-test123";
|
||||||
|
|
||||||
|
let result = parse_env_file_strict(content);
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
let err_msg = format!("{err:?}");
|
||||||
|
assert!(err_msg.contains("第 2 行") || err_msg.contains("line 2"));
|
||||||
|
assert!(err_msg.contains("empty") || err_msg.contains("空"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_env_file_strict_invalid_key_characters() {
|
||||||
|
// 测试严格模式下检测无效字符(如空格、特殊符号)
|
||||||
|
let content = "GOOGLE_GEMINI_BASE_URL=https://example.com
|
||||||
|
INVALID KEY WITH SPACES=value
|
||||||
|
GEMINI_API_KEY=sk-test123";
|
||||||
|
|
||||||
|
let result = parse_env_file_strict(content);
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
let err_msg = format!("{err:?}");
|
||||||
|
assert!(err_msg.contains("第 2 行") || err_msg.contains("line 2"));
|
||||||
|
assert!(err_msg.contains("INVALID KEY WITH SPACES"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_env_file_lax_vs_strict() {
|
||||||
|
// 测试宽松模式和严格模式的差异
|
||||||
|
let content = "VALID_KEY=value
|
||||||
|
INVALID LINE
|
||||||
|
KEY_WITH-DASH=value";
|
||||||
|
|
||||||
|
// 宽松模式:跳过无效行,继续解析
|
||||||
|
let lax_result = parse_env_file(content);
|
||||||
|
assert_eq!(lax_result.len(), 1); // 只有 VALID_KEY
|
||||||
|
assert_eq!(lax_result.get("VALID_KEY"), Some(&"value".to_string()));
|
||||||
|
|
||||||
|
// 严格模式:遇到无效行立即返回错误
|
||||||
|
let strict_result = parse_env_file_strict(content);
|
||||||
|
assert!(strict_result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_packycode_settings_structure() {
|
||||||
|
// 验证 Packycode settings.json 的结构正确
|
||||||
|
let settings_content = serde_json::json!({
|
||||||
|
"security": {
|
||||||
|
"auth": {
|
||||||
|
"selectedType": "gemini-api-key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
settings_content["security"]["auth"]["selectedType"],
|
||||||
|
"gemini-api-key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_packycode_settings_merge() {
|
||||||
|
// 测试合并逻辑:应该保留其他字段
|
||||||
|
let mut existing_settings = serde_json::json!({
|
||||||
|
"otherField": "should-be-kept",
|
||||||
|
"security": {
|
||||||
|
"otherSetting": "also-kept",
|
||||||
|
"auth": {
|
||||||
|
"otherAuth": "preserved"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 模拟更新 selectedType
|
||||||
|
if let Some(obj) = existing_settings.as_object_mut() {
|
||||||
|
let security = obj
|
||||||
|
.entry("security")
|
||||||
|
.or_insert_with(|| serde_json::json!({}));
|
||||||
|
|
||||||
|
if let Some(security_obj) = security.as_object_mut() {
|
||||||
|
let auth = security_obj
|
||||||
|
.entry("auth")
|
||||||
|
.or_insert_with(|| serde_json::json!({}));
|
||||||
|
|
||||||
|
if let Some(auth_obj) = auth.as_object_mut() {
|
||||||
|
auth_obj.insert(
|
||||||
|
"selectedType".to_string(),
|
||||||
|
Value::String("gemini-api-key".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证所有字段都被保留
|
||||||
|
assert_eq!(existing_settings["otherField"], "should-be-kept");
|
||||||
|
assert_eq!(existing_settings["security"]["otherSetting"], "also-kept");
|
||||||
|
assert_eq!(
|
||||||
|
existing_settings["security"]["auth"]["otherAuth"],
|
||||||
|
"preserved"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
existing_settings["security"]["auth"]["selectedType"],
|
||||||
|
"gemini-api-key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_google_oauth_settings_structure() {
|
||||||
|
// 验证 Google OAuth settings.json 的结构正确
|
||||||
|
let settings_content = serde_json::json!({
|
||||||
|
"security": {
|
||||||
|
"auth": {
|
||||||
|
"selectedType": "oauth-personal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
settings_content["security"]["auth"]["selectedType"],
|
||||||
|
"oauth-personal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_empty_env_for_oauth() {
|
||||||
|
// 测试空 env(Google 官方 OAuth)可以通过基本验证
|
||||||
|
let settings = serde_json::json!({
|
||||||
|
"env": {}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(validate_gemini_settings(&settings).is_ok());
|
||||||
|
// 严格验证也应该通过(空 env 表示 OAuth)
|
||||||
|
assert!(validate_gemini_settings_strict(&settings).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_env_with_api_key() {
|
||||||
|
// 测试有 API Key 的配置可以通过验证
|
||||||
|
let settings = serde_json::json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_API_KEY": "sk-test123",
|
||||||
|
"GEMINI_MODEL": "gemini-3-pro-preview"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(validate_gemini_settings(&settings).is_ok());
|
||||||
|
assert!(validate_gemini_settings_strict(&settings).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_env_without_api_key_relaxed() {
|
||||||
|
// 测试缺少 API Key 的非空配置在基本验证中可以通过(用户稍后填写)
|
||||||
|
let settings = serde_json::json!({
|
||||||
|
"env": {
|
||||||
|
"GEMINI_MODEL": "gemini-3-pro-preview"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 基本验证应该通过(允许稍后填写 API Key)
|
||||||
|
assert!(validate_gemini_settings(&settings).is_ok());
|
||||||
|
// 严格验证应该失败(切换时要求完整配置)
|
||||||
|
assert!(validate_gemini_settings_strict(&settings).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_invalid_env_type() {
|
||||||
|
// 测试 env 不是对象时会失败
|
||||||
|
let settings = serde_json::json!({
|
||||||
|
"env": "invalid_string"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(validate_gemini_settings(&settings).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::config::atomic_write;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::gemini_config::get_gemini_settings_path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct McpStatus {
|
||||||
|
pub user_config_path: String,
|
||||||
|
pub user_config_exists: bool,
|
||||||
|
pub server_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Gemini MCP 配置文件路径(~/.gemini/settings.json)
|
||||||
|
fn user_config_path() -> PathBuf {
|
||||||
|
get_gemini_settings_path()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_json_value(path: &Path) -> Result<Value, AppError> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(serde_json::json!({}));
|
||||||
|
}
|
||||||
|
let content = fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
|
||||||
|
let value: Value = serde_json::from_str(&content).map_err(|e| AppError::json(path, e))?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
|
}
|
||||||
|
let json =
|
||||||
|
serde_json::to_string_pretty(value).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||||
|
atomic_write(path, json.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取 Gemini settings.json 中的 mcpServers 映射
|
||||||
|
///
|
||||||
|
/// 执行反向格式转换以保持与统一 MCP 结构的兼容性:
|
||||||
|
/// - httpUrl → url + type: "http"
|
||||||
|
/// - 仅有 url 字段 → 补齐 type: "sse"(Gemini 以字段名推断传输类型)
|
||||||
|
/// - 仅有 command 字段 → 补齐 type: "stdio"
|
||||||
|
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
|
||||||
|
let path = user_config_path();
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(std::collections::HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = read_json_value(&path)?;
|
||||||
|
let mut servers: std::collections::HashMap<String, Value> = root
|
||||||
|
.get("mcpServers")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// 反向格式转换:Gemini 特有格式 → 统一 MCP 格式
|
||||||
|
for (_, spec) in servers.iter_mut() {
|
||||||
|
if let Some(obj) = spec.as_object_mut() {
|
||||||
|
// httpUrl → url + type: "http"
|
||||||
|
if let Some(http_url) = obj.remove("httpUrl") {
|
||||||
|
obj.insert("url".to_string(), http_url);
|
||||||
|
obj.insert("type".to_string(), Value::String("http".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini CLI 不使用 type 字段:这里补齐成统一结构,便于校验与导入
|
||||||
|
if obj.get("type").is_none() {
|
||||||
|
if obj.contains_key("command") {
|
||||||
|
obj.insert("type".to_string(), Value::String("stdio".to_string()));
|
||||||
|
} else if obj.contains_key("url") {
|
||||||
|
obj.insert("type".to_string(), Value::String("sse".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将给定的启用 MCP 服务器映射写入到 Gemini settings.json 的 mcpServers 字段
|
||||||
|
/// 仅覆盖 mcpServers,其他字段保持不变
|
||||||
|
pub fn set_mcp_servers_map(
|
||||||
|
servers: &std::collections::HashMap<String, Value>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let path = user_config_path();
|
||||||
|
let mut root = if path.exists() {
|
||||||
|
read_json_value(&path)?
|
||||||
|
} else {
|
||||||
|
serde_json::json!({})
|
||||||
|
};
|
||||||
|
|
||||||
|
// 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范
|
||||||
|
let mut out: Map<String, Value> = Map::new();
|
||||||
|
for (id, spec) in servers.iter() {
|
||||||
|
let mut obj = if let Some(map) = spec.as_object() {
|
||||||
|
map.clone()
|
||||||
|
} else {
|
||||||
|
return Err(AppError::McpValidation(format!(
|
||||||
|
"MCP 服务器 '{id}' 不是对象"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提取 server 字段(如果存在)
|
||||||
|
if let Some(server_val) = obj.remove("server") {
|
||||||
|
let server_obj = server_val.as_object().cloned().ok_or_else(|| {
|
||||||
|
AppError::McpValidation(format!("MCP 服务器 '{id}' server 字段不是对象"))
|
||||||
|
})?;
|
||||||
|
obj = server_obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini CLI 格式转换:
|
||||||
|
// - Gemini 不使用 "type" 字段(从字段名推断传输类型)
|
||||||
|
// - HTTP 使用 "httpUrl" 字段,SSE 使用 "url" 字段
|
||||||
|
let transport_type = obj.get("type").and_then(|v| v.as_str());
|
||||||
|
if transport_type == Some("http") {
|
||||||
|
// HTTP streaming: 将 "url" 重命名为 "httpUrl"
|
||||||
|
if let Some(url_value) = obj.remove("url") {
|
||||||
|
obj.insert("httpUrl".to_string(), url_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// SSE 保持 "url" 字段不变
|
||||||
|
|
||||||
|
// 移除 UI 辅助字段和 type 字段(Gemini 不需要)
|
||||||
|
obj.remove("type");
|
||||||
|
obj.remove("enabled");
|
||||||
|
obj.remove("source");
|
||||||
|
obj.remove("id");
|
||||||
|
obj.remove("name");
|
||||||
|
obj.remove("description");
|
||||||
|
obj.remove("tags");
|
||||||
|
obj.remove("homepage");
|
||||||
|
obj.remove("docs");
|
||||||
|
|
||||||
|
out.insert(id.clone(), Value::Object(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let obj = root
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or_else(|| AppError::Config("~/.gemini/settings.json 根必须是对象".into()))?;
|
||||||
|
obj.insert("mcpServers".into(), Value::Object(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
write_json_value(&path, &root)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -13,7 +13,9 @@ fn cell() -> &'static RwLock<Option<InitErrorPayload>> {
|
|||||||
INIT_ERROR.get_or_init(|| RwLock::new(None))
|
INIT_ERROR.get_or_init(|| RwLock::new(None))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn set_init_error(payload: InitErrorPayload) {
|
pub fn set_init_error(payload: InitErrorPayload) {
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
if let Ok(mut guard) = cell().write() {
|
if let Ok(mut guard) = cell().write() {
|
||||||
*guard = Some(payload);
|
*guard = Some(payload);
|
||||||
}
|
}
|
||||||
@@ -23,6 +25,74 @@ pub fn get_init_error() -> Option<InitErrorPayload> {
|
|||||||
cell().read().ok()?.clone()
|
cell().read().ok()?.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 迁移结果状态
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
static MIGRATION_SUCCESS: OnceLock<RwLock<bool>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn migration_cell() -> &'static RwLock<bool> {
|
||||||
|
MIGRATION_SUCCESS.get_or_init(|| RwLock::new(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_migration_success() {
|
||||||
|
if let Ok(mut guard) = migration_cell().write() {
|
||||||
|
*guard = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取并消费迁移成功状态(只返回一次 true,之后返回 false)
|
||||||
|
pub fn take_migration_success() -> bool {
|
||||||
|
if let Ok(mut guard) = migration_cell().write() {
|
||||||
|
let val = *guard;
|
||||||
|
*guard = false;
|
||||||
|
val
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Skills SSOT 迁移结果状态
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct SkillsMigrationPayload {
|
||||||
|
pub count: usize,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static SKILLS_MIGRATION_RESULT: OnceLock<RwLock<Option<SkillsMigrationPayload>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn skills_migration_cell() -> &'static RwLock<Option<SkillsMigrationPayload>> {
|
||||||
|
SKILLS_MIGRATION_RESULT.get_or_init(|| RwLock::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_skills_migration_result(count: usize) {
|
||||||
|
if let Ok(mut guard) = skills_migration_cell().write() {
|
||||||
|
*guard = Some(SkillsMigrationPayload { count, error: None });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_skills_migration_error(error: String) {
|
||||||
|
if let Ok(mut guard) = skills_migration_cell().write() {
|
||||||
|
*guard = Some(SkillsMigrationPayload {
|
||||||
|
count: 0,
|
||||||
|
error: Some(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取并消费 Skills 迁移结果(只返回一次 Some,之后返回 None)
|
||||||
|
pub fn take_skills_migration_result() -> Option<SkillsMigrationPayload> {
|
||||||
|
if let Ok(mut guard) = skills_migration_cell().write() {
|
||||||
|
guard.take()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -2,5 +2,15 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
// 在 Linux 上设置 WebKit 环境变量以解决 DMA-BUF 渲染问题
|
||||||
|
// 某些 Linux 系统(如 Debian 13.2、Nvidia GPU)上 WebKitGTK 的 DMA-BUF 渲染器可能导致白屏/黑屏
|
||||||
|
// 参考: https://github.com/tauri-apps/tauri/issues/9394
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() {
|
||||||
|
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cc_switch_lib::run();
|
cc_switch_lib::run();
|
||||||
}
|
}
|
||||||
|
|||||||