Compare commits

..

9 Commits

Author SHA1 Message Date
YoVinchen a87daebe14 feat: add model name configuration support for Codex and fix Gemini model handling
- Add visual model name input field for Codex providers
  - Add model name extraction and update utilities in providerConfigUtils
  - Implement model name state management in useCodexConfigState hook
  - Add conditional model field rendering in CodexFormFields (non-official only)
  - Integrate model name sync with TOML config in ProviderForm

- Fix Gemini deeplink model injection bug
  - Correct environment variable name from GOOGLE_GEMINI_MODEL to GEMINI_MODEL
  - Add test cases for Gemini model injection (with/without model)
  - All tests passing (9/9)

- Fix Gemini model field binding in edit mode
  - Add geminiModel state to useGeminiConfigState hook
  - Extract model value during initialization and reset
  - Sync model field with geminiEnv state to prevent data loss on submit
  - Fix missing model value display when editing Gemini providers

Changes:
  - 6 files changed, 245 insertions(+), 13 deletions(-)
2025-11-19 02:06:31 +08:00
YoVinchen ec04132303 style: apply code formatting and cleanup
- Format TypeScript files with Prettier (App.tsx, EnvWarningBanner.tsx, formatters.ts)
- Organize Rust imports and module order alphabetically
- Add newline at end of JSON files (en.json, zh.json)
- Update Cargo.lock for dependency changes
2025-11-19 01:16:19 +08:00
YoVinchen bbb356a948 style: fix clippy uninlined_format_args warnings
Apply clippy --fix to use inline format arguments in:
- src/mcp.rs (8 fixes)
- src/services/env_manager.rs (10 fixes)
2025-11-19 01:13:22 +08:00
YoVinchen f582bd58b1 Merge branch 'feat/add-provider-notes' into main
- Add provider notes field support
- Add deeplink import functionality
- Merge with environment variable conflict detection
- Merge with Skills management feature
- Combine tauri features: tray-icon and protocol-asset
- Keep both env and deeplink modules in commands
- Merge i18n translations for all new features
2025-11-19 01:10:59 +08:00
YoVinchen a7f1461a33 refactor(deeplink): enhance Codex provider template generation
Align deep link import with UI preset generation logic by:
- Adding complete config.toml template matching frontend defaults
- Generating safe provider name from sanitized input
- Including model_provider, reasoning_effort, and wire_api settings
- Removing minimal template that only contained base_url
- Cleaning up deprecated test file deeplink-test.html
2025-11-19 00:33:31 +08:00
YoVinchen 956e723781 chore(deeplink): integrate deep link handling into app lifecycle
Wire up deep link infrastructure with app initialization and event handling.

Backend Integration:
- Register deep link module and commands in mod.rs
- Add URL handling in app setup (src-tauri/src/lib.rs:handle_deeplink_url)
- Handle deep links from single instance callback (Windows/Linux CLI)
- Handle deep links from macOS system events
- Add tauri-plugin-deep-link dependency (Cargo.toml)

Frontend Integration:
- Listen for deeplink-import/deeplink-error events in App.tsx
- Update DeepLinkImportDialog component imports

Configuration:
- Enable deep link plugin in tauri.conf.json
- Update Cargo.lock for new dependencies

Localization:
- Add Chinese translations for deep link UI (zh.json)
- Add English translations for deep link UI (en.json)

Files: 9 changed, 359 insertions(+), 18 deletions(-)
2025-11-18 02:06:10 +08:00
YoVinchen 461ba6f418 feat(deeplink): implement ccswitch:// protocol for provider import
Add deep link support to enable one-click provider configuration import via ccswitch:// URLs.

Backend:
- Implement URL parsing and validation (src-tauri/src/deeplink.rs)
- Add Tauri commands for parse and import (src-tauri/src/commands/deeplink.rs)
- Register ccswitch:// protocol in macOS Info.plist
- Add comprehensive unit tests (src-tauri/tests/deeplink_import.rs)

Frontend:
- Create confirmation dialog with security review UI (src/components/DeepLinkImportDialog.tsx)
- Add API wrapper (src/lib/api/deeplink.ts)
- Integrate event listeners in App.tsx

Configuration:
- Update Tauri config for deep link handling
- Add i18n support for Chinese and English
- Include test page for deep link validation (deeplink-test.html)

Files: 15 changed, 1312 insertions(+)
2025-11-18 02:03:12 +08:00
YoVinchen 6b5752db24 chore: format code and clean up unused props
- Run cargo fmt on Rust backend code
- Format TypeScript imports and code style
- Remove unused appId prop from ProviderPresetSelector
- Clean up unused variables in tests
- Integrate notes field handling in provider dialogs
2025-11-17 16:45:57 +08:00
YoVinchen ec1ae7073f feat(providers): add notes field for provider management
- Add notes field to Provider model (backend and frontend)
- Display notes with higher priority than URL in provider card
- Style notes as non-clickable text to differentiate from URLs
- Add notes input field in provider form
- Add i18n support (zh/en) for notes field
2025-11-17 16:40:28 +08:00
304 changed files with 8072 additions and 36642 deletions
-1
View File
@@ -17,4 +17,3 @@ GEMINI.md
/.idea
/.vscode
vitest-report.json
nul
-287
View File
@@ -5,287 +5,6 @@ All notable changes to CC Switch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [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
### ✨ New Features
@@ -354,7 +73,6 @@ v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI
### 🏗️ Technical Improvements (For Developers)
**Backend Refactoring (Rust)** - Completed 5-phase refactoring:
- **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 3**: Integration tests and transaction mechanism (config snapshot + failure rollback)
@@ -362,20 +80,17 @@ v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI
- **Phase 5**: Concurrency optimization (`RwLock` instead of `Mutex`, scoped guard to avoid deadlock)
**Frontend Refactoring (React + TypeScript)** - Completed 4-stage refactoring:
- **Stage 1**: Test infrastructure (vitest + MSW + @testing-library/react)
- **Stage 2**: Extracted custom hooks (`useProviderActions`, `useMcpActions`, `useSettings`, `useImportExport`, etc.)
- **Stage 3**: Component splitting and business logic extraction
- **Stage 4**: Code cleanup and formatting unification
**Testing System**:
- Hooks unit tests 100% coverage
- Integration tests covering key processes (App, SettingsDialog, MCP Panel)
- MSW mocking backend API to ensure test independence
**Code Quality**:
- Unified parameter format: All Tauri commands migrated to camelCase (Tauri 2 specification)
- `AppType` renamed to `AppId`: Semantically clearer
- Unified parsing with `FromStr` trait: Centralized `app` parameter parsing
@@ -383,7 +98,6 @@ v3.7.0 represents a major evolution from "Provider Switcher" to **"All-in-One AI
- Remove unused code: `missing_param` helper function, deprecated `tauri-api.ts`, redundant `KimiModelSelector` component
**Internal Optimizations**:
- **Removed Legacy Migration Logic**: v3.6 removed v1 config auto-migration and copy file scanning logic
-**Impact**: Improved startup performance, cleaner code
-**Compatibility**: v2 format configs fully compatible, no action required
@@ -647,7 +361,6 @@ For users upgrading from v2.x (Electron version):
- Basic provider management
- Claude Code integration
- Configuration file handling
## [Unreleased]
### ⚠️ Breaking Changes
+18 -133
View File
@@ -1,8 +1,8 @@
<div align="center">
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
# Claude Code & Codex Provider Switcher
[![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.6.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
@@ -10,11 +10,9 @@
<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_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANGELOG.md)
English | [中文](README_ZH.md) | [Changelog](CHANGELOG.md)
**From Provider Switcher to All-in-One AI CLI Management Platform**
Unified management for Claude Code, Codex & Gemini CLI provider configurations, MCP servers, Skills extensions, and system prompts.
A desktop application for managing and switching between different provider configurations & MCP for Claude Code and Codex.
</div>
@@ -35,12 +33,6 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></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>
<td width="180"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
<td>Thanks to ShanDianShuo for sponsoring this project! ShanDianShuo is a local-first AI voice input: Millisecond latency, data stays on device, 4x faster than typing, AI-powered correction, Privacy-first, completely free. Doubles your coding efficiency with Claude Code! <a href="https://www.shandianshuo.cn">Free download</a> for Mac/Win</td>
</tr>
</table>
## Screenshots
@@ -51,82 +43,12 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
## Features
### Current Version: v3.8.2 | [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
### Current Version: v3.6.2 | [Full Changelog](CHANGELOG.md)
**Core Capabilities**
- **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
- **Import/Export**: Backup and restore configs with auto-rotation (keep 10 most recent)
- **i18n Support**: Complete Chinese/English localization (UI, errors, tray)
@@ -139,6 +61,7 @@ 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)
- WSL environment support with auto-sync on directory change
- 100% hooks test coverage & complete architecture refactoring
- New presets: DMXAPI, Azure Codex, AnyRouter, AiHubMix, MiniMax
**System Features**
@@ -180,14 +103,6 @@ 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.
### ArchLinux 用户
**Install via paru (Recommended)**
```bash
paru -S cc-switch-bin
```
### Linux Users
Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{version}-Linux.AppImage` from the [Releases](../../releases) page.
@@ -206,36 +121,9 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
### MCP Management
- **Location**: Click "MCP" button in top-right corner
- **Add Server**:
- Use built-in templates (mcp-fetch, mcp-filesystem, etc.)
- Support stdio / http / sse transport types
- Configure independent MCP servers for different apps
- **Add Server**: Use built-in templates (mcp-fetch, mcp-filesystem) or custom config
- **Enable/Disable**: Toggle switches to control which servers sync to live config
- **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
- **Sync**: Enabled servers auto-sync to `~/.claude.json` (Claude) or `~/.codex/config.toml` (Codex)
### Configuration Files
@@ -253,16 +141,14 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
**Gemini**
- Live config: `~/.gemini/.env` (API key) + `~/.gemini/settings.json` (auth mode)
- 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
- Live config: `~/.gemini/.env` (API key) + `~/.gemini/settings.json` (auth type for quick switching)
- API key field: `GEMINI_API_KEY` inside `.env`
- Tray quick switch: each provider switch rewrites `~/.gemini/.env` so the Gemini CLI picks up the new credentials immediately
**CC Switch Storage (v3.8.0 New Architecture)**
**CC Switch Storage**
- Database (SSOT): `~/.cc-switch/cc-switch.db` (SQLite, stores providers, MCP, Prompts, Skills)
- Local settings: `~/.cc-switch/settings.json` (device-level settings)
- Main config (SSOT): `~/.cc-switch/config.json`
- Settings: `~/.cc-switch/settings.json`
- Backups: `~/.cc-switch/backups/` (auto-rotate, keep 10)
### Cloud Sync Setup
@@ -298,12 +184,11 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
**Core Design Patterns**
- **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
- **SSOT** (Single Source of Truth): All provider configs stored in `~/.cc-switch/config.json`
- **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
- **Concurrency Safe**: Mutex-protected database connection avoids race conditions
- **Layered Architecture**: Clear separation (Commands → Services → DAO → Database)
- **Concurrency Safe**: RwLock with scoped guards avoids deadlocks
- **Layered Architecture**: Clear separation (Commands → Services → Models)
**Key Components**
-476
View File
@@ -1,476 +0,0 @@
<div align="center">
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
[![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](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)
**プロバイダスイッチャーから AI CLI 一体型管理プラットフォームへ**
Claude Code・Codex・Gemini CLI のプロバイダ設定、MCP サーバー、Skills 拡張、システムプロンプトを統合管理。
</div>
## ❤️スポンサー
![Zhipu GLM](assets/partners/banners/glm-en.jpg)
本プロジェクトは 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"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></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"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
<td>ShanDianShuo のご支援に感謝します!ShanDianShuo はローカルファーストの音声入力ツールで、ミリ秒遅延・データは端末から外に出ず・キーボード入力の 4 倍の速度・AI 自動補正・プライバシー優先で完全無料。Claude Code と組み合わせればコーディング効率が倍増します。<a href="https://www.shandianshuo.cn">Mac/Win 版を無料ダウンロード</a></td>
</tr>
</table>
## スクリーンショット
| メイン画面 | プロバイダ追加 |
| :-------------------------------------------: | :----------------------------------------------: |
| ![メイン画面](assets/screenshots/main-ja.png) | ![プロバイダ追加](assets/screenshots/add-ja.png) |
## 特長
### 現在のバージョン:v3.8.2 | [完全な更新履歴](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
- プラットフォームネイティブ APIRegistry/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 を管理
- 新たに SSEServer-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) から最新版の `CC-Switch-v{version}-Linux.deb` または `CC-Switch-v{version}-Linux.AppImage` をダウンロード。
## クイックスタート
### 基本的な使い方
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
[![Star History Chart](https://api.star-history.com/svg?repos=farion1231/cc-switch&type=Date)](https://www.star-history.com/#farion1231/cc-switch&Date)
## ライセンス
MIT © Jason Young
+18 -133
View File
@@ -1,8 +1,8 @@
<div align="center">
# Claude Code / Codex / Gemini CLI 全方位辅助工具
# Claude Code & Codex 供应商管理器
[![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.6.1-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
@@ -10,11 +10,9 @@
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[English](README.md) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md) | [v3.8.0 发布说明](docs/release-note-v3.8.0-zh.md)
[English](README.md) | 中文 | [更新日志](CHANGELOG.md)
**从供应商切换器到 AI CLI 一体化管理平台**
统一管理 Claude Code、Codex 与 Gemini CLI 的供应商配置、MCP 服务器、Skills 扩展和系统提示词。
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置、MCP的桌面应用。
</div>
@@ -35,12 +33,6 @@ CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></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"><img src="assets/partners/logos/sds-zh.png" alt="ShanDianShuo" width="150"></td>
<td>感谢闪电说赞助了本项目!闪电说是本地优先的 AI 语音输入法:毫秒级响应,数据不离设备;打字速度提升 4 倍,AI 智能纠错;绝对隐私安全,完全免费,配合 Claude Code 写代码效率翻倍!支持 Mac/Win 双平台,<a href="https://www.shandianshuo.cn">免费下载</a></td>
</tr>
</table>
## 界面预览
@@ -51,82 +43,12 @@ CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编
## 功能特性
### 当前版本:v3.8.2 | [完整更新日志](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 CLIClaude 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
- 可视化冲突指示器 + 解决建议
- 覆盖警告 + 更改前备份
### 当前版本:v3.6.2 | [完整更新日志](CHANGELOG.md)
**核心功能**
- **供应商管理**:一键切换 Claude Code、Codex 与 Gemini 的 API 配置
- **MCP 集成**:集中管理 MCP 服务器,支持 stdio/http 类型和实时同步
- **速度测试**:测量 API 端点延迟,可视化连接质量指示器
- **导入导出**:备份和恢复配置,自动轮换(保留最近 10 个)
- **国际化支持**:完整的中英文本地化(UI、错误、托盘)
@@ -139,6 +61,7 @@ CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编
- 细粒度模型配置(四层:Haiku/Sonnet/Opus/自定义)
- WSL 环境支持,配置目录切换自动同步
- 100% hooks 测试覆盖 & 完整架构重构
- 新增预设:DMXAPI、Azure Codex、AnyRouter、AiHubMix、MiniMax
**系统功能**
@@ -180,14 +103,6 @@ brew upgrade --cask cc-switch
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
### ArchLinux 用户
**通过 paru 安装(推荐)**
```bash
paru -S cc-switch-bin
```
### Linux 用户
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Linux.deb` 包或者 `CC-Switch-v{版本号}-Linux.AppImage` 安装包。
@@ -206,36 +121,9 @@ paru -S cc-switch-bin
### MCP 管理
- **位置**:点击右上角"MCP"按钮
- **添加服务器**
- 使用内置模板(mcp-fetch、mcp-filesystem 等)
- 支持 stdio / http / sse 三种传输类型
- 为不同应用配置独立的 MCP 服务器
- **添加服务器**使用内置模板(mcp-fetch、mcp-filesystem)或自定义配置
- **启用/禁用**:切换开关以控制哪些服务器同步到 live 配置
- **同步**:启用的服务器自动同步到各应用的 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`
- **保护机制**:切换前自动保存当前提示词内容,保留手动修改
- **同步**:启用的服务器自动同步到 `~/.claude.json`Claude)或 `~/.codex/config.toml`Codex
### 配置文件
@@ -253,16 +141,14 @@ paru -S cc-switch-bin
**Gemini**
- Live 配置:`~/.gemini/.env`API Key+ `~/.gemini/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 即可生效
- Live 配置:`~/.gemini/.env`API Key+ `~/.gemini/settings.json`(保存认证模式,支持托盘快速切换
- API key 字段:`.env` 文件中的 `GEMINI_API_KEY`
- 托盘快速切换:每次切换供应商都会重写 `~/.gemini/.env`,Gemini CLI 无需额外操作即可使用新配置
**CC Switch 存储v3.8.0 新架构)**
**CC Switch 存储**
- 数据库SSOT):`~/.cc-switch/cc-switch.db`SQLite,存储供应商、MCP、Prompts、Skills
- 本地设置:`~/.cc-switch/settings.json`(设备级设置)
- 主配置SSOT):`~/.cc-switch/config.json`
- 设置:`~/.cc-switch/settings.json`
- 备份:`~/.cc-switch/backups/`(自动轮换,保留 10 个)
### 云同步设置
@@ -298,12 +184,11 @@ paru -S cc-switch-bin
**核心设计模式**
- **SSOT**(单一事实源):所有数据存储在 `~/.cc-switch/cc-switch.db`SQLite
- **双层存储**SQLite 存储可同步数据,JSON 存储设备级设置
- **SSOT**(单一事实源):所有供应商配置存储在 `~/.cc-switch/config.json`
- **双向同步**:切换时写入 live 文件,编辑当前供应商时从 live 回填
- **原子写入**:临时文件 + 重命名模式防止配置损坏
- **并发安全**Mutex 保护的数据库连接避免竞态条件
- **分层架构**:清晰分离(Commands → Services → DAO → Database
- **并发安全**RwLock 与作用域守卫避免死锁
- **分层架构**:清晰分离(Commands → Services → Models
**核心组件**
+76
View File
@@ -0,0 +1,76 @@
# 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` 配置来更改默认语言
Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 205 KiB

+78 -2242
View File
File diff suppressed because it is too large Load Diff
-439
View File
@@ -1,439 +0,0 @@
# 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!**
-435
View File
@@ -1,435 +0,0 @@
# 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、Tableshadcn/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 行)
- **前端**PromptPanel177)、PromptFormModal160)、MarkdownEditor159
- **Hooks**usePromptActions152 行)
- **国际化**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.52Windows
---
## 技术统计
```
总体变更:
- 提交数: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.15Catalina+
- **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`
### HomebrewmacOS
```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 预览**(暂定):
- 本地代理功能
敬请期待更多更新!
-481
View File
@@ -1,481 +0,0 @@
# 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!**
-481
View File
@@ -1,481 +0,0 @@
# 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、Tableshadcn/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 行)
- **前端**PromptPanel177)、PromptFormModal160)、MarkdownEditor159
- **Hooks**usePromptActions152 行)
- **国际化**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.52Windows
---
## 技术统计
```
总体变更:
- 提交数: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.15Catalina+
- **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`
### HomebrewmacOS
```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!**
-369
View File
@@ -1,369 +0,0 @@
# 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!**
-315
View File
@@ -1,315 +0,0 @@
# 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!**
-369
View File
@@ -1,369 +0,0 @@
# 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 行
提交类型分布:
- fix25 个(Bug 修复)
- refactor11 个(代码重构)
- feat9 个(新功能)
- test1 个(测试)
- 其他: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.15Catalina+
- **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`
### HomebrewmacOS
```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!**
+4 -7
View File
@@ -1,7 +1,7 @@
{
"name": "cc-switch",
"version": "3.8.2",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"version": "3.6.2",
"description": "Claude Code & Codex 供应商切换工具",
"scripts": {
"dev": "pnpm tauri dev",
"build": "pnpm tauri build",
@@ -26,13 +26,10 @@
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.20",
"cross-fetch": "^4.1.0",
"jsdom": "^25.0.0",
"msw": "^2.11.6",
"postcss": "^8.4.49",
"prettier": "^3.6.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vitest": "^2.0.5"
@@ -49,7 +46,6 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@lobehub/icons-static-svg": "^1.73.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -59,6 +55,7 @@
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-visually-hidden": "^1.2.4",
"@tailwindcss/vite": "^4.1.13",
"@tanstack/react-query": "^5.90.3",
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-dialog": "^2.4.0",
@@ -75,10 +72,10 @@
"react-dom": "^18.2.0",
"react-hook-form": "^7.65.0",
"react-i18next": "^16.0.0",
"recharts": "^3.5.1",
"smol-toml": "^1.4.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13",
"zod": "^4.1.12"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
+255 -888
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
-208
View File
@@ -1,208 +0,0 @@
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');
-96
View File
@@ -1,96 +0,0 @@
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');
-114
View File
@@ -1,114 +0,0 @@
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}`);
+12 -464
View File
@@ -39,18 +39,6 @@ dependencies = [
"version_check",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.3"
@@ -257,28 +245,6 @@ dependencies = [
"windows-sys 0.61.1",
]
[[package]]
name = "async-stream"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
dependencies = [
"async-stream-impl",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-stream-impl"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "async-task"
version = "4.7.1"
@@ -325,78 +291,12 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auto-launch"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471"
dependencies = [
"dirs 4.0.0",
"thiserror 1.0.69",
"winreg 0.10.1",
]
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower 0.5.2",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "backtrace"
version = "0.3.76"
@@ -695,28 +595,18 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.8.2"
version = "3.6.2"
dependencies = [
"anyhow",
"async-stream",
"auto-launch",
"axum",
"base64 0.22.1",
"bytes",
"chrono",
"dirs 5.0.1",
"futures",
"hyper",
"indexmap 2.11.4",
"log",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"once_cell",
"regex",
"reqwest",
"rquickjs",
"rusqlite",
"rust_decimal",
"serde",
"serde_json",
"serde_yaml",
@@ -732,14 +622,11 @@ dependencies = [
"tauri-plugin-store",
"tauri-plugin-updater",
"tempfile",
"thiserror 2.0.17",
"thiserror 1.0.69",
"tokio",
"toml 0.8.2",
"toml_edit 0.22.27",
"tower 0.4.13",
"tower-http 0.5.2",
"url",
"uuid",
"winreg 0.52.0",
"zip 2.4.2",
]
@@ -868,16 +755,6 @@ dependencies = [
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -901,9 +778,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.9.4",
"core-foundation 0.10.1",
"core-foundation",
"core-graphics-types",
"foreign-types 0.5.0",
"foreign-types",
"libc",
]
@@ -914,7 +791,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.9.4",
"core-foundation 0.10.1",
"core-foundation",
"libc",
]
@@ -1105,15 +982,6 @@ dependencies = [
"subtle",
]
[[package]]
name = "dirs"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
dependencies = [
"dirs-sys 0.3.7",
]
[[package]]
name = "dirs"
version = "5.0.1"
@@ -1132,17 +1000,6 @@ dependencies = [
"dirs-sys 0.5.0",
]
[[package]]
name = "dirs-sys"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
dependencies = [
"libc",
"redox_users 0.4.6",
"winapi",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
@@ -1299,15 +1156,6 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.0"
@@ -1393,18 +1241,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -1473,15 +1309,6 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -1489,7 +1316,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared 0.3.1",
"foreign-types-shared",
]
[[package]]
@@ -1503,12 +1330,6 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -1952,32 +1773,13 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "h2"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.11.4",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash 0.7.8",
"ahash",
]
[[package]]
@@ -1985,9 +1787,6 @@ name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash 0.8.12",
]
[[package]]
name = "hashbrown"
@@ -1995,15 +1794,6 @@ version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "heck"
version = "0.4.1"
@@ -2095,12 +1885,6 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.7.0"
@@ -2111,11 +1895,9 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
@@ -2141,22 +1923,6 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.17"
@@ -2176,11 +1942,9 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -2600,17 +2364,6 @@ dependencies = [
"redox_syscall",
]
[[package]]
name = "libsqlite3-sys"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
@@ -2705,12 +2458,6 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "memchr"
version = "2.7.6"
@@ -2780,23 +2527,6 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -3214,50 +2944,6 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.9.4",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -4001,21 +3687,16 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -4026,11 +3707,10 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower 0.5.2",
"tower-http 0.6.6",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
@@ -4135,20 +3815,6 @@ dependencies = [
"cc",
]
[[package]]
name = "rusqlite"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
dependencies = [
"bitflags 2.9.4",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
@@ -4274,15 +3940,6 @@ dependencies = [
"sdd",
]
[[package]]
name = "schannel"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
dependencies = [
"windows-sys 0.61.1",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -4358,29 +4015,6 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.9.4",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.24.0"
@@ -4475,17 +4109,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
@@ -4720,7 +4343,7 @@ dependencies = [
"bytemuck",
"cfg_aliases",
"core-graphics",
"foreign-types 0.5.0",
"foreign-types",
"js-sys",
"log",
"objc2 0.5.2",
@@ -4861,27 +4484,6 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "system-configuration"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags 2.9.4",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -4903,7 +4505,7 @@ checksum = "959469667dbcea91e5485fc48ba7dd6023face91bb0f1a14681a70f99847c3f7"
dependencies = [
"bitflags 2.9.4",
"block2 0.6.1",
"core-foundation 0.10.1",
"core-foundation",
"core-graphics",
"crossbeam-channel",
"dispatch",
@@ -5542,16 +5144,6 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -5689,17 +5281,6 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109"
[[package]]
name = "tower"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower"
version = "0.5.2"
@@ -5713,23 +5294,6 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-http"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
dependencies = [
"bitflags 2.9.4",
"bytes",
"http",
"http-body",
"http-body-util",
"pin-project-lite",
"tower-layer",
"tower-service",
]
[[package]]
@@ -5745,7 +5309,7 @@ dependencies = [
"http-body",
"iri-string",
"pin-project-lite",
"tower 0.5.2",
"tower",
"tower-layer",
"tower-service",
]
@@ -5768,7 +5332,6 @@ version = "0.1.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -5970,12 +5533,6 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.0"
@@ -6840,15 +6397,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]]
name = "winreg"
version = "0.52.0"
+5 -18
View File
@@ -1,7 +1,7 @@
[package]
name = "cc-switch"
version = "3.8.2"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
version = "3.6.2"
description = "Claude Code & Codex 供应商配置管理工具"
authors = ["Jason Young"]
license = "MIT"
repository = "https://github.com/farion1231/cc-switch"
@@ -37,30 +37,17 @@ tauri-plugin-deep-link = "2"
dirs = "5.0"
toml = "0.8"
toml_edit = "0.22"
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
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"
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
thiserror = "2.0"
thiserror = "1.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]
tauri-plugin-single-instance = "2"
+1 -2
View File
@@ -10,8 +10,7 @@
"opener:default",
"updater:default",
"core:window:allow-set-skip-taskbar",
"core:window:allow-start-dragging",
"process:allow-restart",
"dialog:default"
]
}
}
+2 -5
View File
@@ -494,11 +494,8 @@ impl MultiAppConfig {
// 创建提示词对象
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
});
.unwrap()
.as_secs() as i64;
let id = format!("auto-imported-{timestamp}");
let prompt = crate::prompt::Prompt {
-49
View File
@@ -1,49 +0,0 @@
use crate::error::AppError;
use auto_launch::{AutoLaunch, AutoLaunchBuilder};
/// 初始化 AutoLaunch 实例
fn get_auto_launch() -> Result<AutoLaunch, AppError> {
let app_name = "CC Switch";
let app_path =
std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?;
// 使用 AutoLaunchBuilder 消除平台差异
// Windows/Linux: new() 接受 3 参数
// macOS: new() 接受 4 参数(含 hidden 参数)
// Builder 模式自动处理这些差异
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}")))
}
-2
View File
@@ -29,7 +29,6 @@ pub fn get_codex_config_path() -> PathBuf {
}
/// 获取 Codex 供应商配置文件路径
#[allow(dead_code)]
pub fn get_codex_provider_paths(
provider_id: &str,
provider_name: Option<&str>,
@@ -45,7 +44,6 @@ pub fn get_codex_provider_paths(
}
/// 删除 Codex 供应商配置文件
#[allow(dead_code)]
pub fn delete_codex_provider_config(
provider_id: &str,
provider_name: &str,
+45 -26
View File
@@ -141,10 +141,11 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
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())
let guard = state
.config
.read()
.map_err(|e| format!("读取配置锁失败: {e}"))?;
Ok(guard.common_config_snippets.claude.clone())
}
/// 设置 Claude 通用配置片段(已废弃,使用 set_common_config_snippet
@@ -153,22 +154,24 @@ pub async fn set_claude_common_config_snippet(
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
let mut guard = state
.config
.write()
.map_err(|e| format!("写入配置锁失败: {e}"))?;
// 验证是否为有效的 JSON(如果不为空)
if !snippet.trim().is_empty() {
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
}
let value = if snippet.trim().is_empty() {
guard.common_config_snippets.claude = if snippet.trim().is_empty() {
None
} else {
Some(snippet)
};
state
.db
.set_config_snippet("claude", value)
.map_err(|e| e.to_string())?;
guard.save().map_err(|e| e.to_string())?;
Ok(())
}
@@ -178,10 +181,17 @@ 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())
use crate::app_config::AppType;
use std::str::FromStr;
let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {e}"))?;
let guard = state
.config
.read()
.map_err(|e| format!("读取配置锁失败: {e}"))?;
Ok(guard.common_config_snippets.get(&app).cloned())
}
/// 设置通用配置片段(统一接口)
@@ -191,31 +201,40 @@ pub async fn set_common_config_snippet(
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
use crate::app_config::AppType;
use std::str::FromStr;
let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {e}"))?;
let mut guard = state
.config
.write()
.map_err(|e| format!("写入配置锁失败: {e}"))?;
// 验证格式(根据应用类型)
if !snippet.trim().is_empty() {
match app_type.as_str() {
"claude" | "gemini" => {
match app {
AppType::Claude | AppType::Gemini => {
// 验证 JSON 格式
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
}
"codex" => {
AppType::Codex => {
// TOML 格式暂不验证(或可使用 toml crate)
// 注意:TOML 验证较为复杂,暂时跳过
}
_ => {}
}
}
let value = if snippet.trim().is_empty() {
None
} else {
Some(snippet)
};
guard.common_config_snippets.set(
&app,
if snippet.trim().is_empty() {
None
} else {
Some(snippet)
},
);
state
.db
.set_config_snippet(&app_type, value)
.map_err(|e| e.to_string())?;
guard.save().map_err(|e| e.to_string())?;
Ok(())
}
+3 -63
View File
@@ -1,7 +1,4 @@
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::deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
use crate::store::AppState;
use tauri::State;
@@ -12,24 +9,14 @@ pub fn parse_deeplink(url: String) -> Result<DeepLinkImportRequest, String> {
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)
/// Import a provider from a deep link request (after user confirmation)
#[tauri::command]
pub fn import_from_deeplink(
state: State<AppState>,
request: DeepLinkImportRequest,
) -> Result<String, String> {
log::info!(
"Importing provider from deep link: {:?} for app {:?}",
"Importing provider from deep link: {} for app {}",
request.name,
request.app
);
@@ -40,50 +27,3 @@ pub fn import_from_deeplink(
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)),
}
}
+38 -43
View File
@@ -6,22 +6,20 @@ use tauri::State;
use tauri_plugin_dialog::DialogExt;
use crate::error::AppError;
use crate::services::provider::ProviderService;
use crate::services::ConfigService;
use crate::store::AppState;
/// 导出数据库为 SQL 备份
/// 导出配置文件
#[tauri::command]
pub async fn export_config_to_file(
#[allow(non_snake_case)] filePath: String,
state: State<'_, AppState>,
) -> Result<Value, String> {
let db = state.db.clone();
tauri::async_runtime::spawn_blocking(move || {
let target_path = PathBuf::from(&filePath);
db.export_sql(&target_path)?;
ConfigService::export_config_to_path(&target_path)?;
Ok::<_, AppError>(json!({
"success": true,
"message": "SQL exported successfully",
"message": "Configuration exported successfully",
"filePath": filePath
}))
})
@@ -30,54 +28,51 @@ pub async fn export_config_to_file(
.map_err(|e: AppError| e.to_string())
}
/// 从 SQL 备份导入数据库
/// 从文件导入配置
#[tauri::command]
pub async fn import_config_from_file(
#[allow(non_snake_case)] filePath: String,
state: State<'_, AppState>,
) -> Result<Value, String> {
let db = state.db.clone();
let db_for_state = db.clone();
tauri::async_runtime::spawn_blocking(move || {
let (new_config, backup_id) = tauri::async_runtime::spawn_blocking(move || {
let path_buf = PathBuf::from(&filePath);
let backup_id = db.import_sql(&path_buf)?;
// 导入后同步当前供应商到各自的 live 配置
let app_state = AppState::new(db_for_state);
if let Err(err) = ProviderService::sync_current_to_live(&app_state) {
log::warn!("导入后同步 live 配置失败: {err}");
}
// 重新加载设置到内存缓存,确保导入的设置生效
if let Err(err) = crate::settings::reload_settings() {
log::warn!("导入后重载设置失败: {err}");
}
Ok::<_, AppError>(json!({
"success": true,
"message": "SQL imported successfully",
"backupId": backup_id
}))
ConfigService::load_config_for_import(&path_buf)
})
.await
.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]
pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<Value, String> {
let db = state.db.clone();
tauri::async_runtime::spawn_blocking(move || {
let app_state = AppState::new(db);
ProviderService::sync_current_to_live(&app_state)?;
Ok::<_, AppError>(json!({
"success": true,
"message": "Live configuration synchronized"
}))
})
.await
.map_err(|e| format!("同步当前供应商失败: {e}"))?
.map_err(|e: AppError| e.to_string())
{
let mut config_state = state
.config
.write()
.map_err(|e| AppError::from(e).to_string())?;
ConfigService::sync_current_providers_to_live(&mut config_state)
.map_err(|e| e.to_string())?;
}
Ok(json!({
"success": true,
"message": "Live configuration synchronized"
}))
}
/// 保存文件对话框
@@ -89,7 +84,7 @@ pub async fn save_file_dialog<R: tauri::Runtime>(
let dialog = app.dialog();
let result = dialog
.file()
.add_filter("SQL", &["sql"])
.add_filter("JSON", &["json"])
.set_file_name(&defaultName)
.blocking_save_file();
@@ -104,7 +99,7 @@ pub async fn open_file_dialog<R: tauri::Runtime>(
let dialog = app.dialog();
let result = dialog
.file()
.add_filter("SQL", &["sql"])
.add_filter("JSON", &["json"])
.blocking_pick_file();
Ok(result.map(|p| p.to_string()))
+7 -4
View File
@@ -1,6 +1,5 @@
#![allow(non_snake_case)]
use indexmap::IndexMap;
use std::collections::HashMap;
use serde::Serialize;
@@ -83,8 +82,12 @@ pub async fn upsert_mcp_server_in_config(
// 读取现有的服务器(如果存在)
let existing_server = {
let servers = state.db.get_all_mcp_servers().map_err(|e| e.to_string())?;
servers.get(&id).cloned()
let cfg = state.config.read().map_err(|e| e.to_string())?;
if let Some(servers) = &cfg.mcp.servers {
servers.get(&id).cloned()
} else {
None
}
};
// 构建新的统一服务器结构
@@ -162,7 +165,7 @@ use crate::app_config::McpServer;
#[tauri::command]
pub async fn get_mcp_servers(
state: State<'_, AppState>,
) -> Result<IndexMap<String, McpServer>, String> {
) -> Result<HashMap<String, McpServer>, String> {
McpService::get_all_servers(&state).map_err(|e| e.to_string())
}
-7
View File
@@ -51,10 +51,3 @@ pub async fn is_portable_mode() -> Result<bool, String> {
pub async fn get_init_error() -> Result<Option<InitErrorPayload>, String> {
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())
}
-6
View File
@@ -6,14 +6,11 @@ mod env;
mod import_export;
mod mcp;
mod misc;
mod model_test;
mod plugin;
mod prompt;
mod provider;
mod proxy;
mod settings;
pub mod skill;
mod usage;
pub use config::*;
pub use deeplink::*;
@@ -21,11 +18,8 @@ pub use env::*;
pub use import_export::*;
pub use mcp::*;
pub use misc::*;
pub use model_test::*;
pub use plugin::*;
pub use prompt::*;
pub use provider::*;
pub use proxy::*;
pub use settings::*;
pub use skill::*;
pub use usage::*;
-128
View File
@@ -1,128 +0,0 @@
//! 模型测试相关命令
use crate::app_config::AppType;
use crate::error::AppError;
use crate::services::model_test::{
ModelTestConfig, ModelTestLog, ModelTestResult, ModelTestService,
};
use crate::store::AppState;
use tauri::State;
/// 测试单个供应商的模型可用性
#[tauri::command]
pub async fn test_provider_model(
state: State<'_, AppState>,
app_type: AppType,
provider_id: String,
) -> Result<ModelTestResult, AppError> {
// 获取测试配置
let config = state.db.get_model_test_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 = ModelTestService::test_provider(&app_type, provider, &config).await?;
// 记录日志
let _ = state.db.save_model_test_log(
&provider_id,
&provider.name,
app_type.as_str(),
&result.model_used,
&config.test_prompt,
&result,
);
Ok(result)
}
/// 批量测试所有供应商
#[tauri::command]
pub async fn test_all_providers_model(
state: State<'_, AppState>,
app_type: AppType,
proxy_targets_only: bool,
) -> Result<Vec<(String, ModelTestResult)>, AppError> {
let config = state.db.get_model_test_config()?;
let providers = state.db.get_all_providers(app_type.as_str())?;
let mut results = Vec::new();
for (id, provider) in providers {
// 如果只测试代理目标,跳过非代理目标
if proxy_targets_only && !provider.is_proxy_target.unwrap_or(false) {
continue;
}
match ModelTestService::test_provider(&app_type, &provider, &config).await {
Ok(result) => {
// 记录日志
let _ = state.db.save_model_test_log(
&id,
&provider.name,
app_type.as_str(),
&result.model_used,
&config.test_prompt,
&result,
);
results.push((id, result));
}
Err(e) => {
let error_result = ModelTestResult {
success: false,
message: e.to_string(),
response_time_ms: None,
http_status: None,
model_used: String::new(),
tested_at: chrono::Utc::now().timestamp(),
};
results.push((id, error_result));
}
}
}
Ok(results)
}
/// 获取模型测试配置
#[tauri::command]
pub fn get_model_test_config(state: State<'_, AppState>) -> Result<ModelTestConfig, AppError> {
state.db.get_model_test_config()
}
/// 保存模型测试配置
#[tauri::command]
pub fn save_model_test_config(
state: State<'_, AppState>,
config: ModelTestConfig,
) -> Result<(), AppError> {
state.db.save_model_test_config(&config)
}
/// 获取模型测试日志
#[tauri::command]
pub fn get_model_test_logs(
state: State<'_, AppState>,
app_type: Option<String>,
provider_id: Option<String>,
limit: Option<u32>,
) -> Result<Vec<ModelTestLog>, AppError> {
state.db.get_model_test_logs(
app_type.as_deref(),
provider_id.as_deref(),
limit.unwrap_or(50),
)
}
/// 清理旧的测试日志
#[tauri::command]
pub fn cleanup_model_test_logs(
state: State<'_, AppState>,
keep_count: Option<u32>,
) -> Result<u64, AppError> {
state.db.cleanup_model_test_logs(keep_count.unwrap_or(100))
}
+2 -2
View File
@@ -1,4 +1,4 @@
use indexmap::IndexMap;
use std::collections::HashMap;
use std::str::FromStr;
use tauri::State;
@@ -12,7 +12,7 @@ use crate::store::AppState;
pub async fn get_prompts(
app: String,
state: State<'_, AppState>,
) -> Result<IndexMap<String, Prompt>, String> {
) -> Result<HashMap<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())
}
+7 -18
View File
@@ -1,4 +1,4 @@
use indexmap::IndexMap;
use std::collections::HashMap;
use tauri::State;
use crate::app_config::AppType;
@@ -13,7 +13,7 @@ use std::str::FromStr;
pub fn get_providers(
state: State<'_, AppState>,
app: String,
) -> Result<IndexMap<String, Provider>, String> {
) -> Result<HashMap<String, Provider>, 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())
}
@@ -86,20 +86,7 @@ pub fn switch_provider(
.map_err(|e| e.to_string())
}
/// 设置代理目标供应商
#[tauri::command]
pub fn set_proxy_target_provider(
state: State<'_, AppState>,
app: String,
id: String,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::set_proxy_target(state.inner(), app_type, &id)
.map(|_| true)
.map_err(|e| e.to_string())
}
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<(), AppError> {
ProviderService::import_default_config(state, app_type)
}
@@ -107,7 +94,7 @@ fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result
pub fn import_default_config_test_hook(
state: &AppState,
app_type: AppType,
) -> Result<bool, AppError> {
) -> Result<(), AppError> {
import_default_config_internal(state, app_type)
}
@@ -115,7 +102,9 @@ pub fn import_default_config_test_hook(
#[tauri::command]
pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
import_default_config_internal(&state, app_type).map_err(Into::into)
import_default_config_internal(&state, app_type)
.map(|_| true)
.map_err(Into::into)
}
/// 查询供应商用量
-47
View File
@@ -1,47 +0,0 @@
//! 代理服务相关的 Tauri 命令
//!
//! 提供前端调用的 API 接口
use crate::proxy::types::*;
use crate::store::AppState;
/// 启动代理服务器
#[tauri::command]
pub async fn start_proxy_server(
state: tauri::State<'_, AppState>,
) -> Result<ProxyServerInfo, String> {
state.proxy_service.start().await
}
/// 停止代理服务器
#[tauri::command]
pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(), String> {
state.proxy_service.stop().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
}
/// 检查代理服务器是否正在运行
#[tauri::command]
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
Ok(state.proxy_service.is_running().await)
}
+1 -23
View File
@@ -18,12 +18,7 @@ pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<boo
/// 重启应用程序(当 app_config_dir 变更后使用)
#[tauri::command]
pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
// 在后台延迟重启,让函数有时间返回响应
tauri::async_runtime::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
app.restart();
});
Ok(true)
app.restart();
}
/// 获取 app_config_dir 覆盖配置 (从 Store)
@@ -42,20 +37,3 @@ pub async fn set_app_config_dir_override(
crate::app_store::set_app_config_dir_to_store(&app, path.as_deref())?;
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}"))
}
+68 -140
View File
@@ -1,5 +1,3 @@
use crate::app_config::AppType;
use crate::error::format_skill_error;
use crate::services::skill::SkillState;
use crate::services::{Skill, SkillRepo, SkillService};
use crate::store::AppState;
@@ -9,73 +7,21 @@ use tauri::State;
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}")),
}
}
/// 根据 app_type 生成带前缀的 skill key
fn get_skill_key(app_type: &AppType, directory: &str) -> String {
let prefix = match app_type {
AppType::Claude => "claude",
AppType::Codex => "codex",
AppType::Gemini => "gemini",
};
format!("{prefix}:{directory}")
}
#[tauri::command]
pub async fn get_skills(
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<Skill>, String> {
get_skills_for_app("claude".to_string(), service, app_state).await
}
let repos = {
let config = app_state.config.read().map_err(|e| e.to_string())?;
config.skills.repos.clone()
};
#[tauri::command]
pub async fn get_skills_for_app(
app: String,
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<Skill>, String> {
let app_type = parse_app_type(&app)?;
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
let skills = service
service
.0
.list_skills(repos)
.await
.map_err(|e| e.to_string())?;
// 自动同步本地已安装的 skills 到数据库
// 这样用户在首次运行时,已有的 skills 会被自动记录
let existing_states = app_state.db.get_skills().unwrap_or_default();
for skill in &skills {
if skill.installed {
let key = get_skill_key(&app_type, &skill.directory);
if !existing_states.contains_key(&key) {
// 本地有该 skill,但数据库中没有记录,自动添加
if let Err(e) = app_state.db.update_skill_state(
&key,
&SkillState {
installed: true,
installed_at: Utc::now(),
},
) {
log::warn!("同步本地 skill {key} 状态到数据库失败: {e}");
}
}
}
}
Ok(skills)
.map_err(|e| e.to_string())
}
#[tauri::command]
@@ -84,23 +30,14 @@ pub async fn install_skill(
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
install_skill_for_app("claude".to_string(), directory, service, app_state).await
}
#[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 service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
// 先在不持有写锁的情况下收集仓库与技能信息
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
let repos = {
let config = app_state.config.read().map_err(|e| e.to_string())?;
config.skills.repos.clone()
};
let skills = service
.0
.list_skills(repos)
.await
.map_err(|e| e.to_string())?;
@@ -108,54 +45,46 @@ pub async fn install_skill_for_app(
let skill = skills
.iter()
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
.ok_or_else(|| {
format_skill_error(
"SKILL_NOT_FOUND",
&[("directory", &directory)],
Some("checkRepoUrl"),
)
})?;
.ok_or_else(|| "技能不存在".to_string())?;
if !skill.installed {
let repo = SkillRepo {
owner: skill.repo_owner.clone().ok_or_else(|| {
format_skill_error(
"MISSING_REPO_INFO",
&[("directory", &directory), ("field", "owner")],
None,
)
})?,
name: skill.repo_name.clone().ok_or_else(|| {
format_skill_error(
"MISSING_REPO_INFO",
&[("directory", &directory), ("field", "name")],
None,
)
})?,
owner: skill
.repo_owner
.clone()
.ok_or_else(|| "缺少仓库信息".to_string())?,
name: skill
.repo_name
.clone()
.ok_or_else(|| "缺少仓库信息".to_string())?,
branch: skill
.repo_branch
.clone()
.unwrap_or_else(|| "main".to_string()),
enabled: true,
skills_path: None, // 安装时使用默认路径
};
service
.0
.install_skill(directory.clone(), repo)
.await
.map_err(|e| e.to_string())?;
}
let key = get_skill_key(&app_type, &directory);
app_state
.db
.update_skill_state(
&key,
&SkillState {
{
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
config.skills.skills.insert(
directory.clone(),
SkillState {
installed: true,
installed_at: Utc::now(),
},
)
.map_err(|e| e.to_string())?;
);
}
app_state.save().map_err(|e| e.to_string())?;
Ok(true)
}
@@ -166,35 +95,18 @@ pub fn uninstall_skill(
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
uninstall_skill_for_app("claude".to_string(), directory, service, app_state)
}
#[tauri::command]
pub fn uninstall_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 service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
service
.0
.uninstall_skill(directory.clone())
.map_err(|e| e.to_string())?;
// Remove from database by setting installed = false
let key = get_skill_key(&app_type, &directory);
app_state
.db
.update_skill_state(
&key,
&SkillState {
installed: false,
installed_at: Utc::now(),
},
)
.map_err(|e| e.to_string())?;
{
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
config.skills.skills.remove(&directory);
}
app_state.save().map_err(|e| e.to_string())?;
Ok(true)
}
@@ -204,19 +116,28 @@ pub fn get_skill_repos(
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<SkillRepo>, String> {
app_state.db.get_skill_repos().map_err(|e| e.to_string())
let config = app_state.config.read().map_err(|e| e.to_string())?;
Ok(config.skills.repos.clone())
}
#[tauri::command]
pub fn add_skill_repo(
repo: SkillRepo,
_service: State<'_, SkillServiceState>,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
app_state
.db
.save_skill_repo(&repo)
.map_err(|e| e.to_string())?;
{
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
service
.0
.add_repo(&mut config.skills, repo)
.map_err(|e| e.to_string())?;
}
app_state.save().map_err(|e| e.to_string())?;
Ok(true)
}
@@ -224,12 +145,19 @@ pub fn add_skill_repo(
pub fn remove_skill_repo(
owner: String,
name: String,
_service: State<'_, SkillServiceState>,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
app_state
.db
.delete_skill_repo(&owner, &name)
.map_err(|e| e.to_string())?;
{
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
service
.0
.remove_repo(&mut config.skills, owner, name)
.map_err(|e| e.to_string())?;
}
app_state.save().map_err(|e| e.to_string())?;
Ok(true)
}
-178
View File
@@ -1,178 +0,0 @@
//! 使用统计相关命令
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>,
days: u32,
) -> Result<Vec<DailyStats>, AppError> {
state.db.get_daily_trends(days)
}
/// 获取 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,
}
-2
View File
@@ -79,7 +79,6 @@ pub fn get_app_config_path() -> PathBuf {
}
/// 清理供应商名称,确保文件名安全
#[allow(dead_code)]
pub fn sanitize_provider_name(name: &str) -> String {
name.chars()
.map(|c| match c {
@@ -91,7 +90,6 @@ 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 {
let base_name = provider_name
.map(sanitize_provider_name)
-324
View File
@@ -1,324 +0,0 @@
//! 数据库备份和恢复
//!
//! 提供 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;
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 = Self::sanitize_import_sql(&sql_raw);
// 导入前备份现有数据库
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)
}
/// 移除 SQLite 保留对象相关语句(如 sqlite_sequence),避免导入报错
fn sanitize_import_sql(sql: &str) -> String {
let mut cleaned = String::new();
let lower_keyword = "sqlite_sequence";
for stmt in sql.split(';') {
let trimmed = stmt.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.to_ascii_lowercase().contains(lower_keyword) {
continue;
}
cleaned.push_str(trimmed);
cleaned.push_str(";\n");
}
cleaned
}
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 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 backup_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
let backup_path = backup_dir.join(format!("{backup_id}.db"));
{
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)
}
}
}
}
-97
View File
@@ -1,97 +0,0 @@
//! 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(())
}
}
-12
View File
@@ -1,12 +0,0 @@
//! Data Access Object layer
//!
//! Database access operations for each domain
pub mod mcp;
pub mod prompts;
pub mod providers;
pub mod proxy;
pub mod settings;
pub mod skills;
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
-88
View File
@@ -1,88 +0,0 @@
//! 提示词数据访问对象
//!
//! 提供提示词(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(())
}
}
-407
View File
@@ -1,407 +0,0 @@
//! 供应商数据访问对象
//!
//! 提供供应商(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, is_proxy_target
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 is_proxy_target: 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,
is_proxy_target: Some(is_proxy_target),
},
))
})
.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, is_proxy_target
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 is_proxy_target: 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,
is_proxy_target: Some(is_proxy_target),
})
},
);
match result {
Ok(provider) => Ok(Some(provider)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 获取代理目标供应商 ID
pub fn get_proxy_target_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_proxy_target = 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)
}
}
/// 保存供应商(新增或更新)
///
/// 注意:更新模式下不同步 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 和 is_proxy_target
let existing: Option<(bool, bool)> = tx
.query_row(
"SELECT is_current, is_proxy_target 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, is_proxy_target) = existing.unwrap_or((false, false));
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,
is_proxy_target = ?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,
is_proxy_target,
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, is_proxy_target
) 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,
is_proxy_target,
],
)
.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(())
}
/// 设置代理目标供应商
pub fn set_proxy_target_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_proxy_target = 0 WHERE app_type = ?1",
params![app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 设置新的代理目标供应商
tx.execute(
"UPDATE providers SET is_proxy_target = 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(())
}
/// 获取所有活跃的代理目标
pub fn get_all_proxy_targets(&self) -> Result<Vec<(String, String, String)>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT app_type, name, id FROM providers WHERE is_proxy_target = 1")
.map_err(|e| AppError::Database(e.to_string()))?;
let targets = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.map_err(|e| AppError::Database(e.to_string()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(targets)
}
/// 添加自定义端点
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(())
}
}
-244
View File
@@ -1,244 +0,0 @@
//! 代理功能数据访问层
//!
//! 处理代理配置、Provider健康状态和使用统计的数据库操作
use crate::error::AppError;
use crate::proxy::types::*;
use super::super::{lock_conn, Database};
impl Database {
// ==================== Proxy Config ====================
/// 获取代理配置
pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> {
// 在一个作用域内获取锁并查询,确保锁在await之前释放
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT enabled, listen_address, listen_port, max_retries,
request_timeout, enable_logging
FROM proxy_config WHERE id = 1",
[],
|row| {
Ok(ProxyConfig {
enabled: row.get::<_, i32>(0)? != 0,
listen_address: row.get(1)?,
listen_port: row.get::<_, i32>(2)? as u16,
max_retries: row.get::<_, i32>(3)? as u8,
request_timeout: row.get::<_, i32>(4)? as u64,
enable_logging: row.get::<_, i32>(5)? != 0,
})
},
)
}; // conn锁在这里释放
match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,插入默认配置
let default_config = ProxyConfig::default();
self.update_proxy_config(default_config.clone()).await?;
Ok(default_config)
}
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(
"INSERT OR REPLACE INTO proxy_config
(id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, target_app, created_at, updated_at)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7,
COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')),
datetime('now'))",
rusqlite::params![
if config.enabled { 1 } else { 0 },
config.listen_address,
config.listen_port as i32,
config.max_retries as i32,
config.request_timeout as i32,
if config.enable_logging { 1 } else { 0 },
"claude", // 兼容旧字段,写入默认值
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
// ==================== Provider Health ====================
/// 获取Provider健康状态
pub async fn get_provider_health(
&self,
provider_id: &str,
app_type: &str,
) -> Result<ProviderHealth, AppError> {
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)?,
})
},
)
.map_err(|e| AppError::Database(e.to_string()))
}
/// 更新Provider健康状态
pub async fn update_provider_health(
&self,
provider_id: &str,
app_type: &str,
success: bool,
error_msg: Option<String>,
) -> 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 >= 3 { 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(())
}
// ==================== Proxy Usage (可选) ====================
/// 记录代理使用统计
#[allow(dead_code)]
pub async fn record_proxy_usage(&self, record: &ProxyUsageRecord) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT INTO proxy_usage
(provider_id, app_type, endpoint, request_tokens, response_tokens,
status_code, latency_ms, error, timestamp)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
rusqlite::params![
&record.provider_id,
&record.app_type,
&record.endpoint,
record.request_tokens,
record.response_tokens,
record.status_code as i64,
record.latency_ms as i64,
&record.error,
&record.timestamp,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 查询最近的使用统计
#[allow(dead_code)]
pub async fn get_recent_usage(
&self,
provider_id: &str,
app_type: &str,
limit: usize,
) -> Result<Vec<ProxyUsageRecord>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT provider_id, app_type, endpoint, request_tokens, response_tokens,
status_code, latency_ms, error, timestamp
FROM proxy_usage
WHERE provider_id = ?1 AND app_type = ?2
ORDER BY timestamp DESC
LIMIT ?3",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let rows = stmt
.query_map(
rusqlite::params![provider_id, app_type, limit as i64],
|row| {
Ok(ProxyUsageRecord {
provider_id: row.get(0)?,
app_type: row.get(1)?,
endpoint: row.get(2)?,
request_tokens: row.get(3)?,
response_tokens: row.get(4)?,
status_code: row.get::<_, i64>(5)? as u16,
latency_ms: row.get::<_, i64>(6)? as u64,
error: row.get(7)?,
timestamp: row.get(8)?,
})
},
)
.map_err(|e| AppError::Database(e.to_string()))?;
let mut records = Vec::new();
for row in rows {
records.push(row.map_err(|e| AppError::Database(e.to_string()))?);
}
Ok(records)
}
}
-65
View File
@@ -1,65 +0,0 @@
//! 通用设置数据访问对象
//!
//! 提供键值对形式的通用设置存储。
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(())
}
}
}
-139
View File
@@ -1,139 +0,0 @@
//! Skills 数据访问对象
//!
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::services::skill::{SkillRepo, SkillState};
use indexmap::IndexMap;
use rusqlite::params;
impl Database {
/// 获取所有 Skills 状态
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT directory, app_type, installed, installed_at FROM skills ORDER BY directory ASC, app_type ASC")
.map_err(|e| AppError::Database(e.to_string()))?;
let skill_iter = stmt
.query_map([], |row| {
let directory: String = row.get(0)?;
let app_type: String = row.get(1)?;
let installed: bool = row.get(2)?;
let installed_at_ts: i64 = row.get(3)?;
let installed_at =
chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default();
// 构建复合 key"app_type:directory"
let key = format!("{app_type}:{directory}");
Ok((
key,
SkillState {
installed,
installed_at,
},
))
})
.map_err(|e| AppError::Database(e.to_string()))?;
let mut skills = IndexMap::new();
for skill_res in skill_iter {
let (key, skill) = skill_res.map_err(|e| AppError::Database(e.to_string()))?;
skills.insert(key, skill);
}
Ok(skills)
}
/// 更新 Skill 状态
/// key 格式为 "app_type:directory"
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> {
// 解析 key
let (app_type, directory) = if let Some(idx) = key.find(':') {
let (app, dir) = key.split_at(idx);
(app, &dir[1..]) // 跳过冒号
} else {
// 向后兼容:如果没有前缀,默认为 claude
("claude", key)
};
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)",
params![directory, app_type, state.installed, state.installed_at.timestamp()],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 获取所有 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)
}
}
-242
View File
@@ -1,242 +0,0 @@
//! 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> {
for (key, state) in &config.skills.skills {
tx.execute(
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
params![key, state.installed, state.installed_at.timestamp()],
)
.map_err(|e| AppError::Database(format!("Migrate skill failed: {e}")))?;
}
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(())
}
}
-137
View File
@@ -1,137 +0,0 @@
//! 数据库模块 - 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;
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 = 2;
/// 安全地序列化 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)
}
}
-884
View File
@@ -1,884 +0,0 @@
//! Schema 定义和迁移
//!
//! 负责数据库表结构的创建和版本迁移。
use super::{lock_conn, Database, SCHEMA_VERSION};
use crate::error::AppError;
use rusqlite::Connection;
impl Database {
/// 创建所有数据库表
pub(crate) fn create_tables(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
Self::create_tables_on_conn(&conn)
}
/// 在指定连接上创建表(供迁移和测试使用)
pub(crate) fn create_tables_on_conn(conn: &Connection) -> Result<(), AppError> {
// 1. Providers 表
conn.execute(
"CREATE TABLE IF NOT EXISTS 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,
is_proxy_target BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (id, app_type)
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 尝试添加 is_proxy_target 列(如果表已存在但缺少该列)
let _ = conn.execute(
"ALTER TABLE providers ADD COLUMN is_proxy_target BOOLEAN NOT NULL DEFAULT 0",
[],
);
// 2. Provider Endpoints 表
conn.execute(
"CREATE TABLE IF NOT EXISTS 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
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 3. MCP Servers 表
conn.execute(
"CREATE TABLE IF NOT EXISTS 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
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 4. Prompts 表
conn.execute(
"CREATE TABLE IF NOT EXISTS 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)
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 5. Skills 表
conn.execute(
"CREATE TABLE IF NOT EXISTS skills (
directory TEXT NOT NULL,
app_type TEXT NOT NULL,
installed BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (directory, app_type)
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 6. Skill Repos 表
conn.execute(
"CREATE TABLE IF NOT EXISTS 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)
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 7. Settings 表 (通用配置)
conn.execute(
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 8. Proxy Config 表 (代理服务器配置)
// 代理配置表(单例)
conn.execute(
"CREATE TABLE IF NOT EXISTS proxy_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
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'))
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 尝试添加 target_app 列(如果表已存在但缺少该列)
// 忽略 "duplicate column name" 错误
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN target_app TEXT NOT NULL DEFAULT 'claude'",
[],
);
// 9. Provider Health 表 (Provider健康状态)
conn.execute(
"CREATE TABLE IF NOT EXISTS provider_health (
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
is_healthy INTEGER NOT NULL DEFAULT 1,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
last_success_at TEXT,
last_failure_at TEXT,
last_error TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (provider_id, app_type),
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 10. Proxy Usage 表 (代理使用统计,可选)
conn.execute(
"CREATE TABLE IF NOT EXISTS proxy_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
endpoint TEXT NOT NULL,
request_tokens INTEGER,
response_tokens INTEGER,
status_code INTEGER NOT NULL,
latency_ms INTEGER NOT NULL,
error TEXT,
timestamp TEXT NOT NULL
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 为 proxy_usage 创建索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_proxy_usage_timestamp
ON proxy_usage(timestamp)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_proxy_usage_provider
ON proxy_usage(provider_id, app_type)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 11. Proxy Request Logs 表 (详细请求日志)
conn.execute(
"CREATE TABLE IF NOT EXISTS proxy_request_logs (
request_id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
model TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
input_cost_usd TEXT NOT NULL DEFAULT '0',
output_cost_usd TEXT NOT NULL DEFAULT '0',
cache_read_cost_usd TEXT NOT NULL DEFAULT '0',
cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
total_cost_usd TEXT NOT NULL DEFAULT '0',
latency_ms INTEGER NOT NULL,
first_token_ms INTEGER,
duration_ms INTEGER,
status_code INTEGER NOT NULL,
error_message TEXT,
session_id TEXT,
provider_type TEXT,
is_streaming INTEGER NOT NULL DEFAULT 0,
cost_multiplier TEXT NOT NULL DEFAULT '1.0',
created_at INTEGER NOT NULL
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_provider
ON proxy_request_logs(provider_id, app_type)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_created_at
ON proxy_request_logs(created_at)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_model
ON proxy_request_logs(model)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_session
ON proxy_request_logs(session_id)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_status
ON proxy_request_logs(status_code)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 12. Model Pricing 表 (模型定价)
conn.execute(
"CREATE TABLE IF NOT EXISTS model_pricing (
model_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
input_cost_per_million TEXT NOT NULL,
output_cost_per_million TEXT NOT NULL,
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 13. Usage Daily Stats 表 (每日聚合统计)
conn.execute(
"CREATE TABLE IF NOT EXISTS usage_daily_stats (
date TEXT NOT NULL,
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
model TEXT NOT NULL,
request_count INTEGER NOT NULL DEFAULT 0,
total_input_tokens INTEGER NOT NULL DEFAULT 0,
total_output_tokens INTEGER NOT NULL DEFAULT 0,
total_cost_usd TEXT NOT NULL DEFAULT '0',
success_count INTEGER NOT NULL DEFAULT 0,
error_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (date, provider_id, app_type, model)
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 14. Model Test Logs 表 (模型测试日志,独立于代理使用统计)
conn.execute(
"CREATE TABLE IF NOT EXISTS model_test_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id TEXT NOT NULL,
provider_name TEXT NOT NULL,
app_type TEXT NOT NULL,
model TEXT NOT NULL,
prompt TEXT NOT NULL,
success INTEGER NOT NULL,
message TEXT NOT NULL,
response_time_ms INTEGER,
http_status INTEGER,
tested_at INTEGER NOT NULL
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_model_test_logs_provider
ON model_test_logs(provider_id, app_type)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_model_test_logs_tested_at
ON model_test_logs(tested_at DESC)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 应用 Schema 迁移
pub(crate) fn apply_schema_migrations(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
Self::apply_schema_migrations_on_conn(&conn)
}
/// 在指定连接上应用 Schema 迁移
pub(crate) fn apply_schema_migrations_on_conn(conn: &Connection) -> Result<(), AppError> {
conn.execute("SAVEPOINT schema_migration;", [])
.map_err(|e| AppError::Database(format!("开启迁移 savepoint 失败: {e}")))?;
let mut version = Self::get_user_version(conn)?;
if version > SCHEMA_VERSION {
conn.execute("ROLLBACK TO schema_migration;", []).ok();
conn.execute("RELEASE schema_migration;", []).ok();
return Err(AppError::Database(format!(
"数据库版本过新({version}),当前应用仅支持 {SCHEMA_VERSION},请升级应用后再尝试。"
)));
}
let result = (|| {
while version < SCHEMA_VERSION {
match version {
0 => {
log::info!("检测到 user_version=0,迁移到 1(补齐缺失列并设置版本)");
Self::migrate_v0_to_v1(conn)?;
Self::set_user_version(conn, 1)?;
}
1 => {
log::info!(
"迁移数据库从 v1 到 v2(添加使用统计表和完整字段,重构 skills 表)"
);
Self::migrate_v1_to_v2(conn)?;
Self::set_user_version(conn, 2)?;
}
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
)));
}
}
version = Self::get_user_version(conn)?;
}
Ok(())
})();
match result {
Ok(_) => {
conn.execute("RELEASE schema_migration;", [])
.map_err(|e| AppError::Database(format!("提交迁移 savepoint 失败: {e}")))?;
Ok(())
}
Err(e) => {
conn.execute("ROLLBACK TO schema_migration;", []).ok();
conn.execute("RELEASE schema_migration;", []).ok();
Err(e)
}
}
}
/// v0 -> v1 迁移:补齐所有缺失列
fn migrate_v0_to_v1(conn: &Connection) -> Result<(), AppError> {
// providers 表
Self::add_column_if_missing(conn, "providers", "category", "TEXT")?;
Self::add_column_if_missing(conn, "providers", "created_at", "INTEGER")?;
Self::add_column_if_missing(conn, "providers", "sort_index", "INTEGER")?;
Self::add_column_if_missing(conn, "providers", "notes", "TEXT")?;
Self::add_column_if_missing(conn, "providers", "icon", "TEXT")?;
Self::add_column_if_missing(conn, "providers", "icon_color", "TEXT")?;
Self::add_column_if_missing(conn, "providers", "meta", "TEXT NOT NULL DEFAULT '{}'")?;
Self::add_column_if_missing(
conn,
"providers",
"is_current",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
// provider_endpoints 表
Self::add_column_if_missing(conn, "provider_endpoints", "added_at", "INTEGER")?;
// mcp_servers 表
Self::add_column_if_missing(conn, "mcp_servers", "description", "TEXT")?;
Self::add_column_if_missing(conn, "mcp_servers", "homepage", "TEXT")?;
Self::add_column_if_missing(conn, "mcp_servers", "docs", "TEXT")?;
Self::add_column_if_missing(conn, "mcp_servers", "tags", "TEXT NOT NULL DEFAULT '[]'")?;
Self::add_column_if_missing(
conn,
"mcp_servers",
"enabled_codex",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
Self::add_column_if_missing(
conn,
"mcp_servers",
"enabled_gemini",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
// prompts 表
Self::add_column_if_missing(conn, "prompts", "description", "TEXT")?;
Self::add_column_if_missing(conn, "prompts", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
Self::add_column_if_missing(conn, "prompts", "created_at", "INTEGER")?;
Self::add_column_if_missing(conn, "prompts", "updated_at", "INTEGER")?;
// skills 表
Self::add_column_if_missing(conn, "skills", "installed_at", "INTEGER NOT NULL DEFAULT 0")?;
// skill_repos 表
Self::add_column_if_missing(
conn,
"skill_repos",
"branch",
"TEXT NOT NULL DEFAULT 'main'",
)?;
Self::add_column_if_missing(conn, "skill_repos", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
// 注意: skills_path 字段已被移除,因为现在支持全仓库递归扫描
Ok(())
}
/// v1 -> v2 迁移:添加使用统计表和完整字段,重构 skills 表
fn migrate_v1_to_v2(conn: &Connection) -> Result<(), AppError> {
// providers 表字段
Self::add_column_if_missing(
conn,
"providers",
"cost_multiplier",
"TEXT NOT NULL DEFAULT '1.0'",
)?;
Self::add_column_if_missing(conn, "providers", "limit_daily_usd", "TEXT")?;
Self::add_column_if_missing(conn, "providers", "limit_monthly_usd", "TEXT")?;
Self::add_column_if_missing(conn, "providers", "provider_type", "TEXT")?;
// proxy_request_logs 表(包含所有字段)
conn.execute(
"CREATE TABLE IF NOT EXISTS proxy_request_logs (
request_id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
model TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
input_cost_usd TEXT NOT NULL DEFAULT '0',
output_cost_usd TEXT NOT NULL DEFAULT '0',
cache_read_cost_usd TEXT NOT NULL DEFAULT '0',
cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
total_cost_usd TEXT NOT NULL DEFAULT '0',
latency_ms INTEGER NOT NULL,
first_token_ms INTEGER,
duration_ms INTEGER,
status_code INTEGER NOT NULL,
error_message TEXT,
session_id TEXT,
provider_type TEXT,
is_streaming INTEGER NOT NULL DEFAULT 0,
cost_multiplier TEXT NOT NULL DEFAULT '1.0',
created_at INTEGER NOT NULL
)",
[],
)?;
// 为已存在的表添加新字段
Self::add_column_if_missing(conn, "proxy_request_logs", "provider_type", "TEXT")?;
Self::add_column_if_missing(
conn,
"proxy_request_logs",
"is_streaming",
"INTEGER NOT NULL DEFAULT 0",
)?;
Self::add_column_if_missing(
conn,
"proxy_request_logs",
"cost_multiplier",
"TEXT NOT NULL DEFAULT '1.0'",
)?;
Self::add_column_if_missing(conn, "proxy_request_logs", "first_token_ms", "INTEGER")?;
Self::add_column_if_missing(conn, "proxy_request_logs", "duration_ms", "INTEGER")?;
// model_pricing 表
conn.execute(
"CREATE TABLE IF NOT EXISTS model_pricing (
model_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
input_cost_per_million TEXT NOT NULL,
output_cost_per_million TEXT NOT NULL,
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
)",
[],
)?;
// usage_daily_stats 表
conn.execute(
"CREATE TABLE IF NOT EXISTS usage_daily_stats (
date TEXT NOT NULL,
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
model TEXT NOT NULL,
request_count INTEGER NOT NULL DEFAULT 0,
total_input_tokens INTEGER NOT NULL DEFAULT 0,
total_output_tokens INTEGER NOT NULL DEFAULT 0,
total_cost_usd TEXT NOT NULL DEFAULT '0',
success_count INTEGER NOT NULL DEFAULT 0,
error_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (date, provider_id, app_type, model)
)",
[],
)?;
// 清空并重新插入模型定价
conn.execute("DELETE FROM model_pricing", [])
.map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
Self::seed_model_pricing(conn)?;
// 重构 skills 表(添加 app_type 字段)
Self::migrate_skills_table(conn)?;
Ok(())
}
/// 迁移 skills 表:从单 key 主键改为 (directory, app_type) 复合主键
fn migrate_skills_table(conn: &Connection) -> Result<(), AppError> {
// 检查是否已经是新表结构
if Self::has_column(conn, "skills", "app_type")? {
log::info!("skills 表已经包含 app_type 字段,跳过迁移");
return Ok(());
}
log::info!("开始迁移 skills 表...");
// 1. 重命名旧表
conn.execute("ALTER TABLE skills RENAME TO skills_old", [])
.map_err(|e| AppError::Database(format!("重命名旧 skills 表失败: {e}")))?;
// 2. 创建新表
conn.execute(
"CREATE TABLE skills (
directory TEXT NOT NULL,
app_type TEXT NOT NULL,
installed BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (directory, app_type)
)",
[],
)
.map_err(|e| AppError::Database(format!("创建新 skills 表失败: {e}")))?;
// 3. 迁移数据:解析 key 格式(如 "claude:my-skill" 或 "codex:foo"
// 旧数据如果没有前缀,默认为 claude
let mut stmt = conn
.prepare("SELECT key, installed, installed_at FROM skills_old")
.map_err(|e| AppError::Database(format!("查询旧 skills 数据失败: {e}")))?;
let old_skills: Vec<(String, bool, i64)> = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, bool>(1)?,
row.get::<_, i64>(2)?,
))
})
.map_err(|e| AppError::Database(format!("读取旧 skills 数据失败: {e}")))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| AppError::Database(format!("解析旧 skills 数据失败: {e}")))?;
let count = old_skills.len();
for (key, installed, installed_at) in old_skills {
// 解析 key: "app:directory" 或 "directory"(默认 claude
let (app_type, directory) = if let Some(idx) = key.find(':') {
let (app, dir) = key.split_at(idx);
(app.to_string(), dir[1..].to_string()) // 跳过冒号
} else {
("claude".to_string(), key.clone())
};
conn.execute(
"INSERT INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![directory, app_type, installed, installed_at],
)
.map_err(|e| {
AppError::Database(format!("迁移 skill {key} 到新表失败: {e}"))
})?;
}
// 4. 删除旧表
conn.execute("DROP TABLE skills_old", [])
.map_err(|e| AppError::Database(format!("删除旧 skills 表失败: {e}")))?;
log::info!("skills 表迁移完成,共迁移 {count} 条记录");
Ok(())
}
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
let pricing_data = [
// Claude 4.5 系列
(
"claude-opus-4-5",
"Claude Opus 4.5",
"5",
"25",
"0.50",
"6.25",
),
(
"claude-sonnet-4-5",
"Claude Sonnet 4.5",
"3",
"15",
"0.30",
"3.75",
),
(
"claude-haiku-4-5",
"Claude Haiku 4.5",
"1",
"5",
"0.10",
"1.25",
),
// Claude 4.1 系列
(
"claude-opus-4-1",
"Claude Opus 4.1",
"15",
"75",
"1.50",
"18.75",
),
(
"claude-sonnet-4-1",
"Claude Sonnet 4.1",
"3",
"15",
"0.30",
"3.75",
),
// Claude 3.7 系列
(
"claude-sonnet-3-7",
"Claude Sonnet 3.7",
"3",
"15",
"0.30",
"3.75",
),
// Claude 3.5 系列
(
"claude-sonnet-3-5",
"Claude Sonnet 3.5",
"3",
"15",
"0.30",
"3.75",
),
(
"claude-haiku-3-5",
"Claude Haiku 3.5",
"0.80",
"4",
"0.08",
"1",
),
// GPT-5 系列(model_id 使用短横线格式)
("gpt-5", "GPT-5", "1.25", "10", "0.125", "0"),
("gpt-5-1", "GPT-5.1", "1.25", "10", "0.125", "0"),
("gpt-5-codex", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
("gpt-5-1-codex", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"),
// Gemini 3 系列
(
"gemini-3-pro-preview",
"Gemini 3 Pro Preview",
"2",
"12",
"0",
"0",
),
// Gemini 2.5 系列(model_id 使用短横线格式)
(
"gemini-2-5-pro",
"Gemini 2.5 Pro",
"1.25",
"10",
"0.125",
"0",
),
(
"gemini-2-5-flash",
"Gemini 2.5 Flash",
"0.3",
"2.5",
"0.03",
"0",
),
];
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
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,
output,
cache_read,
cache_creation
],
)
.map_err(|e| AppError::Database(format!("插入模型定价失败: {e}")))?;
}
log::info!("已插入 {} 条默认模型定价数据", pricing_data.len());
Ok(())
}
/// 确保模型定价表具备默认数据
pub fn ensure_model_pricing_seeded(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
Self::ensure_model_pricing_seeded_on_conn(&conn)
}
fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> {
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))
.map_err(|e| AppError::Database(format!("统计模型定价数据失败: {e}")))?;
if count == 0 {
Self::seed_model_pricing(conn)?;
}
Ok(())
}
// --- 辅助方法 ---
pub(crate) fn get_user_version(conn: &Connection) -> Result<i32, AppError> {
conn.query_row("PRAGMA user_version;", [], |row| row.get(0))
.map_err(|e| AppError::Database(format!("读取 user_version 失败: {e}")))
}
pub(crate) fn set_user_version(conn: &Connection, version: i32) -> Result<(), AppError> {
if version < 0 {
return Err(AppError::Database("user_version 不能为负数".to_string()));
}
let sql = format!("PRAGMA user_version = {version};");
conn.execute(&sql, [])
.map_err(|e| AppError::Database(format!("写入 user_version 失败: {e}")))?;
Ok(())
}
fn validate_identifier(s: &str, kind: &str) -> Result<(), AppError> {
if s.is_empty() {
return Err(AppError::Database(format!("{kind} 不能为空")));
}
if !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(AppError::Database(format!(
"非法{kind}: {s},仅允许字母、数字和下划线"
)));
}
Ok(())
}
pub(crate) fn table_exists(conn: &Connection, table: &str) -> Result<bool, AppError> {
Self::validate_identifier(table, "表名")?;
let mut stmt = conn
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.map_err(|e| AppError::Database(format!("读取表名失败: {e}")))?;
let mut rows = stmt
.query([])
.map_err(|e| AppError::Database(format!("查询表名失败: {e}")))?;
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
let name: String = row
.get(0)
.map_err(|e| AppError::Database(format!("解析表名失败: {e}")))?;
if name.eq_ignore_ascii_case(table) {
return Ok(true);
}
}
Ok(false)
}
pub(crate) fn has_column(
conn: &Connection,
table: &str,
column: &str,
) -> Result<bool, AppError> {
Self::validate_identifier(table, "表名")?;
Self::validate_identifier(column, "列名")?;
let sql = format!("PRAGMA table_info(\"{table}\");");
let mut stmt = conn
.prepare(&sql)
.map_err(|e| AppError::Database(format!("读取表结构失败: {e}")))?;
let mut rows = stmt
.query([])
.map_err(|e| AppError::Database(format!("查询表结构失败: {e}")))?;
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
let name: String = row
.get(1)
.map_err(|e| AppError::Database(format!("读取列名失败: {e}")))?;
if name.eq_ignore_ascii_case(column) {
return Ok(true);
}
}
Ok(false)
}
fn add_column_if_missing(
conn: &Connection,
table: &str,
column: &str,
definition: &str,
) -> Result<bool, AppError> {
Self::validate_identifier(table, "表名")?;
Self::validate_identifier(column, "列名")?;
if !Self::table_exists(conn, table)? {
return Err(AppError::Database(format!(
"{table} 不存在,无法添加列 {column}"
)));
}
if Self::has_column(conn, table, column)? {
return Ok(false);
}
let sql = format!("ALTER TABLE \"{table}\" ADD COLUMN \"{column}\" {definition};");
conn.execute(&sql, [])
.map_err(|e| AppError::Database(format!("为表 {table} 添加列 {column} 失败: {e}")))?;
log::info!("已为表 {table} 添加缺失列 {column}");
Ok(true)
}
}
-334
View File
@@ -1,334 +0,0 @@
//! 数据库模块测试
//!
//! 包含 Schema 迁移和基本功能的测试。
use super::*;
use crate::app_config::MultiAppConfig;
use crate::provider::{Provider, ProviderManager};
use indexmap::IndexMap;
use rusqlite::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
);
"#;
#[derive(Debug)]
struct ColumnInfo {
name: String,
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 name: String = row.get(1).expect("name");
if name.eq_ignore_ascii_case(column) {
return ColumnInfo {
name,
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 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,
is_proxy_target: Some(false),
},
);
let mut manager = ProviderManager::default();
manager.providers = providers;
manager.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
);
}
+457
View File
@@ -0,0 +1,457 @@
/// Deep link import functionality for CC Switch
///
/// This module implements the ccswitch:// protocol for importing provider configurations
/// via deep links. See docs/ccswitch-deeplink-design.md for detailed design.
use crate::error::AppError;
use crate::provider::Provider;
use crate::services::ProviderService;
use crate::store::AppState;
use crate::AppType;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::str::FromStr;
use url::Url;
/// Deep link import request model
/// Represents a parsed ccswitch:// URL ready for processing
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeepLinkImportRequest {
/// Protocol version (e.g., "v1")
pub version: String,
/// Resource type to import (e.g., "provider")
pub resource: String,
/// Target application (claude/codex/gemini)
pub app: String,
/// Provider name
pub name: String,
/// Provider homepage URL
pub homepage: String,
/// API endpoint/base URL
pub endpoint: String,
/// API key
pub api_key: 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>,
}
/// Parse a ccswitch:// URL into a DeepLinkImportRequest
///
/// Expected format:
/// ccswitch://v1/import?resource=provider&app=claude&name=...&homepage=...&endpoint=...&apiKey=...
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();
if resource != "provider" {
return Err(AppError::InvalidInput(format!(
"Unsupported resource type: {resource}"
)));
}
// Extract required fields
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();
let homepage = params
.get("homepage")
.ok_or_else(|| AppError::InvalidInput("Missing 'homepage' parameter".to_string()))?
.clone();
let endpoint = params
.get("endpoint")
.ok_or_else(|| AppError::InvalidInput("Missing 'endpoint' parameter".to_string()))?
.clone();
let api_key = params
.get("apiKey")
.ok_or_else(|| AppError::InvalidInput("Missing 'apiKey' parameter".to_string()))?
.clone();
// Validate URLs
validate_url(&homepage, "homepage")?;
validate_url(&endpoint, "endpoint")?;
// Extract optional fields
let model = params.get("model").cloned();
let notes = params.get("notes").cloned();
Ok(DeepLinkImportRequest {
version,
resource,
app,
name,
homepage,
endpoint,
api_key,
model,
notes,
})
}
/// Validate that a string is a valid HTTP(S) URL
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(())
}
/// Import a provider from a deep link request
///
/// This function:
/// 1. Validates the request
/// 2. Converts it to a Provider structure
/// 3. Delegates to ProviderService for actual import
pub fn import_provider_from_deeplink(
state: &AppState,
request: DeepLinkImportRequest,
) -> Result<String, AppError> {
// Parse app type
let app_type = AppType::from_str(&request.app)
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {}", request.app)))?;
// Build provider configuration based on app type
let mut provider = build_provider_from_request(&app_type, &request)?;
// Generate a unique ID for the provider using timestamp + sanitized name
// This is similar to how frontend generates IDs
let timestamp = chrono::Utc::now().timestamp_millis();
let sanitized_name = request
.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, provider)?;
Ok(provider_id)
}
/// Build a Provider structure from a deep link request
fn build_provider_from_request(
app_type: &AppType,
request: &DeepLinkImportRequest,
) -> Result<Provider, AppError> {
use serde_json::json;
let settings_config = match app_type {
AppType::Claude => {
// Claude configuration structure
let mut env = serde_json::Map::new();
env.insert("ANTHROPIC_AUTH_TOKEN".to_string(), json!(request.api_key));
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(request.endpoint));
// Add model if provided (use as default model)
if let Some(model) = &request.model {
env.insert("ANTHROPIC_MODEL".to_string(), json!(model));
}
json!({ "env": env })
}
AppType::Codex => {
// Codex configuration structure
// For Codex, we store auth.json (JSON) and config.toml (TOML string) in settings_config。
//
// 这里尽量与前端 `getCodexCustomTemplate` 的默认模板保持一致,
// 再根据深链接参数注入 base_url / model,避免出现“只有 base_url 行”的极简配置,
// 让通过 UI 新建和通过深链接导入的 Codex 自定义供应商行为一致。
// 1. 生成一个适合作为 model_provider 名的安全标识
// 规则尽量与前端 codexProviderPresets.generateThirdPartyConfig 保持一致:
// - 转小写
// - 非 [a-z0-9_] 统一替换为下划线
// - 去掉首尾下划线
// - 若结果为空,则使用 "custom"
let clean_provider_name = {
let raw: String = request.name.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();
// 去掉首尾下划线
while key.starts_with('_') {
key.remove(0);
}
while key.ends_with('_') {
key.pop();
}
if key.is_empty() {
"custom".to_string()
} else {
key
}
};
// 2. 模型名称:优先使用 deeplink 中的 model,否则退回到 Codex 默认模型
let model_name = request
.model
.as_deref()
.unwrap_or("gpt-5-codex")
.to_string();
// 3. 端点:与 UI 中 Base URL 处理方式保持一致,去掉结尾多余的斜杠
let endpoint = request.endpoint.trim().trim_end_matches('/').to_string();
// 4. 组装 config.toml 内容
// 使用 Rust 1.58+ 的内联格式化语法,避免 clippy::uninlined_format_args 警告
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
})
}
AppType::Gemini => {
// Gemini configuration structure (.env format)
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 })
}
};
let provider = Provider {
id: String::new(), // Will be generated by ProviderService
name: request.name.clone(),
settings_config,
website_url: Some(request.homepage.clone()),
category: None,
created_at: None,
sort_index: None,
notes: request.notes.clone(),
meta: None,
};
Ok(provider)
}
#[cfg(test)]
mod tests {
use super::*;
#[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";
let request = parse_deeplink_url(url).unwrap();
assert_eq!(request.version, "v1");
assert_eq!(request.resource, "provider");
assert_eq!(request.app, "claude");
assert_eq!(request.name, "Test Provider");
assert_eq!(request.homepage, "https://example.com");
assert_eq!(request.endpoint, "https://api.example.com");
assert_eq!(request.api_key, "sk-test-123");
}
#[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&notes=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() {
let url = "ccswitch://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("Missing 'homepage' parameter"));
}
#[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_build_gemini_provider_with_model() {
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: "gemini".to_string(),
name: "Test Gemini".to_string(),
homepage: "https://example.com".to_string(),
endpoint: "https://api.example.com".to_string(),
api_key: "test-api-key".to_string(),
model: Some("gemini-2.0-flash".to_string()),
notes: 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() {
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: "gemini".to_string(),
name: "Test Gemini".to_string(),
homepage: "https://example.com".to_string(),
endpoint: "https://api.example.com".to_string(),
api_key: "test-api-key".to_string(),
model: None,
notes: None,
};
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
// 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");
// Model should not be present
assert!(env.get("GEMINI_MODEL").is_none());
}
}
-191
View File
@@ -1,191 +0,0 @@
//! 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)
}
-116
View File
@@ -1,116 +0,0 @@
//! 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>,
}
-313
View File
@@ -1,313 +0,0 @@
//! 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(&params, version, resource),
"prompt" => parse_prompt_deeplink(&params, version, resource),
"mcp" => parse_mcp_deeplink(&params, version, resource),
"skill" => parse_skill_deeplink(&params, 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());
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,
})
}
/// 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,
})
}
/// 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,
})
}
/// 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,
})
}
-86
View File
@@ -1,86 +0,0 @@
//! 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)
}
-511
View File
@@ -1,511 +0,0 @@
//! 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;
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),
};
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: None,
icon: request.icon.clone(),
icon_color: None,
is_proxy_target: None,
};
Ok(provider)
}
/// 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
}
-51
View File
@@ -1,51 +0,0 @@
//! 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}"))
}
-378
View File
@@ -1,378 +0,0 @@
//! 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&notes=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,
};
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,
};
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,
};
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,
};
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_eq!(request.enabled.unwrap(), true);
}
#[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_eq!(request.enabled.unwrap(), true);
}
#[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");
}
-99
View File
@@ -1,99 +0,0 @@
//! 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}"))
}
-42
View File
@@ -50,8 +50,6 @@ pub enum AppError {
zh: String,
en: String,
},
#[error("数据库错误: {0}")]
Database(String),
}
impl AppError {
@@ -91,48 +89,8 @@ 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 {
fn from(err: AppError) -> Self {
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}")
})
}
+8 -31
View File
@@ -236,17 +236,6 @@ pub fn validate_gemini_settings(settings: &Value) -> Result<(), AppError> {
}
}
// 如果有 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(())
}
@@ -255,9 +244,6 @@ pub fn validate_gemini_settings(settings: &Value) -> Result<(), AppError> {
/// 此函数在切换供应商时使用,确保配置包含所有必需的字段。
/// 对于需要 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 官方),跳过验证
@@ -382,7 +368,7 @@ mod tests {
# Comment line
GOOGLE_GEMINI_BASE_URL=https://example.com
GEMINI_API_KEY=sk-test123
GEMINI_MODEL=gemini-3-pro-preview
GEMINI_MODEL=gemini-2.5-pro
# Another comment
"#;
@@ -395,25 +381,19 @@ GEMINI_MODEL=gemini-3-pro-preview
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())
);
assert_eq!(map.get("GEMINI_MODEL"), Some(&"gemini-2.5-pro".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(),
);
map.insert("GEMINI_MODEL".to_string(), "gemini-2.5-pro".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"));
assert!(content.contains("GEMINI_MODEL=gemini-2.5-pro"));
}
#[test]
@@ -437,7 +417,7 @@ GEMINI_MODEL=gemini-3-pro-preview
# Comment line
GOOGLE_GEMINI_BASE_URL=https://example.com
GEMINI_API_KEY=sk-test123
GEMINI_MODEL=gemini-3-pro-preview
GEMINI_MODEL=gemini-2.5-pro
# Another comment
"#;
@@ -452,10 +432,7 @@ GEMINI_MODEL=gemini-3-pro-preview
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())
);
assert_eq!(map.get("GEMINI_MODEL"), Some(&"gemini-2.5-pro".to_string()));
}
#[test]
@@ -621,7 +598,7 @@ KEY_WITH-DASH=value";
let settings = serde_json::json!({
"env": {
"GEMINI_API_KEY": "sk-test123",
"GEMINI_MODEL": "gemini-3-pro-preview"
"GEMINI_MODEL": "gemini-2.5-pro"
}
});
@@ -634,7 +611,7 @@ KEY_WITH-DASH=value";
// 测试缺少 API Key 的非空配置在基本验证中可以通过(用户稍后填写)
let settings = serde_json::json!({
"env": {
"GEMINI_MODEL": "gemini-3-pro-preview"
"GEMINI_MODEL": "gemini-2.5-pro"
}
});
+12 -33
View File
@@ -38,12 +38,17 @@ fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {
atomic_write(path, json.as_bytes())
}
/// 读取 Gemini MCP 配置文件的完整 JSON 文本
pub fn read_mcp_json() -> Result<Option<String>, AppError> {
let path = user_config_path();
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
Ok(Some(content))
}
/// 读取 Gemini settings.json 中的 mcpServers 映射
///
/// 执行反向格式转换以保持与统一 MCP 结构的兼容性:
/// - httpUrl → url + type: "http"
/// - 仅有 url 字段 → 保持不变(SSE 类型)
/// - 仅有 command 字段 → 保持不变(stdio 类型)
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
let path = user_config_path();
if !path.exists() {
@@ -51,25 +56,12 @@ pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>
}
let root = read_json_value(&path)?;
let mut servers: std::collections::HashMap<String, Value> = root
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();
// 反向格式转换: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()));
}
// 如果有 url 但没有 type,不添加 type(默认为 SSE
// 如果有 command 但没有 type,不添加 type(默认为 stdio
}
}
Ok(servers)
}
@@ -104,20 +96,7 @@ pub fn set_mcp_servers_map(
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");
// 移除 UI 辅助字段
obj.remove("enabled");
obj.remove("source");
obj.remove("id");
-29
View File
@@ -13,9 +13,7 @@ fn cell() -> &'static RwLock<Option<InitErrorPayload>> {
INIT_ERROR.get_or_init(|| RwLock::new(None))
}
#[allow(dead_code)]
pub fn set_init_error(payload: InitErrorPayload) {
#[allow(clippy::unwrap_used)]
if let Ok(mut guard) = cell().write() {
*guard = Some(payload);
}
@@ -25,33 +23,6 @@ pub fn get_init_error() -> Option<InitErrorPayload> {
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
}
}
#[cfg(test)]
mod tests {
use super::*;
+336 -354
View File
@@ -1,34 +1,28 @@
mod app_config;
mod app_store;
mod auto_launch;
mod claude_mcp;
mod claude_plugin;
mod codex_config;
mod commands;
mod config;
mod database;
mod deeplink;
mod error;
mod gemini_config;
mod gemini_config; // 新增
mod gemini_mcp;
mod init_status;
mod mcp;
mod prompt;
mod prompt_files;
mod provider;
mod provider_defaults;
mod proxy;
mod services;
mod settings;
mod store;
mod tray;
mod usage_script;
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
pub use commands::*;
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
pub use database::Database;
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
pub use error::AppError;
pub use mcp::{
@@ -45,14 +39,253 @@ pub use services::{
pub use settings::{update_settings, AppSettings};
pub use store::AppState;
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use std::sync::Arc;
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
use tauri::{
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
tray::{TrayIconBuilder, TrayIconEvent},
};
#[cfg(target_os = "macos")]
use tauri::RunEvent;
use tauri::{ActivationPolicy, RunEvent};
use tauri::{Emitter, Manager};
#[derive(Clone, Copy)]
struct TrayTexts {
show_main: &'static str,
no_provider_hint: &'static str,
quit: &'static str,
}
impl TrayTexts {
fn from_language(language: &str) -> Self {
match language {
"en" => Self {
show_main: "Open main window",
no_provider_hint: " (No providers yet, please add them from the main window)",
quit: "Quit",
},
_ => Self {
show_main: "打开主界面",
no_provider_hint: " (无供应商,请在主界面添加)",
quit: "退出",
},
}
}
}
struct TrayAppSection {
app_type: AppType,
prefix: &'static str,
header_id: &'static str,
empty_id: &'static str,
header_label: &'static str,
log_name: &'static str,
}
const TRAY_SECTIONS: [TrayAppSection; 3] = [
TrayAppSection {
app_type: AppType::Claude,
prefix: "claude_",
header_id: "claude_header",
empty_id: "claude_empty",
header_label: "─── Claude ───",
log_name: "Claude",
},
TrayAppSection {
app_type: AppType::Codex,
prefix: "codex_",
header_id: "codex_header",
empty_id: "codex_empty",
header_label: "─── Codex ───",
log_name: "Codex",
},
TrayAppSection {
app_type: AppType::Gemini,
prefix: "gemini_",
header_id: "gemini_header",
empty_id: "gemini_empty",
header_label: "─── Gemini ───",
log_name: "Gemini",
},
];
fn append_provider_section<'a>(
app: &'a tauri::AppHandle,
mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>,
manager: Option<&crate::provider::ProviderManager>,
section: &TrayAppSection,
tray_texts: &TrayTexts,
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
let Some(manager) = manager else {
return Ok(menu_builder);
};
let header = MenuItem::with_id(
app,
section.header_id,
section.header_label,
false,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建{}标题失败: {e}", section.log_name)))?;
menu_builder = menu_builder.item(&header);
if manager.providers.is_empty() {
let empty_hint = MenuItem::with_id(
app,
section.empty_id,
tray_texts.no_provider_hint,
false,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)))?;
return Ok(menu_builder.item(&empty_hint));
}
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
sorted_providers.sort_by(|(_, a), (_, b)| {
match (a.sort_index, b.sort_index) {
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
(Some(_), None) => return std::cmp::Ordering::Less,
(None, Some(_)) => return std::cmp::Ordering::Greater,
_ => {}
}
match (a.created_at, b.created_at) {
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
(Some(_), None) => return std::cmp::Ordering::Greater,
(None, Some(_)) => return std::cmp::Ordering::Less,
_ => {}
}
a.name.cmp(&b.name)
});
for (id, provider) in sorted_providers {
let is_current = manager.current == *id;
let item = CheckMenuItem::with_id(
app,
format!("{}{}", section.prefix, id),
&provider.name,
true,
is_current,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name)))?;
menu_builder = menu_builder.item(&item);
}
Ok(menu_builder)
}
fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
for section in TRAY_SECTIONS.iter() {
if let Some(provider_id) = event_id.strip_prefix(section.prefix) {
log::info!("切换到{}供应商: {provider_id}", section.log_name);
let app_handle = app.clone();
let provider_id = provider_id.to_string();
let app_type = section.app_type.clone();
tauri::async_runtime::spawn_blocking(move || {
if let Err(e) = switch_provider_internal(&app_handle, app_type, provider_id) {
log::error!("切换{}供应商失败: {e}", section.log_name);
}
});
return true;
}
}
false
}
/// 创建动态托盘菜单
fn create_tray_menu(
app: &tauri::AppHandle,
app_state: &AppState,
) -> Result<Menu<tauri::Wry>, AppError> {
let app_settings = crate::settings::get_settings();
let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh"));
let config = app_state.config.read().map_err(AppError::from)?;
let mut menu_builder = MenuBuilder::new(app);
// 顶部:打开主界面
let show_main_item =
MenuItem::with_id(app, "show_main", tray_texts.show_main, true, None::<&str>)
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?;
menu_builder = menu_builder.item(&show_main_item).separator();
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
for section in TRAY_SECTIONS.iter() {
menu_builder = append_provider_section(
app,
menu_builder,
config.get_manager(&section.app_type),
section,
&tray_texts,
)?;
}
// 分隔符和退出菜单
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
menu_builder = menu_builder.separator().item(&quit_item);
menu_builder
.build()
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
}
#[cfg(target_os = "macos")]
fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
let desired_policy = if dock_visible {
ActivationPolicy::Regular
} else {
ActivationPolicy::Accessory
};
if let Err(err) = app.set_dock_visibility(dock_visible) {
log::warn!("设置 Dock 显示状态失败: {err}");
}
if let Err(err) = app.set_activation_policy(desired_policy) {
log::warn!("设置激活策略失败: {err}");
}
}
/// 处理托盘菜单事件
fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
log::info!("处理托盘菜单事件: {event_id}");
match event_id {
"show_main" => {
if let Some(window) = app.get_webview_window("main") {
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(false);
}
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
#[cfg(target_os = "macos")]
{
apply_tray_policy(app, true);
}
}
}
"quit" => {
log::info!("退出应用");
app.exit(0);
}
_ => {
if handle_provider_tray_event(app, event_id) {
return;
}
log::warn!("未处理的菜单事件: {event_id}");
}
}
}
/// 统一处理 ccswitch:// 深链接 URL
///
/// - 解析 URL
@@ -73,7 +306,7 @@ fn handle_deeplink_url(
match crate::deeplink::parse_deeplink_url(url_str) {
Ok(request) => {
log::info!(
"✓ Successfully parsed deep link: resource={}, app={:?}, name={:?}",
"✓ Successfully parsed deep link: resource={}, app={}, name={}",
request.resource,
request.app,
request.name
@@ -112,13 +345,50 @@ fn handle_deeplink_url(
true
}
//
/// 内部切换供应商函数
fn switch_provider_internal(
app: &tauri::AppHandle,
app_type: crate::app_config::AppType,
provider_id: String,
) -> Result<(), AppError> {
if let Some(app_state) = app.try_state::<AppState>() {
// 在使用前先保存需要的值
let app_type_str = app_type.as_str().to_string();
let provider_id_clone = provider_id.clone();
crate::commands::switch_provider(app_state.clone(), app_type_str.clone(), provider_id)
.map_err(AppError::Message)?;
// 切换成功后重新创建托盘菜单
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
if let Err(e) = tray.set_menu(Some(new_menu)) {
log::error!("更新托盘菜单失败: {e}");
}
}
}
// 发射事件到前端,通知供应商已切换
let event_data = serde_json::json!({
"appType": app_type_str,
"providerId": provider_id_clone
});
if let Err(e) = app.emit("provider-switched", event_data) {
log::error!("发射供应商切换事件失败: {e}");
}
}
Ok(())
}
/// 更新托盘菜单的Tauri命令
#[tauri::command]
async fn update_tray_menu(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
) -> Result<bool, String> {
match tray::create_tray_menu(&app, state.inner()) {
match create_tray_menu(&app, state.inner()) {
Ok(new_menu) => {
if let Some(tray) = app.tray_by_id("main") {
tray.set_menu(Some(new_menu))
@@ -186,7 +456,7 @@ pub fn run() {
}
#[cfg(target_os = "macos")]
{
tray::apply_tray_policy(window.app_handle(), false);
apply_tray_policy(window.app_handle(), false);
}
} else {
window.app_handle().exit(0);
@@ -217,32 +487,24 @@ pub fn run() {
use objc2::runtime::AnyObject;
use objc2_app_kit::NSColor;
match window.ns_window() {
Ok(ns_window_ptr) => {
if let Some(ns_window) =
unsafe { Retained::retain(ns_window_ptr as *mut AnyObject) }
{
// 使用与主界面 banner 相同的蓝色 #3498db
// #3498db = RGB(52, 152, 219)
let bg_color = unsafe {
NSColor::colorWithRed_green_blue_alpha(
52.0 / 255.0, // R: 52
152.0 / 255.0, // G: 152
219.0 / 255.0, // B: 219
1.0, // Alpha: 1.0
)
};
let ns_window_ptr = window.ns_window().unwrap();
let ns_window: Retained<AnyObject> =
unsafe { Retained::retain(ns_window_ptr as *mut AnyObject).unwrap() };
unsafe {
use objc2::msg_send;
let _: () =
msg_send![&*ns_window, setBackgroundColor: &*bg_color];
}
} else {
log::warn!("Failed to retain NSWindow reference");
}
}
Err(e) => log::warn!("Failed to get NSWindow pointer: {e}"),
// 使用与主界面 banner 相同的蓝色 #3498db
// #3498db = RGB(52, 152, 219)
let bg_color = unsafe {
NSColor::colorWithRed_green_blue_alpha(
52.0 / 255.0, // R: 52
152.0 / 255.0, // G: 152
219.0 / 255.0, // B: 219
1.0, // Alpha: 1.0
)
};
unsafe {
use objc2::msg_send;
let _: () = msg_send![&*ns_window, setBackgroundColor: &*bg_color];
}
}
}
@@ -259,172 +521,42 @@ pub fn run() {
// 预先刷新 Store 覆盖配置,确保 AppState 初始化时可读取到最新路径
app_store::refresh_app_config_dir_override(app.handle());
// 初始化数据库
let app_config_dir = crate::config::get_app_config_dir();
let db_path = app_config_dir.join("cc-switch.db");
let json_path = app_config_dir.join("config.json");
// 检查是否需要从 config.json 迁移到 SQLite
let has_json = json_path.exists();
let has_db = db_path.exists();
// 如果需要迁移,先验证 config.json 是否可以加载(在创建数据库之前)
// 这样如果加载失败用户选择退出,数据库文件还没被创建,下次可以正常重试
let migration_config = if !has_db && has_json {
log::info!("检测到旧版配置文件,验证配置文件...");
// 循环:支持用户重试加载配置文件
loop {
match crate::app_config::MultiAppConfig::load() {
Ok(config) => {
log::info!("✓ 配置文件加载成功");
break Some(config);
}
Err(e) => {
log::error!("加载旧配置文件失败: {e}");
// 弹出系统对话框让用户选择
if !show_migration_error_dialog(app.handle(), &e.to_string()) {
// 用户选择退出(此时数据库还没创建,下次启动可以重试)
log::info!("用户选择退出程序");
std::process::exit(1);
}
// 用户选择重试,继续循环
log::info!("用户选择重试加载配置文件");
}
// 初始化应用状态(仅创建一次,并在本函数末尾注入 manage)
// 如果配置解析失败,则向前端发送错误事件并提前结束 setup(不落盘、不覆盖配置)。
let app_state = match AppState::try_new() {
Ok(state) => state,
Err(err) => {
let path = crate::config::get_app_config_path();
let payload_json = serde_json::json!({
"path": path.display().to_string(),
"error": err.to_string(),
});
// 事件通知(可能早于前端订阅,不保证送达)
if let Err(e) = app.emit("configLoadError", payload_json) {
log::error!("发射配置加载错误事件失败: {e}");
}
}
} else {
None
};
// 现在创建数据库
let db = match crate::database::Database::init() {
Ok(db) => Arc::new(db),
Err(e) => {
log::error!("Failed to init database: {e}");
return Err(Box::new(e));
// 同时缓存错误,供前端启动阶段主动拉取
crate::init_status::set_init_error(crate::init_status::InitErrorPayload {
path: path.display().to_string(),
error: err.to_string(),
});
// 不再继续构建托盘/命令依赖的状态,交由前端提示后退出。
return Ok(());
}
};
// 如果有预加载的配置,执行迁移
if let Some(config) = migration_config {
log::info!("开始执行数据迁移...");
match db.migrate_from_json(&config) {
Ok(_) => {
log::info!("✓ 配置迁移成功");
// 标记迁移成功,供前端显示 Toast
crate::init_status::set_migration_success();
// 归档旧配置文件(重命名而非删除,便于用户恢复)
let archive_path = json_path.with_extension("json.migrated");
if let Err(e) = std::fs::rename(&json_path, &archive_path) {
log::warn!("归档旧配置文件失败: {e}");
} else {
log::info!("✓ 旧配置已归档为 config.json.migrated");
}
}
Err(e) => {
// 配置加载成功但迁移失败的情况极少(磁盘满等),仅记录日志
log::error!("配置迁移失败: {e},将从现有配置导入");
}
}
}
let app_state = AppState::new(db);
// ============================================================
// 按表独立判断的导入逻辑(各类数据独立检查,互不影响)
// ============================================================
// 1. 初始化默认 Skills 仓库(已有内置检查:表非空则跳过)
match app_state.db.init_default_skill_repos() {
Ok(count) if count > 0 => {
log::info!("✓ Initialized {count} default skill repositories");
}
Ok(_) => {} // 表非空,静默跳过
Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"),
}
// 2. 导入供应商配置(已有内置检查:该应用已有供应商则跳过)
for app in [
crate::app_config::AppType::Claude,
crate::app_config::AppType::Codex,
crate::app_config::AppType::Gemini,
] {
match crate::services::provider::ProviderService::import_default_config(
&app_state,
app.clone(),
) {
Ok(true) => {
log::info!("✓ Imported default provider for {}", app.as_str());
}
Ok(false) => {} // 已有供应商,静默跳过
Err(e) => {
log::debug!(
"○ No default provider to import for {}: {}",
app.as_str(),
e
);
}
}
}
// 3. 导入 MCP 服务器配置(表空时触发)
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
log::info!("MCP table empty, importing from live configurations...");
match crate::services::mcp::McpService::import_from_claude(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} MCP server(s) from Claude");
}
Ok(_) => log::debug!("○ No Claude MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import Claude MCP: {e}"),
}
match crate::services::mcp::McpService::import_from_codex(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} MCP server(s) from Codex");
}
Ok(_) => log::debug!("○ No Codex MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import Codex MCP: {e}"),
}
match crate::services::mcp::McpService::import_from_gemini(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} MCP server(s) from Gemini");
}
Ok(_) => log::debug!("○ No Gemini MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"),
}
}
// 4. 导入提示词文件(表空时触发)
if app_state.db.is_prompts_table_empty().unwrap_or(false) {
log::info!("Prompts table empty, importing from live configurations...");
for app in [
crate::app_config::AppType::Claude,
crate::app_config::AppType::Codex,
crate::app_config::AppType::Gemini,
] {
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
&app_state,
app.clone(),
) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} prompt(s) for {}", app.as_str());
}
Ok(_) => log::debug!("○ No prompt file found for {}", app.as_str()),
Err(e) => log::warn!("✗ Failed to import prompt for {}: {e}", app.as_str()),
}
}
}
// 迁移旧的 app_config_dir 配置到 Store
if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) {
log::warn!("迁移 app_config_dir 失败: {e}");
}
// 确保配置结构就绪(已移除旧版本的副本迁移逻辑)
{
let mut config_guard = app_state.config.write().unwrap();
config_guard.ensure_app(&app_config::AppType::Claude);
config_guard.ensure_app(&app_config::AppType::Codex);
}
// 启动阶段不再无条件保存,避免意外覆盖用户配置。
// 注册 deep-link URL 处理器(使用正确的 DeepLinkExt API
@@ -433,35 +565,10 @@ pub fn run() {
// Linux 和 Windows 调试模式需要显式注册
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
{
#[cfg(target_os = "linux")]
{
// Use Tauri's path API to get correct path (includes app identifier)
// tauri-plugin-deep-link writes to: ~/.local/share/com.ccswitch.desktop/applications/cc-switch-handler.desktop
// Only register if .desktop file doesn't exist to avoid overwriting user customizations
let should_register = app
.path()
.data_dir()
.map(|d| !d.join("applications/cc-switch-handler.desktop").exists())
.unwrap_or(true);
if should_register {
if let Err(e) = app.deep_link().register_all() {
log::error!("✗ Failed to register deep link schemes: {}", e);
} else {
log::info!("✓ Deep link schemes registered (Linux)");
}
} else {
log::info!("⊘ Deep link handler already exists, skipping registration");
}
}
#[cfg(all(debug_assertions, windows))]
{
if let Err(e) = app.deep_link().register_all() {
log::error!("✗ Failed to register deep link schemes: {}", e);
} else {
log::info!("✓ Deep link schemes registered (Windows debug)");
}
if let Err(e) = app.deep_link().register_all() {
log::error!("✗ Failed to register deep link schemes: {}", e);
} else {
log::info!("✓ Deep link schemes registered (Linux/Windows)");
}
}
@@ -486,7 +593,7 @@ pub fn run() {
log::info!("✓ Deep-link URL handler registered");
// 创建动态托盘菜单
let menu = tray::create_tray_menu(app.handle(), &app_state)?;
let menu = create_tray_menu(app.handle(), &app_state)?;
// 构建托盘
let mut tray_builder = TrayIconBuilder::with_id("main")
@@ -497,16 +604,12 @@ pub fn run() {
})
.menu(&menu)
.on_menu_event(|app, event| {
tray::handle_tray_menu_event(app, &event.id.0);
handle_tray_menu_event(app, &event.id.0);
})
.show_menu_on_left_click(true);
// 统一使用应用默认图标;待托盘模板图标就绪后再启用
if let Some(icon) = app.default_window_icon() {
tray_builder = tray_builder.icon(icon.clone());
} else {
log::warn!("Failed to get default window icon for tray");
}
tray_builder = tray_builder.icon(app.default_window_icon().unwrap().clone());
let _tray = tray_builder.build(app)?;
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
@@ -522,28 +625,6 @@ pub fn run() {
}
}
// 自动启动代理服务器
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let state = app_handle.state::<AppState>();
match state.db.get_proxy_config().await {
Ok(config) => {
if config.enabled {
log::info!("代理服务配置为启用,正在启动...");
match state.proxy_service.start().await {
Ok(info) => log::info!(
"代理服务器自动启动成功: {}:{}",
info.address,
info.port
),
Err(e) => log::error!("代理服务器自动启动失败: {e}"),
}
}
}
Err(e) => log::error!("启动时获取代理配置失败: {e}"),
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -553,7 +634,6 @@ pub fn run() {
commands::update_provider,
commands::delete_provider,
commands::switch_provider,
commands::set_proxy_target_provider,
commands::import_default_config,
commands::get_claude_config_status,
commands::get_config_status,
@@ -563,7 +643,6 @@ pub fn run() {
commands::pick_directory,
commands::open_external,
commands::get_init_error,
commands::get_migration_result,
commands::get_app_config_path,
commands::open_app_config_folder,
commands::get_claude_common_config_snippet,
@@ -625,9 +704,7 @@ pub fn run() {
commands::sync_current_providers_live,
// Deep link import
commands::parse_deeplink,
commands::merge_deeplink_config,
commands::import_from_deeplink,
commands::import_from_deeplink_unified,
update_tray_menu,
// Environment variable management
commands::check_env_conflicts,
@@ -635,42 +712,11 @@ pub fn run() {
commands::restore_env_backup,
// Skill management
commands::get_skills,
commands::get_skills_for_app,
commands::install_skill,
commands::install_skill_for_app,
commands::uninstall_skill,
commands::uninstall_skill_for_app,
commands::get_skill_repos,
commands::add_skill_repo,
commands::remove_skill_repo,
// Auto launch
commands::set_auto_launch,
commands::get_auto_launch_status,
// Proxy server management
commands::start_proxy_server,
commands::stop_proxy_server,
commands::get_proxy_status,
commands::get_proxy_config,
commands::update_proxy_config,
commands::is_proxy_running,
// Usage statistics
commands::get_usage_summary,
commands::get_usage_trends,
commands::get_provider_stats,
commands::get_model_stats,
commands::get_request_logs,
commands::get_request_detail,
commands::get_model_pricing,
commands::update_model_pricing,
commands::delete_model_pricing,
commands::check_provider_limits,
// Model testing
commands::test_provider_model,
commands::test_all_providers_model,
commands::get_model_test_config,
commands::save_model_test_config,
commands::get_model_test_logs,
commands::cleanup_model_test_logs,
]);
let app = builder
@@ -691,7 +737,7 @@ pub fn run() {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
tray::apply_tray_policy(app_handle, true);
apply_tray_policy(app_handle, true);
}
}
// 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...
@@ -705,7 +751,7 @@ pub fn run() {
match crate::deeplink::parse_deeplink_url(&url_str) {
Ok(request) => {
log::info!(
"Successfully parsed deep link from RunEvent::Opened: resource={}, app={:?}",
"Successfully parsed deep link from RunEvent::Opened: resource={}, app={}",
request.resource,
request.app
);
@@ -756,67 +802,3 @@ pub fn run() {
}
});
}
// ============================================================
// 迁移错误对话框辅助函数
// ============================================================
/// 检测是否为中文环境
fn is_chinese_locale() -> bool {
std::env::var("LANG")
.or_else(|_| std::env::var("LC_ALL"))
.or_else(|_| std::env::var("LC_MESSAGES"))
.map(|lang| lang.starts_with("zh"))
.unwrap_or(false)
}
/// 显示迁移错误对话框
/// 返回 true 表示用户选择重试,false 表示用户选择退出
fn show_migration_error_dialog(app: &tauri::AppHandle, error: &str) -> bool {
let title = if is_chinese_locale() {
"配置迁移失败"
} else {
"Migration Failed"
};
let message = if is_chinese_locale() {
format!(
"从旧版本迁移配置时发生错误:\n\n{error}\n\n\
您的数据尚未丢失,旧配置文件仍然保留。\n\
建议回退到旧版本 CC Switch 以保护数据。\n\n\
点击「重试」重新尝试迁移\n\
点击「退出」关闭程序(可回退版本后重新打开)"
)
} else {
format!(
"An error occurred while migrating configuration:\n\n{error}\n\n\
Your data is NOT lost - the old config file is still preserved.\n\
Consider rolling back to an older CC Switch version.\n\n\
Click 'Retry' to attempt migration again\n\
Click 'Exit' to close the program"
)
};
let retry_text = if is_chinese_locale() {
"重试"
} else {
"Retry"
};
let exit_text = if is_chinese_locale() {
"退出"
} else {
"Exit"
};
// 使用 blocking_show 同步等待用户响应
// OkCancelCustom: 第一个按钮(重试)返回 true,第二个按钮(退出)返回 false
app.dialog()
.message(&message)
.title(title)
.kind(MessageDialogKind::Error)
.buttons(MessageDialogButtons::OkCancelCustom(
retry_text.to_string(),
exit_text.to_string(),
))
.blocking_show()
}
-10
View File
@@ -2,15 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
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();
}
File diff suppressed because it is too large Load Diff
-131
View File
@@ -1,131 +0,0 @@
//! Claude MCP 同步和导入模块
use serde_json::Value;
use std::collections::HashMap;
use crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig};
use crate::error::AppError;
use super::validation::{extract_server_spec, validate_server_spec};
/// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new();
for (id, entry) in cfg.servers.iter() {
let enabled = entry
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !enabled {
continue;
}
match extract_server_spec(entry) {
Ok(spec) => {
out.insert(id.clone(), spec);
}
Err(err) => {
log::warn!("跳过无效的 MCP 条目 '{id}': {err}");
}
}
}
out
}
/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json
pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> {
let enabled = collect_enabled_servers(&config.mcp.claude);
crate::claude_mcp::set_mcp_servers_map(&enabled)
}
/// 从 ~/.claude.json 导入 mcpServers 到统一结构(v3.7.0+
/// 已存在的服务器将启用 Claude 应用,不覆盖其他字段和应用状态
pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError> {
let text_opt = crate::claude_mcp::read_mcp_json()?;
let Some(text) = text_opt else { return Ok(0) };
let v: Value = serde_json::from_str(&text)
.map_err(|e| AppError::McpValidation(format!("解析 ~/.claude.json 失败: {e}")))?;
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
return Ok(0);
};
// 确保新结构存在
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
let mut changed = 0;
let mut errors = Vec::new();
for (id, spec) in map.iter() {
// 校验:单项失败不中止,收集错误继续处理
if let Err(e) = validate_server_spec(spec) {
log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
errors.push(format!("{id}: {e}"));
continue;
}
if let Some(existing) = servers.get_mut(id) {
// 已存在:仅启用 Claude 应用
if !existing.apps.claude {
existing.apps.claude = true;
changed += 1;
log::info!("MCP 服务器 '{id}' 已启用 Claude 应用");
}
} else {
// 新建服务器:默认仅启用 Claude
servers.insert(
id.clone(),
McpServer {
id: id.clone(),
name: id.clone(),
server: spec.clone(),
apps: McpApps {
claude: true,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
changed += 1;
log::info!("导入新 MCP 服务器 '{id}'");
}
}
if !errors.is_empty() {
log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
}
Ok(changed)
}
/// 将单个 MCP 服务器同步到 Claude live 配置
pub fn sync_single_server_to_claude(
_config: &MultiAppConfig,
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
// 读取现有的 MCP 配置
let current = crate::claude_mcp::read_mcp_servers_map()?;
// 创建新的 HashMap,包含现有的所有服务器 + 当前要同步的服务器
let mut updated = current;
updated.insert(id.to_string(), server_spec.clone());
// 写回
crate::claude_mcp::set_mcp_servers_map(&updated)
}
/// 从 Claude live 配置中移除单个 MCP 服务器
pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> {
// 读取现有的 MCP 配置
let mut current = crate::claude_mcp::read_mcp_servers_map()?;
// 移除指定服务器
current.remove(id);
// 写回
crate::claude_mcp::set_mcp_servers_map(&current)
}
-126
View File
@@ -1,126 +0,0 @@
//! Gemini MCP 同步和导入模块
use serde_json::Value;
use std::collections::HashMap;
use crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig};
use crate::error::AppError;
use super::validation::{extract_server_spec, validate_server_spec};
/// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new();
for (id, entry) in cfg.servers.iter() {
let enabled = entry
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !enabled {
continue;
}
match extract_server_spec(entry) {
Ok(spec) => {
out.insert(id.clone(), spec);
}
Err(err) => {
log::warn!("跳过无效的 MCP 条目 '{id}': {err}");
}
}
}
out
}
/// 将 config.json 中 Gemini 的 enabled==true 项写入 Gemini MCP 配置
pub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> {
let enabled = collect_enabled_servers(&config.mcp.gemini);
crate::gemini_mcp::set_mcp_servers_map(&enabled)
}
/// 从 Gemini MCP 配置导入到统一结构(v3.7.0+)
/// 已存在的服务器将启用 Gemini 应用,不覆盖其他字段和应用状态
pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError> {
let map = crate::gemini_mcp::read_mcp_servers_map()?;
if map.is_empty() {
return Ok(0);
}
// 确保新结构存在
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
let mut changed = 0;
let mut errors = Vec::new();
for (id, spec) in map.iter() {
// 校验:单项失败不中止,收集错误继续处理
if let Err(e) = validate_server_spec(spec) {
log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
errors.push(format!("{id}: {e}"));
continue;
}
if let Some(existing) = servers.get_mut(id) {
// 已存在:仅启用 Gemini 应用
if !existing.apps.gemini {
existing.apps.gemini = true;
changed += 1;
log::info!("MCP 服务器 '{id}' 已启用 Gemini 应用");
}
} else {
// 新建服务器:默认仅启用 Gemini
servers.insert(
id.clone(),
McpServer {
id: id.clone(),
name: id.clone(),
server: spec.clone(),
apps: McpApps {
claude: false,
codex: false,
gemini: true,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
changed += 1;
log::info!("导入新 MCP 服务器 '{id}'");
}
}
if !errors.is_empty() {
log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
}
Ok(changed)
}
/// 将单个 MCP 服务器同步到 Gemini live 配置
pub fn sync_single_server_to_gemini(
_config: &MultiAppConfig,
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
// 读取现有的 MCP 配置
let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
// 添加/更新当前服务器
current.insert(id.to_string(), server_spec.clone());
// 写回
crate::gemini_mcp::set_mcp_servers_map(&current)
}
/// 从 Gemini live 配置中移除单个 MCP 服务器
pub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> {
// 读取现有的 MCP 配置
let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
// 移除指定服务器
current.remove(id);
// 写回
crate::gemini_mcp::set_mcp_servers_map(&current)
}
-28
View File
@@ -1,28 +0,0 @@
//! MCP (Model Context Protocol) 服务器管理模块
//!
//! 本模块负责 MCP 服务器配置的验证、同步和导入导出。
//!
//! ## 模块结构
//!
//! - `validation` - 服务器配置验证
//! - `claude` - Claude MCP 同步和导入
//! - `codex` - Codex MCP 同步和导入(含 TOML 转换)
//! - `gemini` - Gemini MCP 同步和导入
mod claude;
mod codex;
mod gemini;
mod validation;
// 重新导出公共 API
pub use claude::{
import_from_claude, remove_server_from_claude, sync_enabled_to_claude,
sync_single_server_to_claude,
};
pub use codex::{
import_from_codex, remove_server_from_codex, sync_enabled_to_codex, sync_single_server_to_codex,
};
pub use gemini::{
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
sync_single_server_to_gemini,
};
-69
View File
@@ -1,69 +0,0 @@
//! MCP 服务器配置验证模块
use serde_json::Value;
use crate::error::AppError;
/// 基础校验:允许 stdio/http/sse;或省略 type(视为 stdio)。对应必填字段存在
pub fn validate_server_spec(spec: &Value) -> Result<(), AppError> {
if !spec.is_object() {
return Err(AppError::McpValidation(
"MCP 服务器连接定义必须为 JSON 对象".into(),
));
}
let t_opt = spec.get("type").and_then(|x| x.as_str());
// 支持三种:stdio/http/sse;若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致)
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true);
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false);
if !(is_stdio || is_http || is_sse) {
return Err(AppError::McpValidation(
"MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'(或省略表示 stdio".into(),
));
}
if is_stdio {
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
if cmd.trim().is_empty() {
return Err(AppError::McpValidation(
"stdio 类型的 MCP 服务器缺少 command 字段".into(),
));
}
}
if is_http {
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
if url.trim().is_empty() {
return Err(AppError::McpValidation(
"http 类型的 MCP 服务器缺少 url 字段".into(),
));
}
}
if is_sse {
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
if url.trim().is_empty() {
return Err(AppError::McpValidation(
"sse 类型的 MCP 服务器缺少 url 字段".into(),
));
}
}
Ok(())
}
/// 从 MCP 条目中提取服务器规范
pub fn extract_server_spec(entry: &Value) -> Result<Value, AppError> {
let obj = entry
.as_object()
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
let server = obj
.get("server")
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?;
if !server.is_object() {
return Err(AppError::McpValidation(
"MCP 服务器 server 字段必须为 JSON 对象".into(),
));
}
Ok(server.clone())
}
+2 -26
View File
@@ -1,4 +1,3 @@
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
@@ -29,17 +28,6 @@ pub struct Provider {
/// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<ProviderMeta>,
/// 图标名称(如 "openai", "anthropic"
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// 图标颜色(Hex 格式,如 "#00A67E"
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "iconColor")]
pub icon_color: Option<String>,
/// 是否为代理目标(数据库专用字段,不写入配置文件)
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "isProxyTarget")]
pub is_proxy_target: Option<bool>,
}
impl Provider {
@@ -60,9 +48,6 @@ impl Provider {
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
is_proxy_target: None,
}
}
}
@@ -70,7 +55,7 @@ impl Provider {
/// 供应商管理器
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderManager {
pub providers: IndexMap<String, Provider>,
pub providers: HashMap<String, Provider>,
pub current: String,
}
@@ -156,20 +141,11 @@ pub struct ProviderMeta {
skip_serializing_if = "Option::is_none"
)]
pub partner_promotion_key: Option<String>,
/// 成本倍数(用于计算实际成本)
#[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")]
pub cost_multiplier: Option<String>,
/// 每日消费限额(USD
#[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")]
pub limit_daily_usd: Option<String>,
/// 每月消费限额(USD
#[serde(rename = "limitMonthlyUsd", skip_serializing_if = "Option::is_none")]
pub limit_monthly_usd: Option<String>,
}
impl ProviderManager {
/// 获取所有供应商
pub fn get_all_providers(&self) -> &IndexMap<String, Provider> {
pub fn get_all_providers(&self) -> &HashMap<String, Provider> {
&self.providers
}
}
-238
View File
@@ -1,238 +0,0 @@
use once_cell::sync::Lazy;
use std::collections::HashMap;
/// 供应商图标信息
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct ProviderIcon {
pub name: &'static str,
pub color: &'static str,
}
/// 供应商名称到图标的默认映射
#[allow(dead_code)]
pub static DEFAULT_PROVIDER_ICONS: Lazy<HashMap<&'static str, ProviderIcon>> = Lazy::new(|| {
let mut m = HashMap::new();
// AI 服务商
m.insert(
"openai",
ProviderIcon {
name: "openai",
color: "#00A67E",
},
);
m.insert(
"anthropic",
ProviderIcon {
name: "anthropic",
color: "#D4915D",
},
);
m.insert(
"claude",
ProviderIcon {
name: "claude",
color: "#D4915D",
},
);
m.insert(
"google",
ProviderIcon {
name: "google",
color: "#4285F4",
},
);
m.insert(
"gemini",
ProviderIcon {
name: "gemini",
color: "#4285F4",
},
);
m.insert(
"deepseek",
ProviderIcon {
name: "deepseek",
color: "#1E88E5",
},
);
m.insert(
"kimi",
ProviderIcon {
name: "kimi",
color: "#6366F1",
},
);
m.insert(
"moonshot",
ProviderIcon {
name: "moonshot",
color: "#6366F1",
},
);
m.insert(
"zhipu",
ProviderIcon {
name: "zhipu",
color: "#0F62FE",
},
);
m.insert(
"minimax",
ProviderIcon {
name: "minimax",
color: "#FF6B6B",
},
);
m.insert(
"baidu",
ProviderIcon {
name: "baidu",
color: "#2932E1",
},
);
m.insert(
"alibaba",
ProviderIcon {
name: "alibaba",
color: "#FF6A00",
},
);
m.insert(
"tencent",
ProviderIcon {
name: "tencent",
color: "#00A4FF",
},
);
m.insert(
"meta",
ProviderIcon {
name: "meta",
color: "#0081FB",
},
);
m.insert(
"microsoft",
ProviderIcon {
name: "microsoft",
color: "#00A4EF",
},
);
m.insert(
"cohere",
ProviderIcon {
name: "cohere",
color: "#39594D",
},
);
m.insert(
"perplexity",
ProviderIcon {
name: "perplexity",
color: "#20808D",
},
);
m.insert(
"mistral",
ProviderIcon {
name: "mistral",
color: "#FF7000",
},
);
m.insert(
"huggingface",
ProviderIcon {
name: "huggingface",
color: "#FFD21E",
},
);
// 云平台
m.insert(
"aws",
ProviderIcon {
name: "aws",
color: "#FF9900",
},
);
m.insert(
"azure",
ProviderIcon {
name: "azure",
color: "#0078D4",
},
);
m.insert(
"huawei",
ProviderIcon {
name: "huawei",
color: "#FF0000",
},
);
m.insert(
"cloudflare",
ProviderIcon {
name: "cloudflare",
color: "#F38020",
},
);
m
});
/// 根据供应商名称智能推断图标
#[allow(dead_code)]
pub fn infer_provider_icon(provider_name: &str) -> Option<ProviderIcon> {
let name_lower = provider_name.to_lowercase();
// 精确匹配
if let Some(icon) = DEFAULT_PROVIDER_ICONS.get(name_lower.as_str()) {
return Some(icon.clone());
}
// 模糊匹配(包含关键词)
for (key, icon) in DEFAULT_PROVIDER_ICONS.iter() {
if name_lower.contains(key) {
return Some(icon.clone());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_exact_match() {
let icon = infer_provider_icon("openai");
assert!(icon.is_some());
let icon = icon.unwrap();
assert_eq!(icon.name, "openai");
assert_eq!(icon.color, "#00A67E");
}
#[test]
fn test_fuzzy_match() {
let icon = infer_provider_icon("OpenAI Official");
assert!(icon.is_some());
let icon = icon.unwrap();
assert_eq!(icon.name, "openai");
}
#[test]
fn test_case_insensitive() {
let icon = infer_provider_icon("ANTHROPIC");
assert!(icon.is_some());
assert_eq!(icon.unwrap().name, "anthropic");
}
#[test]
fn test_no_match() {
let icon = infer_provider_icon("unknown provider");
assert!(icon.is_none());
}
}
-183
View File
@@ -1,183 +0,0 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ProxyError {
#[error("服务器已在运行")]
AlreadyRunning,
#[error("服务器未运行")]
NotRunning,
#[error("地址绑定失败: {0}")]
BindFailed(String),
#[error("请求转发失败: {0}")]
ForwardFailed(String),
#[error("无可用的Provider")]
NoAvailableProvider,
#[allow(dead_code)]
#[error("Provider不健康: {0}")]
ProviderUnhealthy(String),
#[error("上游错误 (状态码 {status}): {body:?}")]
UpstreamError { status: u16, body: Option<String> },
#[error("超过最大重试次数")]
MaxRetriesExceeded,
#[error("数据库错误: {0}")]
DatabaseError(String),
#[error("配置错误: {0}")]
ConfigError(String),
#[allow(dead_code)]
#[error("格式转换错误: {0}")]
TransformError(String),
#[allow(dead_code)]
#[error("无效的请求: {0}")]
InvalidRequest(String),
#[error("超时: {0}")]
Timeout(String),
/// 流式响应空闲超时
#[allow(dead_code)]
#[error("流式响应空闲超时: {0}秒无数据")]
StreamIdleTimeout(u64),
/// 认证错误
#[allow(dead_code)]
#[error("认证失败: {0}")]
AuthError(String),
#[allow(dead_code)]
#[error("内部错误: {0}")]
Internal(String),
}
impl IntoResponse for ProxyError {
fn into_response(self) -> Response {
let (status, body) = match &self {
ProxyError::UpstreamError {
status: upstream_status,
body: upstream_body,
} => {
let http_status =
StatusCode::from_u16(*upstream_status).unwrap_or(StatusCode::BAD_GATEWAY);
// 尝试解析上游响应体为 JSON,如果失败则包装为字符串
let error_body = if let Some(body_str) = upstream_body {
if let Ok(json_body) = serde_json::from_str::<serde_json::Value>(body_str) {
// 上游返回的是 JSON,直接透传
json_body
} else {
// 上游返回的不是 JSON,包装为错误消息
json!({
"error": {
"message": body_str,
"type": "upstream_error",
}
})
}
} else {
json!({
"error": {
"message": format!("Upstream error (status {})", upstream_status),
"type": "upstream_error",
}
})
};
(http_status, error_body)
}
_ => {
let (http_status, message) = match &self {
ProxyError::AlreadyRunning => (StatusCode::CONFLICT, self.to_string()),
ProxyError::NotRunning => (StatusCode::SERVICE_UNAVAILABLE, self.to_string()),
ProxyError::BindFailed(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
ProxyError::ForwardFailed(_) => (StatusCode::BAD_GATEWAY, self.to_string()),
ProxyError::NoAvailableProvider => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
}
ProxyError::ProviderUnhealthy(_) => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
}
ProxyError::MaxRetriesExceeded => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
}
ProxyError::DatabaseError(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
ProxyError::ConfigError(_) => (StatusCode::BAD_REQUEST, self.to_string()),
ProxyError::TransformError(_) => {
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
}
ProxyError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
ProxyError::Timeout(_) => (StatusCode::GATEWAY_TIMEOUT, self.to_string()),
ProxyError::StreamIdleTimeout(_) => {
(StatusCode::GATEWAY_TIMEOUT, self.to_string())
}
ProxyError::AuthError(_) => (StatusCode::UNAUTHORIZED, self.to_string()),
ProxyError::Internal(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
ProxyError::UpstreamError { .. } => unreachable!(),
};
let error_body = json!({
"error": {
"message": message,
"type": "proxy_error",
}
});
(http_status, error_body)
}
};
(status, Json(body)).into_response()
}
}
/// 错误分类
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorCategory {
/// 可重试错误(网络问题、5xx)
Retryable, // 网络超时、5xx 错误
/// 不可重试错误(4xx、认证失败)
NonRetryable, // 认证失败、参数错误、4xx 错误
#[allow(dead_code)]
ClientAbort, // 客户端主动中断
}
/// 判断错误是否可重试
#[allow(dead_code)]
pub fn categorize_error(error: &reqwest::Error) -> ErrorCategory {
if error.is_timeout() || error.is_connect() {
return ErrorCategory::Retryable;
}
if let Some(status) = error.status() {
if status.is_server_error() {
ErrorCategory::Retryable
} else if status.is_client_error() {
ErrorCategory::NonRetryable
} else {
ErrorCategory::Retryable
}
} else {
ErrorCategory::Retryable
}
}
-328
View File
@@ -1,328 +0,0 @@
//! 请求转发器
//!
//! 负责将请求转发到上游Provider,支持重试和故障转移
use super::{
error::*,
providers::{get_adapter, ProviderAdapter},
router::ProviderRouter,
types::ProxyStatus,
ProxyError,
};
use crate::{app_config::AppType, database::Database, provider::Provider};
use reqwest::{Client, Response};
use serde_json::Value;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
pub struct RequestForwarder {
client: Client,
router: ProviderRouter,
max_retries: u8,
status: Arc<RwLock<ProxyStatus>>,
}
impl RequestForwarder {
pub fn new(
db: Arc<Database>,
timeout_secs: u64,
max_retries: u8,
status: Arc<RwLock<ProxyStatus>>,
) -> Self {
let mut client_builder = Client::builder();
if timeout_secs > 0 {
client_builder = client_builder.timeout(Duration::from_secs(timeout_secs));
}
let client = client_builder
.build()
.expect("Failed to create HTTP client");
Self {
client,
router: ProviderRouter::new(db),
max_retries,
status,
}
}
/// 转发请求(带重试和故障转移)
pub async fn forward_with_retry(
&self,
app_type: &AppType,
endpoint: &str,
body: Value,
headers: axum::http::HeaderMap,
) -> Result<Response, ProxyError> {
let mut failed_ids = Vec::new();
let mut failover_happened = false;
// 获取适配器
let adapter = get_adapter(app_type);
for attempt in 0..self.max_retries {
// 选择Provider
let provider = self.router.select_provider(app_type, &failed_ids).await?;
log::debug!(
"尝试 {} - 使用Provider: {} ({})",
attempt + 1,
provider.name,
provider.id
);
// 更新状态中的当前Provider信息
{
let mut status = self.status.write().await;
status.current_provider = Some(provider.name.clone());
status.current_provider_id = Some(provider.id.clone());
status.total_requests += 1;
status.last_request_at = Some(chrono::Utc::now().to_rfc3339());
if attempt > 0 {
failover_happened = true;
}
}
let start = Instant::now();
// 转发请求
match self
.forward(&provider, endpoint, &body, &headers, adapter.as_ref())
.await
{
Ok(response) => {
let _latency = start.elapsed().as_millis() as u64;
// 成功:更新健康状态
self.router
.update_health(&provider, app_type, true, None)
.await;
// 更新成功统计
{
let mut status = self.status.write().await;
status.success_requests += 1;
status.last_error = None;
if failover_happened {
status.failover_count += 1;
}
// 重新计算成功率
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
}
return Ok(response);
}
Err(e) => {
let latency = start.elapsed().as_millis() as u64;
// 失败:分类错误
let category = self.categorize_proxy_error(&e);
match category {
ErrorCategory::Retryable => {
// 可重试:更新健康状态,添加到失败列表
self.router
.update_health(&provider, app_type, false, Some(e.to_string()))
.await;
failed_ids.push(provider.id.clone());
// 更新错误信息
{
let mut status = self.status.write().await;
status.last_error =
Some(format!("Provider {} 失败: {}", provider.name, e));
}
log::warn!(
"请求失败(可重试): Provider {} - {} - {}ms",
provider.name,
e,
latency
);
continue;
}
ErrorCategory::NonRetryable | ErrorCategory::ClientAbort => {
// 不可重试:更新失败统计并返回
{
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(e.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
}
log::error!("请求失败(不可重试): {e}");
return Err(e);
}
}
}
}
}
// 所有重试都失败
{
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some("已达到最大重试次数".to_string());
if status.total_requests > 0 {
status.success_rate =
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
}
}
Err(ProxyError::MaxRetriesExceeded)
}
/// 转发单个请求(使用适配器)
async fn forward(
&self,
provider: &Provider,
endpoint: &str,
body: &Value,
headers: &axum::http::HeaderMap,
adapter: &dyn ProviderAdapter,
) -> Result<Response, ProxyError> {
// 使用适配器提取 base_url
let base_url = adapter.extract_base_url(provider)?;
log::info!("[{}] base_url: {}", adapter.name(), base_url);
// 使用适配器构建 URL
let url = adapter.build_url(&base_url, endpoint);
// 检查是否需要格式转换
let needs_transform = adapter.needs_transform(provider);
// 记录原始请求 JSON
log::info!(
"[{}] ====== 请求开始 ======\n>>> 原始请求 JSON:\n{}",
adapter.name(),
serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string())
);
// 转换请求体(如果需要)
let request_body = if needs_transform {
log::info!("[{}] 转换请求格式 (Anthropic → OpenAI)", adapter.name());
let transformed = adapter.transform_request(body.clone(), provider)?;
log::info!(
"[{}] >>> 转换后的请求 JSON:\n{}",
adapter.name(),
serde_json::to_string_pretty(&transformed).unwrap_or_default()
);
transformed
} else {
body.clone()
};
log::info!(
"[{}] 转发请求: {} -> {}",
adapter.name(),
provider.name,
url
);
// 构建请求
let mut request = self.client.post(&url);
// 只透传必要的 Headers(白名单模式)
let allowed_headers = [
"accept",
"user-agent",
"x-request-id",
"x-stainless-arch",
"x-stainless-lang",
"x-stainless-os",
"x-stainless-package-version",
"x-stainless-runtime",
"x-stainless-runtime-version",
];
for (key, value) in headers {
let key_str = key.as_str().to_lowercase();
if allowed_headers.contains(&key_str.as_str()) {
request = request.header(key, value);
}
}
// 确保 Content-Type 是 json
request = request.header("Content-Type", "application/json");
// 使用适配器添加认证头
if let Some(auth) = adapter.extract_auth(provider) {
log::debug!(
"[{}] 使用认证: {:?} (key: {})",
adapter.name(),
auth.strategy,
auth.masked_key()
);
request = adapter.add_auth_headers(request, &auth);
} else {
log::error!(
"[{}] 未找到 API KeyProvider: {}",
adapter.name(),
provider.name
);
}
// 发送请求
log::info!("[{}] 发送请求到: {}", adapter.name(), url);
let response = request.json(&request_body).send().await.map_err(|e| {
log::error!("[{}] 请求失败: {}", adapter.name(), e);
if e.is_timeout() {
ProxyError::Timeout(format!("请求超时: {e}"))
} else if e.is_connect() {
ProxyError::ForwardFailed(format!("连接失败: {e}"))
} else {
ProxyError::ForwardFailed(e.to_string())
}
})?;
// 检查响应状态
let status = response.status();
log::info!("[{}] 响应状态: {}", adapter.name(), status);
if status.is_success() {
Ok(response)
} else {
let status_code = status.as_u16();
let body_text = response.text().await.ok();
log::error!(
"[{}] 上游错误 ({}): {:?}",
adapter.name(),
status_code,
body_text
);
Err(ProxyError::UpstreamError {
status: status_code,
body: body_text,
})
}
}
/// 分类ProxyError
fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
match error {
ProxyError::Timeout(_) => ErrorCategory::Retryable,
ProxyError::ForwardFailed(_) => ErrorCategory::Retryable,
ProxyError::UpstreamError { status, .. } => {
if *status >= 500 {
ErrorCategory::Retryable
} else if *status >= 400 && *status < 500 {
ErrorCategory::NonRetryable
} else {
ErrorCategory::Retryable
}
}
ProxyError::ProviderUnhealthy(_) => ErrorCategory::Retryable,
ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable,
_ => ErrorCategory::NonRetryable,
}
}
}
File diff suppressed because it is too large Load Diff
-7
View File
@@ -1,7 +0,0 @@
//! 健康检查器
//!
//! 负责定期检查Provider健康状态(占位实现)
// 占位实现,稍后添加完整逻辑
#[allow(dead_code)]
pub struct HealthChecker;
-30
View File
@@ -1,30 +0,0 @@
//! 代理服务器模块
//!
//! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传
pub mod error;
mod forwarder;
mod handlers;
mod health;
pub mod providers;
pub mod response_handler;
mod router;
pub(crate) mod server;
pub mod session;
pub(crate) mod types;
pub mod usage;
// 公开导出给外部使用(commands, services等模块需要)
#[allow(unused_imports)]
pub use error::ProxyError;
#[allow(unused_imports)]
pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler};
#[allow(unused_imports)]
pub use session::{ClientFormat, ProxySession};
#[allow(unused_imports)]
pub use types::{ProxyConfig, ProxyServerInfo, ProxyStatus};
// 内部模块间共享(供子模块使用)
// 注意:这个导出用于模块内部,编译器可能警告未使用但实际被子模块使用
#[allow(unused_imports)]
pub(crate) use types::*;
-131
View File
@@ -1,131 +0,0 @@
//! Provider Adapter Trait
//!
//! 定义供应商适配器的统一接口,抽象不同上游供应商的处理逻辑。
use super::auth::AuthInfo;
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use reqwest::RequestBuilder;
use serde_json::Value;
/// 供应商适配器 Trait
///
/// 所有供应商适配器都需要实现此 trait,提供统一的接口来处理:
/// - URL 构建
/// - 认证信息提取和头部注入
/// - 请求/响应格式转换(可选)
///
/// # 示例
///
/// ```ignore
/// pub struct ClaudeAdapter;
///
/// impl ProviderAdapter for ClaudeAdapter {
/// fn name(&self) -> &'static str { "Claude" }
///
/// fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
/// // 从 provider 配置中提取 base_url
/// }
///
/// fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
/// // 从 provider 配置中提取认证信息
/// }
///
/// fn build_url(&self, base_url: &str, endpoint: &str) -> String {
/// format!("{}{}", base_url.trim_end_matches('/'), endpoint)
/// }
///
/// fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
/// // 添加认证头
/// }
/// }
/// ```
pub trait ProviderAdapter: Send + Sync {
/// 适配器名称(用于日志和调试)
fn name(&self) -> &'static str;
/// 从 Provider 配置中提取 base_url
///
/// # Arguments
/// * `provider` - Provider 配置
///
/// # Returns
/// * `Ok(String)` - 提取到的 base_url(已去除尾部斜杠)
/// * `Err(ProxyError)` - 提取失败
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError>;
/// 从 Provider 配置中提取认证信息
///
/// # Arguments
/// * `provider` - Provider 配置
///
/// # Returns
/// * `Some(AuthInfo)` - 提取到的认证信息
/// * `None` - 未找到认证信息
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo>;
/// 构建请求 URL
///
/// # Arguments
/// * `base_url` - 基础 URL
/// * `endpoint` - 请求端点(如 `/v1/messages`
///
/// # Returns
/// 完整的请求 URL
fn build_url(&self, base_url: &str, endpoint: &str) -> String;
/// 添加认证头到请求
///
/// # Arguments
/// * `request` - reqwest RequestBuilder
/// * `auth` - 认证信息
///
/// # Returns
/// 添加了认证头的 RequestBuilder
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder;
/// 是否需要格式转换
///
/// 默认返回 `false`(透传模式)。
/// 仅当供应商需要格式转换时(如 Claude + OpenRouter)才返回 `true`。
///
/// # Arguments
/// * `provider` - Provider 配置
fn needs_transform(&self, _provider: &Provider) -> bool {
false
}
/// 转换请求体
///
/// 将请求体从一种格式转换为另一种格式(如 Anthropic → OpenAI)。
/// 默认实现直接返回原始请求体(透传)。
///
/// # Arguments
/// * `body` - 原始请求体
/// * `provider` - Provider 配置(用于获取模型映射等)
///
/// # Returns
/// * `Ok(Value)` - 转换后的请求体
/// * `Err(ProxyError)` - 转换失败
fn transform_request(&self, body: Value, _provider: &Provider) -> Result<Value, ProxyError> {
Ok(body)
}
/// 转换响应体
///
/// 将响应体从一种格式转换为另一种格式(如 OpenAI → Anthropic)。
/// 默认实现直接返回原始响应体(透传)。
///
/// # Arguments
/// * `body` - 原始响应体
///
/// # Returns
/// * `Ok(Value)` - 转换后的响应体
/// * `Err(ProxyError)` - 转换失败
///
/// Note: 响应转换将在 handler 层集成,目前预留接口
#[allow(dead_code)]
fn transform_response(&self, body: Value) -> Result<Value, ProxyError> {
Ok(body)
}
}
-210
View File
@@ -1,210 +0,0 @@
//! Authentication Types
//!
//! 定义认证信息和认证策略,支持多种上游供应商的认证方式。
/// 认证信息
///
/// 包含 API Key 和对应的认证策略
#[derive(Debug, Clone)]
pub struct AuthInfo {
/// API Key
pub api_key: String,
/// 认证策略
pub strategy: AuthStrategy,
/// OAuth access_token(用于 GoogleOAuth 策略)
pub access_token: Option<String>,
}
impl AuthInfo {
/// 创建新的认证信息
pub fn new(api_key: String, strategy: AuthStrategy) -> Self {
Self {
api_key,
strategy,
access_token: None,
}
}
/// 创建带有 access_token 的认证信息(用于 OAuth
pub fn with_access_token(api_key: String, access_token: String) -> Self {
Self {
api_key,
strategy: AuthStrategy::GoogleOAuth,
access_token: Some(access_token),
}
}
/// 返回遮蔽后的 API Key(用于日志输出)
///
/// 显示前4位和后4位,中间用 `...` 代替
/// 如果 key 长度不足8位,则返回 `***`
pub fn masked_key(&self) -> String {
if self.api_key.len() > 8 {
format!(
"{}...{}",
&self.api_key[..4],
&self.api_key[self.api_key.len() - 4..]
)
} else {
"***".to_string()
}
}
/// 返回遮蔽后的 access_token(用于日志输出)
#[allow(dead_code)]
pub fn masked_access_token(&self) -> Option<String> {
self.access_token.as_ref().map(|token| {
if token.len() > 8 {
format!("{}...{}", &token[..4], &token[token.len() - 4..])
} else {
"***".to_string()
}
})
}
}
/// 认证策略
///
/// 不同供应商使用不同的认证方式
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthStrategy {
/// Anthropic 认证方式
/// - Header: `x-api-key: <api_key>`
/// - Header: `anthropic-version: 2023-06-01`
Anthropic,
/// Claude 中转服务认证方式(仅 Bearer,无 x-api-key
///
/// - Header: `Authorization: Bearer <api_key>`
///
/// 用于不支持 x-api-key 的中转服务
ClaudeAuth,
/// Bearer Token 认证方式(OpenAI 等)
///
/// - Header: `Authorization: Bearer <api_key>`
Bearer,
/// Google API Key 认证方式
///
/// - Header: `x-goog-api-key: <api_key>`
Google,
/// Google OAuth 认证方式
///
/// - Header: `Authorization: Bearer <access_token>`
///
/// 用于 Gemini CLI 等需要 OAuth 的场景
GoogleOAuth,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_masked_key_long() {
let auth = AuthInfo::new("sk-1234567890abcdef".to_string(), AuthStrategy::Bearer);
assert_eq!(auth.masked_key(), "sk-1...cdef");
}
#[test]
fn test_masked_key_short() {
let auth = AuthInfo::new("short".to_string(), AuthStrategy::Bearer);
assert_eq!(auth.masked_key(), "***");
}
#[test]
fn test_masked_key_exactly_8() {
let auth = AuthInfo::new("12345678".to_string(), AuthStrategy::Bearer);
assert_eq!(auth.masked_key(), "***");
}
#[test]
fn test_masked_key_9_chars() {
let auth = AuthInfo::new("123456789".to_string(), AuthStrategy::Bearer);
assert_eq!(auth.masked_key(), "1234...6789");
}
#[test]
fn test_auth_strategy_equality() {
assert_eq!(AuthStrategy::Anthropic, AuthStrategy::Anthropic);
assert_ne!(AuthStrategy::Anthropic, AuthStrategy::Bearer);
assert_ne!(AuthStrategy::Bearer, AuthStrategy::Google);
}
#[test]
fn test_auth_info_new_has_no_access_token() {
let auth = AuthInfo::new("api-key".to_string(), AuthStrategy::Bearer);
assert!(auth.access_token.is_none());
}
#[test]
fn test_auth_info_with_access_token() {
let auth = AuthInfo::with_access_token(
"refresh-token".to_string(),
"ya29.access-token-12345".to_string(),
);
assert_eq!(auth.api_key, "refresh-token");
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
assert_eq!(
auth.access_token,
Some("ya29.access-token-12345".to_string())
);
}
#[test]
fn test_masked_access_token_long() {
let auth =
AuthInfo::with_access_token("refresh".to_string(), "ya29.1234567890abcdef".to_string());
assert_eq!(auth.masked_access_token(), Some("ya29...cdef".to_string()));
}
#[test]
fn test_masked_access_token_short() {
let auth = AuthInfo::with_access_token("refresh".to_string(), "short".to_string());
assert_eq!(auth.masked_access_token(), Some("***".to_string()));
}
#[test]
fn test_masked_access_token_none() {
let auth = AuthInfo::new("api-key".to_string(), AuthStrategy::Bearer);
assert!(auth.masked_access_token().is_none());
}
#[test]
fn test_claude_auth_strategy() {
let auth = AuthInfo::new("sk-test".to_string(), AuthStrategy::ClaudeAuth);
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
assert_ne!(auth.strategy, AuthStrategy::Anthropic);
assert_ne!(auth.strategy, AuthStrategy::Bearer);
}
#[test]
fn test_google_oauth_strategy() {
let auth = AuthInfo::new("refresh-token".to_string(), AuthStrategy::GoogleOAuth);
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
assert_ne!(auth.strategy, AuthStrategy::Google);
}
#[test]
fn test_all_strategies_are_distinct() {
let strategies = [
AuthStrategy::Anthropic,
AuthStrategy::ClaudeAuth,
AuthStrategy::Bearer,
AuthStrategy::Google,
AuthStrategy::GoogleOAuth,
];
for (i, s1) in strategies.iter().enumerate() {
for (j, s2) in strategies.iter().enumerate() {
if i == j {
assert_eq!(s1, s2);
} else {
assert_ne!(s1, s2);
}
}
}
}
}
-403
View File
@@ -1,403 +0,0 @@
//! Claude (Anthropic) Provider Adapter
//!
//! 支持透传模式和 OpenRouter 兼容模式
//!
//! ## 认证模式
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
//! - **OpenRouter**: 需要 Anthropic ↔ OpenAI 格式转换
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use reqwest::RequestBuilder;
/// Claude 适配器
pub struct ClaudeAdapter;
impl ClaudeAdapter {
pub fn new() -> Self {
Self
}
/// 获取供应商类型
///
/// 根据 base_url 和 auth_mode 检测具体的供应商类型:
/// - OpenRouter: base_url 包含 openrouter.ai
/// - ClaudeAuth: auth_mode 为 bearer_only
/// - Claude: 默认 Anthropic 官方
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
// 检测 OpenRouter
if let Ok(base_url) = self.extract_base_url(provider) {
if base_url.contains("openrouter.ai") {
return ProviderType::OpenRouter;
}
}
// 检测 ClaudeAuth (仅 Bearer 认证)
if self.is_bearer_only_mode(provider) {
return ProviderType::ClaudeAuth;
}
ProviderType::Claude
}
/// 检测是否使用 OpenRouter
fn is_openrouter(&self, provider: &Provider) -> bool {
if let Ok(base_url) = self.extract_base_url(provider) {
return base_url.contains("openrouter.ai");
}
false
}
/// 检测是否为仅 Bearer 认证模式
fn is_bearer_only_mode(&self, provider: &Provider) -> bool {
// 检查 settings_config 中的 auth_mode
if let Some(auth_mode) = provider
.settings_config
.get("auth_mode")
.and_then(|v| v.as_str())
{
if auth_mode == "bearer_only" {
return true;
}
}
// 检查 env 中的 AUTH_MODE
if let Some(env) = provider.settings_config.get("env") {
if let Some(auth_mode) = env.get("AUTH_MODE").and_then(|v| v.as_str()) {
if auth_mode == "bearer_only" {
return true;
}
}
}
false
}
/// 从 Provider 配置中提取 API Key
fn extract_key(&self, provider: &Provider) -> Option<String> {
if let Some(env) = provider.settings_config.get("env") {
// Anthropic 标准 key
if let Some(key) = env
.get("ANTHROPIC_AUTH_TOKEN")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN");
return Some(key.to_string());
}
// OpenRouter key
if let Some(key) = env
.get("OPENROUTER_API_KEY")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
log::debug!("[Claude] 使用 OPENROUTER_API_KEY");
return Some(key.to_string());
}
// 备选 OpenAI key (用于 OpenRouter)
if let Some(key) = env
.get("OPENAI_API_KEY")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
log::debug!("[Claude] 使用 OPENAI_API_KEY");
return Some(key.to_string());
}
}
// 尝试直接获取
if let Some(key) = provider
.settings_config
.get("apiKey")
.or_else(|| provider.settings_config.get("api_key"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
log::debug!("[Claude] 使用 apiKey/api_key");
return Some(key.to_string());
}
log::warn!("[Claude] 未找到有效的 API Key");
None
}
}
impl Default for ClaudeAdapter {
fn default() -> Self {
Self::new()
}
}
impl ProviderAdapter for ClaudeAdapter {
fn name(&self) -> &'static str {
"Claude"
}
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
// 1. 从 env 中获取
if let Some(env) = provider.settings_config.get("env") {
if let Some(url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
return Ok(url.trim_end_matches('/').to_string());
}
}
// 2. 尝试直接获取
if let Some(url) = provider
.settings_config
.get("base_url")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
if let Some(url) = provider
.settings_config
.get("baseURL")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
if let Some(url) = provider
.settings_config
.get("apiEndpoint")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
Err(ProxyError::ConfigError(
"Claude Provider 缺少 base_url 配置".to_string(),
))
}
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
let provider_type = self.provider_type(provider);
let strategy = match provider_type {
ProviderType::OpenRouter => AuthStrategy::Bearer,
ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth,
_ => AuthStrategy::Anthropic,
};
self.extract_key(provider)
.map(|key| AuthInfo::new(key, strategy))
}
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
// OpenRouter 使用 /v1/chat/completions
if base_url.contains("openrouter.ai") {
return format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
}
// Anthropic 直连
format!(
"{}/{}",
base_url.trim_end_matches('/'),
endpoint.trim_start_matches('/')
)
}
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
match auth.strategy {
// Anthropic 官方: Authorization Bearer + x-api-key + anthropic-version
AuthStrategy::Anthropic => request
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("x-api-key", &auth.api_key)
.header("anthropic-version", "2023-06-01"),
// ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key
AuthStrategy::ClaudeAuth => {
request.header("Authorization", format!("Bearer {}", auth.api_key))
}
// OpenRouter: Bearer
AuthStrategy::Bearer => {
request.header("Authorization", format!("Bearer {}", auth.api_key))
}
_ => request,
}
}
fn needs_transform(&self, provider: &Provider) -> bool {
self.is_openrouter(provider)
}
fn transform_request(
&self,
body: serde_json::Value,
provider: &Provider,
) -> Result<serde_json::Value, ProxyError> {
super::transform::anthropic_to_openai(body, provider)
}
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
super::transform::openai_to_anthropic(body)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn create_provider(config: serde_json::Value) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Claude".to_string(),
settings_config: config,
website_url: None,
category: Some("claude".to_string()),
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
is_proxy_target: None,
}
}
#[test]
fn test_extract_base_url_from_env() {
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
}
}));
let url = adapter.extract_base_url(&provider).unwrap();
assert_eq!(url, "https://api.anthropic.com");
}
#[test]
fn test_extract_auth_anthropic() {
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test-key"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-ant-test-key");
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
}
#[test]
fn test_extract_auth_openrouter() {
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
"OPENROUTER_API_KEY": "sk-or-test-key"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-or-test-key");
assert_eq!(auth.strategy, AuthStrategy::Bearer);
}
#[test]
fn test_extract_auth_claude_auth_mode() {
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
"ANTHROPIC_AUTH_TOKEN": "sk-proxy-key"
},
"auth_mode": "bearer_only"
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-proxy-key");
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
}
#[test]
fn test_extract_auth_claude_auth_env_mode() {
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
"ANTHROPIC_AUTH_TOKEN": "sk-proxy-key",
"AUTH_MODE": "bearer_only"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-proxy-key");
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
}
#[test]
fn test_provider_type_detection() {
let adapter = ClaudeAdapter::new();
// Anthropic 官方
let anthropic = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test"
}
}));
assert_eq!(adapter.provider_type(&anthropic), ProviderType::Claude);
// OpenRouter
let openrouter = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
"OPENROUTER_API_KEY": "sk-or-test"
}
}));
assert_eq!(adapter.provider_type(&openrouter), ProviderType::OpenRouter);
// ClaudeAuth
let claude_auth = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
"ANTHROPIC_AUTH_TOKEN": "sk-test"
},
"auth_mode": "bearer_only"
}));
assert_eq!(
adapter.provider_type(&claude_auth),
ProviderType::ClaudeAuth
);
}
#[test]
fn test_build_url_anthropic() {
let adapter = ClaudeAdapter::new();
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
assert_eq!(url, "https://api.anthropic.com/v1/messages");
}
#[test]
fn test_build_url_openrouter() {
let adapter = ClaudeAdapter::new();
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
assert_eq!(url, "https://openrouter.ai/api/v1/chat/completions");
}
#[test]
fn test_needs_transform() {
let adapter = ClaudeAdapter::new();
let anthropic_provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
}
}));
assert!(!adapter.needs_transform(&anthropic_provider));
let openrouter_provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
}
}));
assert!(adapter.needs_transform(&openrouter_provider));
}
}
-265
View File
@@ -1,265 +0,0 @@
//! Codex (OpenAI) Provider Adapter
//!
//! 仅透传模式,支持直连 OpenAI API
//!
//! ## 客户端检测
//! 支持检测官方 Codex 客户端 (codex_vscode, codex_cli_rs)
use super::{AuthInfo, AuthStrategy, ProviderAdapter};
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use regex::Regex;
use reqwest::RequestBuilder;
use std::sync::LazyLock;
/// 官方 Codex 客户端 User-Agent 正则
#[allow(dead_code)]
static CODEX_CLIENT_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(codex_vscode|codex_cli_rs)/[\d.]+").unwrap());
/// Codex 适配器
pub struct CodexAdapter;
impl CodexAdapter {
pub fn new() -> Self {
Self
}
/// 检测是否为官方 Codex 客户端
///
/// 匹配 User-Agent 模式: `^(codex_vscode|codex_cli_rs)/[\d.]+`
#[allow(dead_code)]
pub fn is_official_client(user_agent: &str) -> bool {
CODEX_CLIENT_REGEX.is_match(user_agent)
}
/// 从 Provider 配置中提取 API Key
fn extract_key(&self, provider: &Provider) -> Option<String> {
// 1. 尝试从 env 中获取
if let Some(env) = provider.settings_config.get("env") {
if let Some(key) = env.get("OPENAI_API_KEY").and_then(|v| v.as_str()) {
return Some(key.to_string());
}
}
// 2. 尝试从 auth 中获取 (Codex CLI 格式)
if let Some(auth) = provider.settings_config.get("auth") {
if let Some(key) = auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) {
return Some(key.to_string());
}
}
// 3. 尝试直接获取
if let Some(key) = provider
.settings_config
.get("apiKey")
.or_else(|| provider.settings_config.get("api_key"))
.and_then(|v| v.as_str())
{
return Some(key.to_string());
}
// 4. 尝试从 config 对象中获取
if let Some(config) = provider.settings_config.get("config") {
if let Some(key) = config
.get("api_key")
.or_else(|| config.get("apiKey"))
.and_then(|v| v.as_str())
{
return Some(key.to_string());
}
}
None
}
}
impl Default for CodexAdapter {
fn default() -> Self {
Self::new()
}
}
impl ProviderAdapter for CodexAdapter {
fn name(&self) -> &'static str {
"Codex"
}
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
// 1. 尝试直接获取 base_url 字段
if let Some(url) = provider
.settings_config
.get("base_url")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
// 2. 尝试 baseURL
if let Some(url) = provider
.settings_config
.get("baseURL")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
// 3. 尝试从 config 对象中获取
if let Some(config) = provider.settings_config.get("config") {
if let Some(url) = config.get("base_url").and_then(|v| v.as_str()) {
return Ok(url.trim_end_matches('/').to_string());
}
// 尝试解析 TOML 字符串格式
if let Some(config_str) = config.as_str() {
if let Some(start) = config_str.find("base_url = \"") {
let rest = &config_str[start + 12..];
if let Some(end) = rest.find('"') {
return Ok(rest[..end].trim_end_matches('/').to_string());
}
}
if let Some(start) = config_str.find("base_url = '") {
let rest = &config_str[start + 12..];
if let Some(end) = rest.find('\'') {
return Ok(rest[..end].trim_end_matches('/').to_string());
}
}
}
}
Err(ProxyError::ConfigError(
"Codex Provider 缺少 base_url 配置".to_string(),
))
}
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
self.extract_key(provider)
.map(|key| AuthInfo::new(key, AuthStrategy::Bearer))
}
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
let base_trimmed = base_url.trim_end_matches('/');
let endpoint_trimmed = endpoint.trim_start_matches('/');
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
// 去除重复的 /v1/v1
if url.contains("/v1/v1") {
url = url.replace("/v1/v1", "/v1");
}
url
}
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
request.header("Authorization", format!("Bearer {}", auth.api_key))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn create_provider(config: serde_json::Value) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Codex".to_string(),
settings_config: config,
website_url: None,
category: Some("codex".to_string()),
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
is_proxy_target: None,
}
}
#[test]
fn test_extract_base_url_direct() {
let adapter = CodexAdapter::new();
let provider = create_provider(json!({
"base_url": "https://api.openai.com/v1"
}));
let url = adapter.extract_base_url(&provider).unwrap();
assert_eq!(url, "https://api.openai.com/v1");
}
#[test]
fn test_extract_auth_from_auth_field() {
let adapter = CodexAdapter::new();
let provider = create_provider(json!({
"auth": {
"OPENAI_API_KEY": "sk-test-key-12345678"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-test-key-12345678");
assert_eq!(auth.strategy, AuthStrategy::Bearer);
}
#[test]
fn test_extract_auth_from_env() {
let adapter = CodexAdapter::new();
let provider = create_provider(json!({
"env": {
"OPENAI_API_KEY": "sk-env-key-12345678"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-env-key-12345678");
}
#[test]
fn test_build_url() {
let adapter = CodexAdapter::new();
let url = adapter.build_url("https://api.openai.com/v1", "/responses");
assert_eq!(url, "https://api.openai.com/v1/responses");
}
#[test]
fn test_build_url_dedup_v1() {
let adapter = CodexAdapter::new();
// base_url 已包含 /v1endpoint 也包含 /v1
let url = adapter.build_url("https://www.packyapi.com/v1", "/v1/responses");
assert_eq!(url, "https://www.packyapi.com/v1/responses");
}
// 官方客户端检测测试
#[test]
fn test_is_official_client_vscode() {
assert!(CodexAdapter::is_official_client("codex_vscode/1.0.0"));
assert!(CodexAdapter::is_official_client("codex_vscode/2.3.4"));
assert!(CodexAdapter::is_official_client("codex_vscode/0.1"));
}
#[test]
fn test_is_official_client_cli() {
assert!(CodexAdapter::is_official_client("codex_cli_rs/1.0.0"));
assert!(CodexAdapter::is_official_client("codex_cli_rs/0.5.2"));
}
#[test]
fn test_is_not_official_client() {
assert!(!CodexAdapter::is_official_client("Mozilla/5.0"));
assert!(!CodexAdapter::is_official_client("curl/7.68.0"));
assert!(!CodexAdapter::is_official_client("python-requests/2.25.1"));
assert!(!CodexAdapter::is_official_client("codex_other/1.0.0"));
assert!(!CodexAdapter::is_official_client(""));
}
#[test]
fn test_is_official_client_partial_match() {
// 必须从开头匹配
assert!(!CodexAdapter::is_official_client("some codex_vscode/1.0.0"));
assert!(!CodexAdapter::is_official_client(
"prefix_codex_cli_rs/1.0.0"
));
}
}
-426
View File
@@ -1,426 +0,0 @@
//! Gemini (Google) Provider Adapter
//!
//! 支持 API Key 和 OAuth 两种认证方式
//!
//! ## 认证模式
//! - **Gemini**: API Key 认证 (x-goog-api-key)
//! - **GeminiCli**: OAuth Bearer 认证 (用于 Gemini CLI)
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use reqwest::RequestBuilder;
/// Gemini 适配器
pub struct GeminiAdapter;
/// OAuth 凭证结构
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct OAuthCredentials {
pub access_token: String,
pub refresh_token: Option<String>,
pub client_id: Option<String>,
pub client_secret: Option<String>,
}
#[allow(dead_code)]
impl OAuthCredentials {
/// 检查是否需要刷新 token(有 refresh_token 但没有有效的 access_token
pub fn needs_refresh(&self) -> bool {
self.refresh_token.is_some() && self.access_token.is_empty()
}
/// 检查是否可以刷新 token
pub fn can_refresh(&self) -> bool {
self.refresh_token.is_some() && self.client_id.is_some() && self.client_secret.is_some()
}
}
impl GeminiAdapter {
pub fn new() -> Self {
Self
}
/// 获取供应商类型
///
/// 根据 API Key 格式检测:
/// - GeminiCli: access_token (ya29. 开头) 或 JSON 格式凭证
/// - Gemini: 普通 API Key
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
if let Some(key) = self.extract_key_raw(provider) {
// OAuth access_token 以 ya29. 开头
if key.starts_with("ya29.") {
return ProviderType::GeminiCli;
}
// JSON 格式的 OAuth 凭证
if key.starts_with('{') {
return ProviderType::GeminiCli;
}
}
ProviderType::Gemini
}
/// 检测认证类型
pub fn detect_auth_type(&self, provider: &Provider) -> AuthStrategy {
match self.provider_type(provider) {
ProviderType::GeminiCli => AuthStrategy::GoogleOAuth,
_ => AuthStrategy::Google,
}
}
/// 解析 OAuth 凭证
pub fn parse_oauth_credentials(&self, key: &str) -> Option<OAuthCredentials> {
// 直接是 access_token
if key.starts_with("ya29.") {
return Some(OAuthCredentials {
access_token: key.to_string(),
refresh_token: None,
client_id: None,
client_secret: None,
});
}
// JSON 格式
if key.starts_with('{') {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(key) {
let access_token = json
.get("access_token")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default();
let refresh_token = json
.get("refresh_token")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let client_id = json
.get("client_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let client_secret = json
.get("client_secret")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// 如果有 access_token 或 refresh_token,返回凭证
if !access_token.is_empty() || refresh_token.is_some() {
return Some(OAuthCredentials {
access_token,
refresh_token,
client_id,
client_secret,
});
}
}
}
None
}
/// 从 Provider 配置中提取原始 API Key
fn extract_key_raw(&self, provider: &Provider) -> Option<String> {
if let Some(env) = provider.settings_config.get("env") {
// 优先使用 GOOGLE_GEMINI_API_KEY
if let Some(key) = env.get("GOOGLE_GEMINI_API_KEY").and_then(|v| v.as_str()) {
return Some(key.to_string());
}
// 备选 GEMINI_API_KEY
if let Some(key) = env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
return Some(key.to_string());
}
}
// 尝试直接获取
if let Some(key) = provider
.settings_config
.get("apiKey")
.or_else(|| provider.settings_config.get("api_key"))
.and_then(|v| v.as_str())
{
return Some(key.to_string());
}
None
}
}
impl Default for GeminiAdapter {
fn default() -> Self {
Self::new()
}
}
impl ProviderAdapter for GeminiAdapter {
fn name(&self) -> &'static str {
"Gemini"
}
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
// 从 env 中获取
if let Some(env) = provider.settings_config.get("env") {
if let Some(url) = env.get("GOOGLE_GEMINI_BASE_URL").and_then(|v| v.as_str()) {
return Ok(url.trim_end_matches('/').to_string());
}
}
// 尝试直接获取
if let Some(url) = provider
.settings_config
.get("base_url")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
if let Some(url) = provider
.settings_config
.get("baseURL")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
Err(ProxyError::ConfigError(
"Gemini Provider 缺少 base_url 配置".to_string(),
))
}
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
let key = self.extract_key_raw(provider)?;
let strategy = self.detect_auth_type(provider);
match strategy {
AuthStrategy::GoogleOAuth => {
// 解析 OAuth 凭证
if let Some(creds) = self.parse_oauth_credentials(&key) {
Some(AuthInfo::with_access_token(key, creds.access_token))
} else {
// 回退到普通 API Key
Some(AuthInfo::new(key, AuthStrategy::Google))
}
}
_ => Some(AuthInfo::new(key, AuthStrategy::Google)),
}
}
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
let base_trimmed = base_url.trim_end_matches('/');
let endpoint_trimmed = endpoint.trim_start_matches('/');
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
// 处理 /v1beta 路径去重
let version_patterns = ["/v1beta", "/v1"];
for pattern in &version_patterns {
let duplicate = format!("{pattern}{pattern}");
if url.contains(&duplicate) {
url = url.replace(&duplicate, pattern);
}
}
url
}
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
match auth.strategy {
// OAuth Bearer 认证
AuthStrategy::GoogleOAuth => {
let token = auth.access_token.as_ref().unwrap_or(&auth.api_key);
request
.header("Authorization", format!("Bearer {token}"))
.header("x-goog-api-client", "GeminiCLI/1.0")
}
// API Key 认证
_ => request.header("x-goog-api-key", &auth.api_key),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn create_provider(config: serde_json::Value) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Gemini".to_string(),
settings_config: config,
website_url: None,
category: Some("gemini".to_string()),
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
is_proxy_target: None,
}
}
#[test]
fn test_extract_base_url_from_env() {
let adapter = GeminiAdapter::new();
let provider = create_provider(json!({
"env": {
"GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com/v1beta"
}
}));
let url = adapter.extract_base_url(&provider).unwrap();
assert_eq!(url, "https://generativelanguage.googleapis.com/v1beta");
}
#[test]
fn test_extract_auth_api_key() {
let adapter = GeminiAdapter::new();
let provider = create_provider(json!({
"env": {
"GOOGLE_GEMINI_API_KEY": "AIza-test-key-12345678"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "AIza-test-key-12345678");
assert_eq!(auth.strategy, AuthStrategy::Google);
assert!(auth.access_token.is_none());
}
#[test]
fn test_extract_auth_oauth_access_token() {
let adapter = GeminiAdapter::new();
let provider = create_provider(json!({
"env": {
"GOOGLE_GEMINI_API_KEY": "ya29.test-access-token-12345"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
assert_eq!(
auth.access_token,
Some("ya29.test-access-token-12345".to_string())
);
}
#[test]
fn test_extract_auth_oauth_json() {
let adapter = GeminiAdapter::new();
let provider = create_provider(json!({
"env": {
"GOOGLE_GEMINI_API_KEY": "{\"access_token\":\"ya29.test-token\",\"refresh_token\":\"1//refresh\"}"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
assert_eq!(auth.access_token, Some("ya29.test-token".to_string()));
}
#[test]
fn test_provider_type_detection() {
let adapter = GeminiAdapter::new();
// API Key
let api_key_provider = create_provider(json!({
"env": {
"GOOGLE_GEMINI_API_KEY": "AIza-test-key"
}
}));
assert_eq!(
adapter.provider_type(&api_key_provider),
ProviderType::Gemini
);
// OAuth access_token
let oauth_provider = create_provider(json!({
"env": {
"GOOGLE_GEMINI_API_KEY": "ya29.test-token"
}
}));
assert_eq!(
adapter.provider_type(&oauth_provider),
ProviderType::GeminiCli
);
// OAuth JSON
let oauth_json_provider = create_provider(json!({
"env": {
"GOOGLE_GEMINI_API_KEY": "{\"access_token\":\"ya29.test\"}"
}
}));
assert_eq!(
adapter.provider_type(&oauth_json_provider),
ProviderType::GeminiCli
);
}
#[test]
fn test_extract_auth_fallback() {
let adapter = GeminiAdapter::new();
let provider = create_provider(json!({
"env": {
"GEMINI_API_KEY": "AIza-fallback-key"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "AIza-fallback-key");
}
#[test]
fn test_build_url_dedup() {
let adapter = GeminiAdapter::new();
// 模拟 base_url 已包含 /v1betaendpoint 也包含 /v1beta
let url = adapter.build_url(
"https://generativelanguage.googleapis.com/v1beta",
"/v1beta/models/gemini-pro:generateContent",
);
assert_eq!(
url,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent"
);
}
#[test]
fn test_build_url_normal() {
let adapter = GeminiAdapter::new();
let url = adapter.build_url(
"https://generativelanguage.googleapis.com/v1beta",
"/models/gemini-pro:generateContent",
);
assert_eq!(
url,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent"
);
}
#[test]
fn test_parse_oauth_credentials_direct_token() {
let adapter = GeminiAdapter::new();
let creds = adapter
.parse_oauth_credentials("ya29.test-access-token")
.unwrap();
assert_eq!(creds.access_token, "ya29.test-access-token");
assert!(creds.refresh_token.is_none());
}
#[test]
fn test_parse_oauth_credentials_json() {
let adapter = GeminiAdapter::new();
let creds = adapter
.parse_oauth_credentials(
"{\"access_token\":\"ya29.test\",\"refresh_token\":\"1//refresh\"}",
)
.unwrap();
assert_eq!(creds.access_token, "ya29.test");
assert_eq!(creds.refresh_token, Some("1//refresh".to_string()));
}
#[test]
fn test_parse_oauth_credentials_invalid() {
let adapter = GeminiAdapter::new();
assert!(adapter.parse_oauth_credentials("AIza-api-key").is_none());
assert!(adapter.parse_oauth_credentials("invalid-json{").is_none());
}
}
-424
View File
@@ -1,424 +0,0 @@
//! Provider Adapters Module
//!
//! 供应商适配器模块,提供统一的接口抽象不同上游供应商的处理逻辑。
//!
//! ## 模块结构
//! - `adapter`: 定义 `ProviderAdapter` trait
//! - `auth`: 认证类型和策略
//! - `claude`: Claude (Anthropic) 适配器
//! - `codex`: Codex (OpenAI) 适配器
//! - `gemini`: Gemini (Google) 适配器
//! - `models`: API 数据模型
//! - `transform`: 格式转换
mod adapter;
mod auth;
mod claude;
mod codex;
mod gemini;
pub mod models;
pub mod streaming;
pub mod transform;
use crate::app_config::AppType;
use crate::provider::Provider;
use serde::{Deserialize, Serialize};
// 公开导出
pub use adapter::ProviderAdapter;
pub use auth::{AuthInfo, AuthStrategy};
pub use claude::ClaudeAdapter;
pub use codex::CodexAdapter;
pub use gemini::GeminiAdapter;
/// 供应商类型枚举
///
/// 区分不同供应商的具体实现方式,决定认证和请求处理逻辑。
/// 比 AppType 更细粒度,支持同一 AppType 下的多种变体。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderType {
/// Anthropic 官方 API (x-api-key + anthropic-version)
Claude,
/// Claude 中转服务 (仅 Bearer 认证,无 x-api-key)
ClaudeAuth,
/// OpenAI Codex Response API
Codex,
/// Google Gemini API (x-goog-api-key)
Gemini,
/// Google Gemini CLI (OAuth Bearer)
GeminiCli,
/// OpenRouter (需要 Anthropic ↔ OpenAI 格式转换)
OpenRouter,
}
impl ProviderType {
/// 是否需要格式转换
///
/// OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式
#[allow(dead_code)]
pub fn needs_transform(&self) -> bool {
matches!(self, ProviderType::OpenRouter)
}
/// 获取默认端点
#[allow(dead_code)]
pub fn default_endpoint(&self) -> &'static str {
match self {
ProviderType::Claude | ProviderType::ClaudeAuth => "https://api.anthropic.com",
ProviderType::Codex => "https://api.openai.com",
ProviderType::Gemini | ProviderType::GeminiCli => {
"https://generativelanguage.googleapis.com"
}
ProviderType::OpenRouter => "https://openrouter.ai/api",
}
}
/// 从 AppType 和 Provider 配置推断供应商类型
///
/// 根据配置中的 base_url、auth_mode、api_key 格式等信息推断具体的供应商类型
#[allow(dead_code)]
pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self {
match app_type {
AppType::Claude => {
// 检测是否为 OpenRouter
let adapter = ClaudeAdapter::new();
if let Ok(base_url) = adapter.extract_base_url(provider) {
if base_url.contains("openrouter.ai") {
return ProviderType::OpenRouter;
}
}
// 检测是否为中转服务(仅 Bearer 认证)
// 注意:ProviderMeta 没有直接的 auth_mode 字段,
// 我们通过检查 settings_config 中的配置来判断
// 检查 settings_config 中的 auth_mode
if let Some(auth_mode) = provider
.settings_config
.get("auth_mode")
.and_then(|v| v.as_str())
{
if auth_mode == "bearer_only" {
return ProviderType::ClaudeAuth;
}
}
// 检查 env 中的 auth_mode
if let Some(env) = provider.settings_config.get("env") {
if let Some(auth_mode) = env.get("AUTH_MODE").and_then(|v| v.as_str()) {
if auth_mode == "bearer_only" {
return ProviderType::ClaudeAuth;
}
}
}
ProviderType::Claude
}
AppType::Codex => ProviderType::Codex,
AppType::Gemini => {
// 检测是否为 CLI 模式(OAuth
let adapter = GeminiAdapter::new();
if let Some(auth) = adapter.extract_auth(provider) {
let key = &auth.api_key;
// OAuth access_token 以 ya29. 开头
if key.starts_with("ya29.") {
return ProviderType::GeminiCli;
}
// JSON 格式的 OAuth 凭证
if key.starts_with('{') {
return ProviderType::GeminiCli;
}
}
ProviderType::Gemini
}
}
}
/// 转换为字符串表示
pub fn as_str(&self) -> &'static str {
match self {
ProviderType::Claude => "claude",
ProviderType::ClaudeAuth => "claude_auth",
ProviderType::Codex => "codex",
ProviderType::Gemini => "gemini",
ProviderType::GeminiCli => "gemini_cli",
ProviderType::OpenRouter => "openrouter",
}
}
}
impl std::fmt::Display for ProviderType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl std::str::FromStr for ProviderType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"claude" => Ok(ProviderType::Claude),
"claude_auth" | "claude-auth" => Ok(ProviderType::ClaudeAuth),
"codex" => Ok(ProviderType::Codex),
"gemini" => Ok(ProviderType::Gemini),
"gemini_cli" | "gemini-cli" => Ok(ProviderType::GeminiCli),
"openrouter" => Ok(ProviderType::OpenRouter),
_ => Err(format!("Invalid provider type: {s}")),
}
}
}
/// 根据 AppType 获取对应的适配器
pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
match app_type {
AppType::Claude => Box::new(ClaudeAdapter::new()),
AppType::Codex => Box::new(CodexAdapter::new()),
AppType::Gemini => Box::new(GeminiAdapter::new()),
}
}
/// 根据 ProviderType 获取对应的适配器
#[allow(dead_code)]
pub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box<dyn ProviderAdapter> {
match provider_type {
ProviderType::Claude | ProviderType::ClaudeAuth | ProviderType::OpenRouter => {
Box::new(ClaudeAdapter::new())
}
ProviderType::Codex => Box::new(CodexAdapter::new()),
ProviderType::Gemini | ProviderType::GeminiCli => Box::new(GeminiAdapter::new()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn create_provider(config: serde_json::Value) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Provider".to_string(),
settings_config: config,
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
is_proxy_target: None,
}
}
#[test]
fn test_provider_type_needs_transform() {
assert!(!ProviderType::Claude.needs_transform());
assert!(!ProviderType::ClaudeAuth.needs_transform());
assert!(!ProviderType::Codex.needs_transform());
assert!(!ProviderType::Gemini.needs_transform());
assert!(!ProviderType::GeminiCli.needs_transform());
assert!(ProviderType::OpenRouter.needs_transform());
}
#[test]
fn test_provider_type_default_endpoint() {
assert_eq!(
ProviderType::Claude.default_endpoint(),
"https://api.anthropic.com"
);
assert_eq!(
ProviderType::ClaudeAuth.default_endpoint(),
"https://api.anthropic.com"
);
assert_eq!(
ProviderType::Codex.default_endpoint(),
"https://api.openai.com"
);
assert_eq!(
ProviderType::Gemini.default_endpoint(),
"https://generativelanguage.googleapis.com"
);
assert_eq!(
ProviderType::GeminiCli.default_endpoint(),
"https://generativelanguage.googleapis.com"
);
assert_eq!(
ProviderType::OpenRouter.default_endpoint(),
"https://openrouter.ai/api"
);
}
#[test]
fn test_provider_type_from_str() {
assert_eq!(
"claude".parse::<ProviderType>().unwrap(),
ProviderType::Claude
);
assert_eq!(
"claude_auth".parse::<ProviderType>().unwrap(),
ProviderType::ClaudeAuth
);
assert_eq!(
"claude-auth".parse::<ProviderType>().unwrap(),
ProviderType::ClaudeAuth
);
assert_eq!(
"codex".parse::<ProviderType>().unwrap(),
ProviderType::Codex
);
assert_eq!(
"gemini".parse::<ProviderType>().unwrap(),
ProviderType::Gemini
);
assert_eq!(
"gemini_cli".parse::<ProviderType>().unwrap(),
ProviderType::GeminiCli
);
assert_eq!(
"gemini-cli".parse::<ProviderType>().unwrap(),
ProviderType::GeminiCli
);
assert_eq!(
"openrouter".parse::<ProviderType>().unwrap(),
ProviderType::OpenRouter
);
assert!("invalid".parse::<ProviderType>().is_err());
}
#[test]
fn test_provider_type_as_str() {
assert_eq!(ProviderType::Claude.as_str(), "claude");
assert_eq!(ProviderType::ClaudeAuth.as_str(), "claude_auth");
assert_eq!(ProviderType::Codex.as_str(), "codex");
assert_eq!(ProviderType::Gemini.as_str(), "gemini");
assert_eq!(ProviderType::GeminiCli.as_str(), "gemini_cli");
assert_eq!(ProviderType::OpenRouter.as_str(), "openrouter");
}
#[test]
fn test_provider_type_serde() {
// Test serialization
let claude = ProviderType::Claude;
let serialized = serde_json::to_string(&claude).unwrap();
assert_eq!(serialized, "\"claude\"");
let claude_auth = ProviderType::ClaudeAuth;
let serialized = serde_json::to_string(&claude_auth).unwrap();
assert_eq!(serialized, "\"claude_auth\"");
// Test deserialization
let deserialized: ProviderType = serde_json::from_str("\"claude\"").unwrap();
assert_eq!(deserialized, ProviderType::Claude);
let deserialized: ProviderType = serde_json::from_str("\"gemini_cli\"").unwrap();
assert_eq!(deserialized, ProviderType::GeminiCli);
}
#[test]
fn test_from_app_type_claude_direct() {
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
assert_eq!(provider_type, ProviderType::Claude);
}
#[test]
fn test_from_app_type_claude_openrouter() {
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
"OPENROUTER_API_KEY": "sk-or-test"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
assert_eq!(provider_type, ProviderType::OpenRouter);
}
#[test]
fn test_from_app_type_claude_auth() {
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
"ANTHROPIC_AUTH_TOKEN": "sk-test"
},
"auth_mode": "bearer_only"
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
assert_eq!(provider_type, ProviderType::ClaudeAuth);
}
#[test]
fn test_from_app_type_codex() {
let provider = create_provider(json!({
"env": {
"OPENAI_API_KEY": "sk-test"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Codex, &provider);
assert_eq!(provider_type, ProviderType::Codex);
}
#[test]
fn test_from_app_type_gemini_api_key() {
let provider = create_provider(json!({
"env": {
"GOOGLE_GEMINI_API_KEY": "AIza-test-key"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
assert_eq!(provider_type, ProviderType::Gemini);
}
#[test]
fn test_from_app_type_gemini_cli_oauth() {
let provider = create_provider(json!({
"env": {
"GOOGLE_GEMINI_API_KEY": "ya29.test-access-token"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
assert_eq!(provider_type, ProviderType::GeminiCli);
}
#[test]
fn test_from_app_type_gemini_cli_json() {
let provider = create_provider(json!({
"env": {
"GOOGLE_GEMINI_API_KEY": "{\"access_token\":\"ya29.test\",\"refresh_token\":\"1//test\"}"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
assert_eq!(provider_type, ProviderType::GeminiCli);
}
#[test]
fn test_get_adapter_for_provider_type() {
let adapter = get_adapter_for_provider_type(&ProviderType::Claude);
assert_eq!(adapter.name(), "Claude");
let adapter = get_adapter_for_provider_type(&ProviderType::ClaudeAuth);
assert_eq!(adapter.name(), "Claude");
let adapter = get_adapter_for_provider_type(&ProviderType::OpenRouter);
assert_eq!(adapter.name(), "Claude");
let adapter = get_adapter_for_provider_type(&ProviderType::Codex);
assert_eq!(adapter.name(), "Codex");
let adapter = get_adapter_for_provider_type(&ProviderType::Gemini);
assert_eq!(adapter.name(), "Gemini");
let adapter = get_adapter_for_provider_type(&ProviderType::GeminiCli);
assert_eq!(adapter.name(), "Gemini");
}
}
@@ -1,104 +0,0 @@
//! Anthropic API 数据模型
//!
//! 用于 Anthropic Messages API 的请求/响应格式转换
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Anthropic 请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnthropicRequest {
pub model: String,
pub messages: Vec<AnthropicMessage>,
pub max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<Value>, // 可以是 String 或 Vec<SystemBlock>
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<AnthropicTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<Value>,
}
/// Anthropic 消息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnthropicMessage {
pub role: String,
pub content: Value, // String 或 Vec<ContentBlock>
}
/// Anthropic 内容块
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum AnthropicContentBlock {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "image")]
Image { source: ImageSource },
#[serde(rename = "tool_use")]
ToolUse {
id: String,
name: String,
input: Value,
},
#[serde(rename = "tool_result")]
ToolResult { tool_use_id: String, content: Value },
}
/// 图片来源
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageSource {
#[serde(rename = "type")]
pub source_type: String,
pub media_type: String,
pub data: String,
}
/// Anthropic 工具定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnthropicTool {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub input_schema: Value,
}
/// Anthropic 响应
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnthropicResponse {
pub id: String,
#[serde(rename = "type")]
pub response_type: String,
pub role: String,
pub content: Vec<AnthropicResponseContent>,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop_sequence: Option<String>,
pub usage: AnthropicUsage,
}
/// Anthropic 响应内容
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum AnthropicResponseContent {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "tool_use")]
ToolUse {
id: String,
name: String,
input: Value,
},
}
/// Anthropic 使用量
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnthropicUsage {
pub input_tokens: u32,
pub output_tokens: u32,
}
@@ -1,6 +0,0 @@
//! API 数据模型
//!
//! 定义 Anthropic 和 OpenAI API 的请求/响应结构
pub mod anthropic;
pub mod openai;
@@ -1,113 +0,0 @@
//! OpenAI API 数据模型
//!
//! 用于 OpenAI Chat Completions API 的请求/响应格式转换
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// OpenAI 请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIRequest {
pub model: String,
pub messages: Vec<OpenAIMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<OpenAITool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<Value>,
}
/// OpenAI 消息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIMessage {
pub role: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<Value>, // String 或 Vec<ContentPart>
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<OpenAIToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
/// OpenAI 内容部分
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum OpenAIContentPart {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "image_url")]
ImageUrl { image_url: ImageUrl },
}
/// 图片 URL
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageUrl {
pub url: String,
}
/// OpenAI 工具调用
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIToolCall {
pub id: String,
#[serde(rename = "type")]
pub call_type: String,
pub function: OpenAIFunction,
}
/// OpenAI 函数
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIFunction {
pub name: String,
pub arguments: String, // JSON 字符串
}
/// OpenAI 工具定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAITool {
#[serde(rename = "type")]
pub tool_type: String,
pub function: OpenAIFunctionDef,
}
/// OpenAI 函数定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIFunctionDef {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub parameters: Value,
}
/// OpenAI 响应
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIResponse {
pub id: String,
pub object: String,
pub created: u64,
pub model: String,
pub choices: Vec<OpenAIChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<OpenAIUsage>,
}
/// OpenAI 选择
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIChoice {
pub index: u32,
pub message: OpenAIMessage,
#[serde(skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<String>,
}
/// OpenAI 使用量
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIUsage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
}
-338
View File
@@ -1,338 +0,0 @@
//! 流式响应转换模块
//!
//! 实现 OpenAI SSE → Anthropic SSE 格式转换
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use serde::{Deserialize, Serialize};
use serde_json::json;
/// OpenAI 流式响应数据结构
#[derive(Debug, Deserialize)]
struct OpenAIStreamChunk {
id: String,
model: String,
choices: Vec<StreamChoice>,
#[serde(default)]
usage: Option<Usage>,
}
#[derive(Debug, Deserialize)]
struct StreamChoice {
delta: Delta,
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Delta {
#[serde(default)]
content: Option<String>,
#[serde(default)]
reasoning: Option<String>, // OpenRouter 的推理内容
#[serde(default)]
tool_calls: Option<Vec<DeltaToolCall>>,
}
#[derive(Debug, Deserialize, Serialize)]
struct DeltaToolCall {
index: usize,
#[serde(default)]
id: Option<String>,
#[serde(rename = "type", default)]
call_type: Option<String>,
#[serde(default)]
function: Option<DeltaFunction>,
}
#[derive(Debug, Deserialize, Serialize)]
struct DeltaFunction {
#[serde(default)]
name: Option<String>,
#[serde(default)]
arguments: Option<String>,
}
/// OpenAI 流式响应的 usage 信息(完整版)
#[derive(Debug, Deserialize)]
struct Usage {
#[serde(default)]
prompt_tokens: u32,
#[serde(default)]
completion_tokens: u32,
}
/// 创建 Anthropic SSE 流
pub fn create_anthropic_sse_stream(
stream: impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
async_stream::stream! {
let mut buffer = String::new();
let mut message_id = None;
let mut current_model = None;
let mut content_index = 0;
let mut has_sent_message_start = false;
let mut current_block_type: Option<String> = None;
let mut tool_call_id = None;
log::info!("[Claude/OpenRouter] ====== 开始流式响应转换 ======");
tokio::pin!(stream);
while let Some(chunk) = stream.next().await {
match chunk {
Ok(bytes) => {
let text = String::from_utf8_lossy(&bytes);
buffer.push_str(&text);
while let Some(pos) = buffer.find("\n\n") {
let line = buffer[..pos].to_string();
buffer = buffer[pos + 2..].to_string();
if line.trim().is_empty() {
continue;
}
for l in line.lines() {
if let Some(data) = l.strip_prefix("data: ") {
if data.trim() == "[DONE]" {
log::info!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]");
let event = json!({"type": "message_stop"});
let sse_data = format!("event: message_stop\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
log::info!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop");
yield Ok(Bytes::from(sse_data));
continue;
}
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
// 记录原始 OpenAI 事件(格式化显示)
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(data) {
log::info!(
"[Claude/OpenRouter] <<< OpenAI SSE 事件:\n{}",
serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string())
);
} else {
log::info!("[Claude/OpenRouter] <<< OpenAI SSE 数据: {data}");
}
if message_id.is_none() {
message_id = Some(chunk.id.clone());
}
if current_model.is_none() {
current_model = Some(chunk.model.clone());
}
if let Some(choice) = chunk.choices.first() {
if !has_sent_message_start {
let event = json!({
"type": "message_start",
"message": {
"id": message_id.clone().unwrap_or_default(),
"type": "message",
"role": "assistant",
"model": current_model.clone().unwrap_or_default(),
"usage": {
"input_tokens": 0,
"output_tokens": 0
}
}
});
let sse_data = format!("event: message_start\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
has_sent_message_start = true;
}
// 处理 reasoningthinking
if let Some(reasoning) = &choice.delta.reasoning {
if current_block_type.is_none() {
let event = json!({
"type": "content_block_start",
"index": content_index,
"content_block": {
"type": "thinking",
"thinking": ""
}
});
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
current_block_type = Some("thinking".to_string());
}
let event = json!({
"type": "content_block_delta",
"index": content_index,
"delta": {
"type": "thinking_delta",
"thinking": reasoning
}
});
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
}
// 处理文本内容
if let Some(content) = &choice.delta.content {
if !content.is_empty() {
if current_block_type.as_deref() != Some("text") {
if current_block_type.is_some() {
let event = json!({
"type": "content_block_stop",
"index": content_index
});
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
content_index += 1;
}
let event = json!({
"type": "content_block_start",
"index": content_index,
"content_block": {
"type": "text",
"text": ""
}
});
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
current_block_type = Some("text".to_string());
}
let event = json!({
"type": "content_block_delta",
"index": content_index,
"delta": {
"type": "text_delta",
"text": content
}
});
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
}
}
// 处理工具调用
if let Some(tool_calls) = &choice.delta.tool_calls {
for tool_call in tool_calls {
if let Some(id) = &tool_call.id {
if current_block_type.is_some() {
let event = json!({
"type": "content_block_stop",
"index": content_index
});
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
content_index += 1;
}
tool_call_id = Some(id.clone());
}
if let Some(function) = &tool_call.function {
if let Some(name) = &function.name {
let event = json!({
"type": "content_block_start",
"index": content_index,
"content_block": {
"type": "tool_use",
"id": tool_call_id.clone().unwrap_or_default(),
"name": name
}
});
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
current_block_type = Some("tool_use".to_string());
}
if let Some(args) = &function.arguments {
let event = json!({
"type": "content_block_delta",
"index": content_index,
"delta": {
"type": "input_json_delta",
"partial_json": args
}
});
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
}
}
}
}
// 处理 finish_reason
if let Some(finish_reason) = &choice.finish_reason {
if current_block_type.is_some() {
let event = json!({
"type": "content_block_stop",
"index": content_index
});
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
}
let stop_reason = map_stop_reason(Some(finish_reason));
// 构建 usage 信息,包含 input_tokens 和 output_tokens
let usage_json = chunk.usage.as_ref().map(|u| json!({
"input_tokens": u.prompt_tokens,
"output_tokens": u.completion_tokens
}));
let event = json!({
"type": "message_delta",
"delta": {
"stop_reason": stop_reason,
"stop_sequence": null
},
"usage": usage_json
});
let sse_data = format!("event: message_delta\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
}
}
}
}
}
}
}
Err(e) => {
log::error!("Stream error: {e}");
let error_event = json!({
"type": "error",
"error": {
"type": "stream_error",
"message": format!("Stream error: {e}")
}
});
let sse_data = format!("event: error\ndata: {}\n\n",
serde_json::to_string(&error_event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
break;
}
}
}
}
}
/// 映射停止原因
fn map_stop_reason(finish_reason: Option<&str>) -> Option<String> {
finish_reason.map(|r| {
match r {
"tool_calls" => "tool_use",
"stop" => "end_turn",
"length" => "max_tokens",
_ => "end_turn",
}
.to_string()
})
}
-640
View File
@@ -1,640 +0,0 @@
//! 格式转换模块
//!
//! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持
//! 参考: anthropic-proxy-rs
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use serde_json::{json, Value};
/// 从 Provider 配置中获取模型映射
fn get_model_from_provider(model: &str, provider: &Provider, body: &Value) -> String {
let env = provider.settings_config.get("env");
let model_lower = model.to_lowercase();
// 检测 thinking 参数
let has_thinking = body
.get("thinking")
.and_then(|v| v.as_object())
.and_then(|o| o.get("type"))
.and_then(|t| t.as_str())
== Some("enabled");
if let Some(env) = env {
// 如果启用 thinking,优先使用推理模型
if has_thinking {
if let Some(m) = env
.get("ANTHROPIC_REASONING_MODEL")
.and_then(|v| v.as_str())
{
log::debug!("[Transform] 使用推理模型: {m}");
return m.to_string();
}
}
// 根据模型类型选择配置模型
if model_lower.contains("haiku") {
if let Some(m) = env
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
.and_then(|v| v.as_str())
{
return m.to_string();
}
}
if model_lower.contains("opus") {
if let Some(m) = env
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
.and_then(|v| v.as_str())
{
return m.to_string();
}
}
if model_lower.contains("sonnet") {
if let Some(m) = env
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
.and_then(|v| v.as_str())
{
return m.to_string();
}
}
// 默认使用 ANTHROPIC_MODEL
if let Some(m) = env.get("ANTHROPIC_MODEL").and_then(|v| v.as_str()) {
return m.to_string();
}
}
model.to_string()
}
/// Anthropic 请求 → OpenAI 请求
pub fn anthropic_to_openai(body: Value, provider: &Provider) -> Result<Value, ProxyError> {
let mut result = json!({});
// 模型映射:使用 Provider 配置中的模型(支持 thinking 参数)
if let Some(model) = body.get("model").and_then(|m| m.as_str()) {
let mapped_model = get_model_from_provider(model, provider, &body);
result["model"] = json!(mapped_model);
}
let mut messages = Vec::new();
// 处理 system prompt
if let Some(system) = body.get("system") {
if let Some(text) = system.as_str() {
// 单个字符串
messages.push(json!({"role": "system", "content": text}));
} else if let Some(arr) = system.as_array() {
// 多个 system message
for msg in arr {
if let Some(text) = msg.get("text").and_then(|t| t.as_str()) {
messages.push(json!({"role": "system", "content": text}));
}
}
}
}
// 转换 messages
if let Some(msgs) = body.get("messages").and_then(|m| m.as_array()) {
for msg in msgs {
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
let content = msg.get("content");
let converted = convert_message_to_openai(role, content)?;
messages.extend(converted);
}
}
result["messages"] = json!(messages);
// 转换参数
if let Some(v) = body.get("max_tokens") {
result["max_tokens"] = v.clone();
}
if let Some(v) = body.get("temperature") {
result["temperature"] = v.clone();
}
if let Some(v) = body.get("top_p") {
result["top_p"] = v.clone();
}
if let Some(v) = body.get("stop_sequences") {
result["stop"] = v.clone();
}
if let Some(v) = body.get("stream") {
result["stream"] = v.clone();
}
// 转换 tools (过滤 BatchTool)
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
let openai_tools: Vec<Value> = tools
.iter()
.filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool"))
.map(|t| {
json!({
"type": "function",
"function": {
"name": t.get("name").and_then(|n| n.as_str()).unwrap_or(""),
"description": t.get("description"),
"parameters": clean_schema(t.get("input_schema").cloned().unwrap_or(json!({})))
}
})
})
.collect();
if !openai_tools.is_empty() {
result["tools"] = json!(openai_tools);
}
}
if let Some(v) = body.get("tool_choice") {
result["tool_choice"] = v.clone();
}
Ok(result)
}
/// 转换单条消息到 OpenAI 格式(可能产生多条消息)
fn convert_message_to_openai(
role: &str,
content: Option<&Value>,
) -> Result<Vec<Value>, ProxyError> {
let mut result = Vec::new();
let content = match content {
Some(c) => c,
None => {
result.push(json!({"role": role, "content": null}));
return Ok(result);
}
};
// 字符串内容
if let Some(text) = content.as_str() {
result.push(json!({"role": role, "content": text}));
return Ok(result);
}
// 数组内容(多模态/工具调用)
if let Some(blocks) = content.as_array() {
let mut content_parts = Vec::new();
let mut tool_calls = Vec::new();
for block in blocks {
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
match block_type {
"text" => {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
content_parts.push(json!({"type": "text", "text": text}));
}
}
"image" => {
if let Some(source) = block.get("source") {
let media_type = source
.get("media_type")
.and_then(|m| m.as_str())
.unwrap_or("image/png");
let data = source.get("data").and_then(|d| d.as_str()).unwrap_or("");
content_parts.push(json!({
"type": "image_url",
"image_url": {"url": format!("data:{};base64,{}", media_type, data)}
}));
}
}
"tool_use" => {
let id = block.get("id").and_then(|i| i.as_str()).unwrap_or("");
let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("");
let input = block.get("input").cloned().unwrap_or(json!({}));
tool_calls.push(json!({
"id": id,
"type": "function",
"function": {
"name": name,
"arguments": serde_json::to_string(&input).unwrap_or_default()
}
}));
}
"tool_result" => {
// tool_result 变成单独的 tool role 消息
let tool_use_id = block
.get("tool_use_id")
.and_then(|i| i.as_str())
.unwrap_or("");
let content_val = block.get("content");
let content_str = match content_val {
Some(Value::String(s)) => s.clone(),
Some(v) => serde_json::to_string(v).unwrap_or_default(),
None => String::new(),
};
result.push(json!({
"role": "tool",
"tool_call_id": tool_use_id,
"content": content_str
}));
}
"thinking" => {
// 跳过 thinking blocks
}
_ => {}
}
}
// 添加带内容和/或工具调用的消息
if !content_parts.is_empty() || !tool_calls.is_empty() {
let mut msg = json!({"role": role});
// 内容处理
if content_parts.is_empty() {
msg["content"] = Value::Null;
} else if content_parts.len() == 1 {
if let Some(text) = content_parts[0].get("text") {
msg["content"] = text.clone();
} else {
msg["content"] = json!(content_parts);
}
} else {
msg["content"] = json!(content_parts);
}
// 工具调用
if !tool_calls.is_empty() {
msg["tool_calls"] = json!(tool_calls);
}
result.push(msg);
}
return Ok(result);
}
// 其他情况直接透传
result.push(json!({"role": role, "content": content}));
Ok(result)
}
/// 清理 JSON schema(移除不支持的 format
fn clean_schema(mut schema: Value) -> Value {
if let Some(obj) = schema.as_object_mut() {
// 移除 "format": "uri"
if obj.get("format").and_then(|v| v.as_str()) == Some("uri") {
obj.remove("format");
}
// 递归清理嵌套 schema
if let Some(properties) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) {
for (_, value) in properties.iter_mut() {
*value = clean_schema(value.clone());
}
}
if let Some(items) = obj.get_mut("items") {
*items = clean_schema(items.clone());
}
}
schema
}
/// OpenAI 响应 → Anthropic 响应
pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
let choices = body
.get("choices")
.and_then(|c| c.as_array())
.ok_or_else(|| ProxyError::TransformError("No choices in response".to_string()))?;
let choice = choices
.first()
.ok_or_else(|| ProxyError::TransformError("Empty choices array".to_string()))?;
let message = choice
.get("message")
.ok_or_else(|| ProxyError::TransformError("No message in choice".to_string()))?;
let mut content = Vec::new();
// 文本内容
if let Some(text) = message.get("content").and_then(|c| c.as_str()) {
if !text.is_empty() {
content.push(json!({"type": "text", "text": text}));
}
}
// 工具调用
if let Some(tool_calls) = message.get("tool_calls").and_then(|t| t.as_array()) {
for tc in tool_calls {
let id = tc.get("id").and_then(|i| i.as_str()).unwrap_or("");
let empty_obj = json!({});
let func = tc.get("function").unwrap_or(&empty_obj);
let name = func.get("name").and_then(|n| n.as_str()).unwrap_or("");
let args_str = func
.get("arguments")
.and_then(|a| a.as_str())
.unwrap_or("{}");
let input: Value = serde_json::from_str(args_str).unwrap_or(json!({}));
content.push(json!({
"type": "tool_use",
"id": id,
"name": name,
"input": input
}));
}
}
// 映射 finish_reason → stop_reason
let stop_reason = choice
.get("finish_reason")
.and_then(|r| r.as_str())
.map(|r| match r {
"stop" => "end_turn",
"length" => "max_tokens",
"tool_calls" => "tool_use",
other => other,
});
// usage
let usage = body.get("usage").cloned().unwrap_or(json!({}));
let input_tokens = usage
.get("prompt_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let output_tokens = usage
.get("completion_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let result = json!({
"id": body.get("id").and_then(|i| i.as_str()).unwrap_or(""),
"type": "message",
"role": "assistant",
"content": content,
"model": body.get("model").and_then(|m| m.as_str()).unwrap_or(""),
"stop_reason": stop_reason,
"stop_sequence": null,
"usage": {
"input_tokens": input_tokens,
"output_tokens": output_tokens
}
});
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
fn create_provider(env_config: Value) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Provider".to_string(),
settings_config: json!({"env": env_config}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
is_proxy_target: None,
}
}
fn create_openrouter_provider() -> Provider {
create_provider(json!({
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
"ANTHROPIC_MODEL": "anthropic/claude-sonnet-4.5",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "anthropic/claude-haiku-4.5",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "anthropic/claude-sonnet-4.5",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "anthropic/claude-opus-4.5"
}))
}
#[test]
fn test_anthropic_to_openai_simple() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
// opus 模型映射到配置的 ANTHROPIC_DEFAULT_OPUS_MODEL
assert_eq!(result["model"], "anthropic/claude-opus-4.5");
assert_eq!(result["max_tokens"], 1024);
assert_eq!(result["messages"][0]["role"], "user");
assert_eq!(result["messages"][0]["content"], "Hello");
}
#[test]
fn test_anthropic_to_openai_with_system() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
"system": "You are a helpful assistant.",
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
assert_eq!(result["messages"][0]["role"], "system");
assert_eq!(
result["messages"][0]["content"],
"You are a helpful assistant."
);
assert_eq!(result["messages"][1]["role"], "user");
}
#[test]
fn test_anthropic_to_openai_with_tools() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "What's the weather?"}],
"tools": [{
"name": "get_weather",
"description": "Get weather info",
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
assert_eq!(result["tools"][0]["type"], "function");
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
}
#[test]
fn test_anthropic_to_openai_tool_use() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{
"role": "assistant",
"content": [
{"type": "text", "text": "Let me check"},
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
]
}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
let msg = &result["messages"][0];
assert_eq!(msg["role"], "assistant");
assert!(msg.get("tool_calls").is_some());
assert_eq!(msg["tool_calls"][0]["id"], "call_123");
}
#[test]
fn test_anthropic_to_openai_tool_result() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "call_123", "content": "Sunny, 25°C"}
]
}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
let msg = &result["messages"][0];
assert_eq!(msg["role"], "tool");
assert_eq!(msg["tool_call_id"], "call_123");
assert_eq!(msg["content"], "Sunny, 25°C");
}
#[test]
fn test_openai_to_anthropic_simple() {
let input = json!({
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "Hello!"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["id"], "chatcmpl-123");
assert_eq!(result["type"], "message");
assert_eq!(result["content"][0]["type"], "text");
assert_eq!(result["content"][0]["text"], "Hello!");
assert_eq!(result["stop_reason"], "end_turn");
assert_eq!(result["usage"]["input_tokens"], 10);
assert_eq!(result["usage"]["output_tokens"], 5);
}
#[test]
fn test_openai_to_anthropic_with_tool_calls() {
let input = json!({
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_123",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"location\": \"Tokyo\"}"}
}]
},
"finish_reason": "tool_calls"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["content"][0]["type"], "tool_use");
assert_eq!(result["content"][0]["id"], "call_123");
assert_eq!(result["content"][0]["name"], "get_weather");
assert_eq!(result["content"][0]["input"]["location"], "Tokyo");
assert_eq!(result["stop_reason"], "tool_use");
}
#[test]
fn test_model_mapping_from_provider() {
let provider = create_openrouter_provider();
let body = json!({"model": "test"});
// sonnet 模型
assert_eq!(
get_model_from_provider("claude-sonnet-4-5-20250929", &provider, &body),
"anthropic/claude-sonnet-4.5"
);
// haiku 模型
assert_eq!(
get_model_from_provider("claude-haiku-4-5-20250929", &provider, &body),
"anthropic/claude-haiku-4.5"
);
// opus 模型
assert_eq!(
get_model_from_provider("claude-opus-4-5", &provider, &body),
"anthropic/claude-opus-4.5"
);
}
#[test]
fn test_anthropic_to_openai_model_mapping() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-sonnet-4-5-20250929",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
}
#[test]
fn test_thinking_parameter_detection() {
let mut provider = create_openrouter_provider();
// 添加推理模型配置
if let Some(env) = provider.settings_config.get_mut("env") {
env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended");
}
let input = json!({
"model": "claude-sonnet-4-5",
"max_tokens": 1024,
"thinking": {"type": "enabled"},
"messages": [{"role": "user", "content": "Solve this problem"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
// 应该使用推理模型
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5:extended");
}
#[test]
fn test_thinking_parameter_disabled() {
let mut provider = create_openrouter_provider();
if let Some(env) = provider.settings_config.get_mut("env") {
env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended");
}
let input = json!({
"model": "claude-sonnet-4-5",
"max_tokens": 1024,
"thinking": {"type": "disabled"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
// 应该使用普通模型
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
}
}
-214
View File
@@ -1,214 +0,0 @@
//! Response Handler - 统一响应处理
//!
//! 提供流式和非流式响应的统一处理接口
use super::session::ProxySession;
use super::usage::parser::TokenUsage;
use super::ProxyError;
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use serde_json::Value;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use tokio::time::timeout;
/// 响应类型
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum ResponseType {
/// 流式响应 (SSE)
Stream,
/// 非流式响应
NonStream,
}
impl ResponseType {
/// 从 Content-Type 检测响应类型
#[allow(dead_code)]
pub fn from_content_type(content_type: &str) -> Self {
if content_type.contains("text/event-stream") {
ResponseType::Stream
} else {
ResponseType::NonStream
}
}
}
/// 流式响应处理器
#[allow(dead_code)]
pub struct StreamHandler {
/// 空闲超时时间
idle_timeout: Duration,
/// 收集的事件
events: Arc<Mutex<Vec<Value>>>,
}
#[allow(dead_code)]
impl StreamHandler {
/// 创建新的流式处理器
pub fn new(idle_timeout_secs: u64) -> Self {
Self {
idle_timeout: Duration::from_secs(idle_timeout_secs),
events: Arc::new(Mutex::new(Vec::new())),
}
}
/// 处理流式响应,返回分流后的客户端流
///
/// 客户端流立即返回,内部流在后台收集事件
pub fn handle_stream<S>(
&self,
stream: S,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send
where
S: Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
{
let events = self.events.clone();
let idle_timeout = self.idle_timeout;
async_stream::stream! {
let mut _last_activity = Instant::now();
let mut buffer = String::new();
tokio::pin!(stream);
loop {
let chunk_result = timeout(idle_timeout, stream.next()).await;
match chunk_result {
Ok(Some(Ok(bytes))) => {
_last_activity = Instant::now();
// 解析 SSE 事件
let text = String::from_utf8_lossy(&bytes);
buffer.push_str(&text);
// 提取完整事件
while let Some(pos) = buffer.find("\n\n") {
let event_text = buffer[..pos].to_string();
buffer = buffer[pos + 2..].to_string();
for line in event_text.lines() {
if let Some(data) = line.strip_prefix("data: ") {
if data.trim() != "[DONE]" {
if let Ok(json) = serde_json::from_str::<Value>(data) {
let mut guard = events.lock().await;
guard.push(json);
}
}
}
}
}
yield Ok(bytes);
}
Ok(Some(Err(e))) => {
log::error!("流错误: {e}");
yield Err(std::io::Error::other(e.to_string()));
break;
}
Ok(None) => {
// 流结束
break;
}
Err(_) => {
// 空闲超时
log::warn!("流式响应空闲超时: {idle_timeout:?} 无数据");
yield Err(std::io::Error::other("Stream idle timeout"));
break;
}
}
}
}
}
/// 获取收集的事件
pub async fn get_events(&self) -> Vec<Value> {
let guard = self.events.lock().await;
guard.clone()
}
/// 从收集的事件中提取 Token 使用量
pub async fn extract_usage(&self, session: &ProxySession) -> Option<TokenUsage> {
let events = self.get_events().await;
match session.client_format {
super::session::ClientFormat::Claude => TokenUsage::from_claude_stream_events(&events),
super::session::ClientFormat::Codex => TokenUsage::from_codex_stream_events(&events),
super::session::ClientFormat::Gemini | super::session::ClientFormat::GeminiCli => {
TokenUsage::from_gemini_stream_chunks(&events)
}
_ => None,
}
}
}
/// 非流式响应处理器
#[allow(dead_code)]
pub struct NonStreamHandler;
#[allow(dead_code)]
impl NonStreamHandler {
/// 处理非流式响应
///
/// 克隆响应体用于后台解析,原始响应立即返回
pub async fn handle_response(
body: &[u8],
session: &ProxySession,
) -> Result<Option<TokenUsage>, ProxyError> {
let json: Value = serde_json::from_slice(body)
.map_err(|e| ProxyError::TransformError(format!("Failed to parse response: {e}")))?;
let usage = match session.client_format {
super::session::ClientFormat::Claude => TokenUsage::from_claude_response(&json),
super::session::ClientFormat::Codex => TokenUsage::from_codex_response_adjusted(&json),
super::session::ClientFormat::Gemini | super::session::ClientFormat::GeminiCli => {
TokenUsage::from_gemini_response(&json)
}
super::session::ClientFormat::OpenAI => TokenUsage::from_openrouter_response(&json),
_ => None,
};
Ok(usage)
}
}
/// 统一响应分发器
#[allow(dead_code)]
pub struct ResponseDispatcher;
#[allow(dead_code)]
impl ResponseDispatcher {
/// 判断响应类型
pub fn detect_type(content_type: &str) -> ResponseType {
ResponseType::from_content_type(content_type)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_response_type_detection() {
assert_eq!(
ResponseType::from_content_type("text/event-stream"),
ResponseType::Stream
);
assert_eq!(
ResponseType::from_content_type("text/event-stream; charset=utf-8"),
ResponseType::Stream
);
assert_eq!(
ResponseType::from_content_type("application/json"),
ResponseType::NonStream
);
}
#[test]
fn test_stream_handler_creation() {
let handler = StreamHandler::new(30);
assert_eq!(handler.idle_timeout, Duration::from_secs(30));
}
}
-69
View File
@@ -1,69 +0,0 @@
//! Provider路由器
//!
//! 负责选择合适的Provider进行请求转发
use super::ProxyError;
use crate::{app_config::AppType, database::Database, provider::Provider};
use std::sync::Arc;
pub struct ProviderRouter {
db: Arc<Database>,
}
impl ProviderRouter {
pub fn new(db: Arc<Database>) -> Self {
Self { db }
}
/// 选择Provider(只使用标记为代理目标的 Provider)
pub async fn select_provider(
&self,
app_type: &AppType,
_failed_ids: &[String],
) -> Result<Provider, ProxyError> {
// 1. 获取 Proxy Target Provider ID
let proxy_target_id = self
.db
.get_proxy_target_provider(app_type.as_str())
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
let target_id = proxy_target_id.ok_or_else(|| {
log::warn!("[{}] 未设置代理目标 Provider", app_type.as_str());
ProxyError::NoAvailableProvider
})?;
// 2. 获取所有 Provider
let providers = self
.db
.get_all_providers(app_type.as_str())
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
// 3. 找到目标 Provider
let target = providers.get(&target_id).ok_or_else(|| {
log::warn!(
"[{}] 代理目标 Provider 不存在: {}",
app_type.as_str(),
target_id
);
ProxyError::NoAvailableProvider
})?;
log::info!(
"[{}] 使用代理目标 Provider: {}",
app_type.as_str(),
target.name
);
Ok(target.clone())
}
/// 更新Provider健康状态(保留接口但不影响选择)
pub async fn update_health(
&self,
_provider: &Provider,
_app_type: &AppType,
_success: bool,
_error_msg: Option<String>,
) {
// 不再记录健康状态
}
}

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