Compare commits

...

47 Commits

Author SHA1 Message Date
Jason c06dd53dca docs(changelog): add macOS tray template icon entry 2025-12-24 15:16:48 +08:00
Jason caade44cf9 feat(tray): use macOS tray template icon
Cherry-picked from main (f047960)
2025-12-24 14:41:28 +08:00
Jason c5f8ca1df9 chore(release): prepare v3.8.3
- Bump version to 3.8.3 in package.json, Cargo.toml, tauri.conf.json
- Add CHANGELOG entry for v3.8.3 with new features, fixes and changes
2025-12-24 11:33:05 +08:00
Jason 4cdaed56e6 feat(presets): add AiGoCode icon and partner promotion
- Add AiGoCode colored icon (blue body #5B7FFF, purple star #7C6AEF)
- Add original SVG source file for reference
- Add icon metadata with keywords and default color
- Add iconColor to AiGoCode presets for Claude, Codex, and Gemini
- Add partner promotion messages in zh/en/ja locales
2025-12-24 11:06:48 +08:00
Jason 0de1c1356b chore: update GLM partner banner images 2025-12-23 23:25:55 +08:00
Jason 4afa9b07e8 chore(presets): update model versions for provider presets
Claude presets:
- Zhipu GLM: glm-4.6 → glm-4.7
- Z.ai GLM: glm-4.6 → glm-4.7
- ModelScope: GLM-4.6 → GLM-4.7
- MiniMax: MiniMax-M2 → MiniMax-M2.1

Codex presets:
- Azure, AiHubMix, DMXAPI, PackyCode: gpt-5.1-codex → gpt-5.2
- Custom template: gpt-5-codex → gpt-5.2
2025-12-23 22:48:38 +08:00
Jason 21ceb819b9 feat(settings): add option to skip Claude Code first-run confirmation
Add a new setting to automatically skip Claude Code's onboarding screen
by writing hasCompletedOnboarding=true to ~/.claude.json. The setting
defaults to enabled for better user experience.

Cherry-picked from main (ddbff07) with conflict resolution.
2025-12-23 22:39:57 +08:00
Jason 9ea7952246 fix(ui): add padding-top to prevent content being covered by fixed header
Add CONTENT_TOP_OFFSET constant (92px = 28px drag bar + 64px header)
and apply it as paddingTop to the root container, ensuring provider
cards and other content are not obscured by the fixed-position header
and env warning banner.
2025-12-23 22:36:36 +08:00
Jason 77cce44490 chore: update aigocode partner logo 2025-12-23 19:27:28 +08:00
Jason 7846e4b1e4 update readme 2025-12-23 19:27:22 +08:00
Jason ac20a7b13c fix(mcp): skip sync when target CLI app is not installed
Add guard functions to check if Claude/Codex/Gemini CLI has been
initialized before attempting to sync MCP configurations. This prevents
creating unwanted config files in directories that don't exist.

- Claude: check ~/.claude dir OR ~/.claude.json file exists
- Codex: check ~/.codex dir exists
- Gemini: check ~/.gemini dir exists

When the target app is not installed, sync operations now silently
succeed without writing any files, allowing users to manage MCP servers
for apps they actually use without side effects on others.
2025-12-23 19:18:57 +08:00
TinsFox 3330ef5132 fix: azure website link (#407) 2025-12-23 19:18:15 +08:00
Jason 04a43fba7d chore(i18n): remove restart prompt from provider switch notification
Claude Code now supports hot reload, so users no longer need to restart
the terminal after switching providers. Also removes unused `restartClaude`
i18n string.
2025-12-23 19:18:09 +08:00
Jason f2e6ffd2d6 feat(providers): add DMXAPI as official partner
Mark DMXAPI as partner in both Claude and Codex presets with promotion
message for their Claude Code exclusive model 66% OFF offer.
2025-12-23 19:13:44 +08:00
Jason 84a4cd8358 feat(icons): add provider icons for OpenRouter, LongCat, ModelScope, AiHubMix
- Add SVG icons for OpenRouter, LongCat, ModelScope, and AiHubMix
- Register icons in index.ts and metadata.ts with search keywords
- Link icons to corresponding provider presets in claudeProviderPresets.ts
2025-12-23 19:13:37 +08:00
Jason 0ac66e8e37 fix(ui): reduce header spacing and fix layout shift on view switch
- Change right-side button container from min-h-[40px] to h-[32px]
  for more compact header layout
- Remove conditional padding (pt-6/pt-4) from main content area
  to eliminate layout jump during view transitions
2025-12-23 19:06:32 +08:00
Jason bd488f8f26 fix(ui): prevent header layout shift when switching views
Add min-height to right-side button container and ml-auto to add buttons
in MCP/Prompts views to maintain consistent header height and button
position across all views.
2025-12-23 19:05:50 +08:00
Jason 81423001be fix(ui): improve text visibility in dark mode
Replace hardcoded gray color classes with semantic color classes
to fix poor text contrast in dark mode:

- MCP panel: server names, descriptions, tags, app labels
- Prompt panel: prompt names, descriptions, empty states
- Usage footer: timestamps, refresh buttons
- Update badge: close button icon
- API key input: disabled state text
- Env warning banner: source info text

Changes:
- `text-gray-400 dark:text-gray-500` → `text-muted-foreground`
  (fixes reversed dark mode logic)
- `text-gray-500 dark:text-gray-400` → `text-muted-foreground`
- `bg-gray-100 dark:bg-gray-800` → `bg-muted`
2025-12-23 19:05:42 +08:00
Jason dffaf4071d fix(import): refresh all providers immediately after SQL import
- Remove setTimeout delay that could be cancelled on component unmount
- Invalidate all providers cache (not just current app) since import affects all apps
- Call onImportSuccess before sync to ensure UI refresh even if sync fails
- Update i18n: "Data refreshed" (past tense, reflecting immediate action)
2025-12-23 19:05:01 +08:00
Jason 8ce6edfe94 fix(backup): restrict SQL import to CC Switch exported backups only
- Add validation to reject SQL files without CC Switch export header
- Remove redundant sanitize_import_sql (sqlite_* objects already excluded at export time)
- Fix backup filename collision by appending counter suffix
- Update i18n hints to clarify import restriction
2025-12-23 19:04:56 +08:00
YoVinchen de168b860e fix(skill): use directory basename for skill installation path (#358)
Extract last segment from skill directory path to prevent nested directory
issues during install/uninstall operations. For example, "skills/codex" now
correctly installs to "codex" instead of creating nested "skills/codex" path.
2025-12-23 18:48:36 +08:00
Jason 3230b0e094 chore: bump version to 3.8.2 2025-12-01 22:41:43 +08:00
Jason 2347ac0ee0 fix(config): update DeepSeek default model from Exp to stable version 2025-12-01 22:37:01 +08:00
Jason 58c5468bf6 fix(ui): prevent provider card hover scale from being clipped
Move 1px of horizontal padding from outer container to inner scroll
container, providing buffer space for the hover scale-[1.01] effect
without being cut off by overflow-hidden.
2025-12-01 16:03:20 +08:00
Jason 98084d61aa fix(ui): add independent scroll containers to fix scroll wheel on Linux
Providers page was using DndContext which may interfere with scroll wheel
events on Linux/Ubuntu WebKitGTK. Added independent scroll containers
with `overflow-y-auto` to all main pages, matching the pattern already
used by the MCP panel which works correctly.

Changes:
- App.tsx: Wrap ProviderList in independent scroll container
- SkillsPage: Use consistent h-[calc(100vh-8rem)] layout
- SettingsPage: Add overflow-hidden and overflow-x-hidden for consistency
2025-12-01 11:28:01 +08:00
Jason a627e1bb50 fix(config): update Codex default model and correct MiniMax URL
- Update Codex preset model from gpt-5-codex to gpt-5.1-codex
- Fix MiniMax English preset URL from minimaxi.io to minimax.io
2025-12-01 11:08:53 +08:00
Jason 04a588694b chore: bump version to 3.8.1 2025-11-30 23:39:05 +08:00
Jason f5f7dfed8c style: apply code formatting fixes
- Fix Prettier formatting in claudeProviderPresets.ts
- Fix cargo fmt trailing blank line in provider/mod.rs
2025-11-30 23:30:27 +08:00
Jason 5888c56f2a fix(config): correct MiniMax apiKeyUrl to minimaxi.com
Update apiKeyUrl domain to match official website URL.
Also update screenshots for all locales (en, ja, zh).
2025-11-30 23:03:09 +08:00
Jason c5cadb73bc fix(config): correct MiniMax apiKeyUrl domain from minimax.io to minimax.com 2025-11-29 23:03:19 +08:00
Jason 17948ee031 docs: update screenshots and add Japanese locale images
- Update README_JA.md to reference Japanese screenshots
- Add Japanese screenshots (main-ja.png, add-ja.png)
- Update English and Chinese screenshots with latest UI
- Remove outdated README_i18n.md documentation
2025-11-29 22:14:11 +08:00
Jason 2643595012 fix(skills): target first span only when hiding select checkmark indicator 2025-11-29 21:36:16 +08:00
Jason 6ac4d1652c fix(skills): use theme-aware glass-card class for light mode compatibility
Replace hardcoded dark mode styles (bg-gray-900/40, border-white/10) with
the unified glass-card class that adapts to both light and dark themes.
2025-11-29 21:18:51 +08:00
Jason 3bf37cf0ff refactor(url): remove automatic trailing slash stripping from base URL inputs
- Remove `.replace(/\/+$/, "")` from all base URL handlers in useBaseUrlState.ts
- Remove trailing slash stripping in useCodexConfigState.ts
- Remove trailing slash normalization in providerConfigUtils.ts setCodexBaseUrl()
- Update i18n hints (en/ja/zh) to instruct users to avoid trailing slashes

This gives users explicit control over URL format rather than silently modifying input.
2025-11-29 20:52:29 +08:00
Jason 526c7406fa fix(ui): persist active provider card highlight when not hovered
The selected provider's visual highlight (background, border, glow)
was being overridden by .glass-card base styles due to CSS specificity.
Add dedicated .glass-card-active class to ensure active state persists.
2025-11-29 20:19:17 +08:00
Jason 9db85dd4dc style(css): consolidate global selector rules and remove duplicates
- Merge three separate `*` selector blocks into one for better maintainability
- Remove duplicate "Glassmorphism Utilities" comment
2025-11-29 20:03:19 +08:00
Jason 0f333d9e5b fix(tailwind): add missing shadcn/ui color mappings for opaque dialog backgrounds
The dialog backgrounds were transparent because Tailwind 3 requires explicit
color mappings in the config to use CSS variables. Added standard shadcn/ui
color mappings (background, foreground, card, primary, etc.) and removed
unnecessary overlayClassName overrides from dialog components.
2025-11-29 19:49:55 +08:00
Jason ba875552a6 style(mcp): align wizard modal with confirm dialog styling
- Use zIndex="alert" and semi-transparent overlay for consistency
- Apply border-b-0/border-t-0 bg-transparent to header/footer
- Replace emerald accent with blue to match app theme
- Use Input component instead of raw input elements
- Simplify button variants (outline for cancel)
2025-11-29 17:41:12 +08:00
Jason 75e7f9d731 chore: remove dead code from MCP and provider modules
- Remove unused `read_mcp_json` from gemini_mcp.rs
- Remove unused `normalize_server_keys` from mcp/claude.rs
- Remove unused `validate_mcp_entry` from mcp/validation.rs
- Remove unused `write_codex_live`, `write_claude_live`, `app_not_found` from services/provider/mod.rs
- Clean up unused imports
2025-11-29 16:24:22 +08:00
Jason 7e6074a9a9 refactor(mcp): modularize MCP and tray menu logic
Split mcp.rs (1135 lines) into focused modules:
- validation.rs: server config validation
- claude.rs: Claude MCP sync/import
- codex.rs: Codex MCP sync (TOML handling)
- gemini.rs: Gemini MCP sync/import

Extract tray menu logic from lib.rs to tray.rs for better separation of concerns.
2025-11-29 10:29:10 +08:00
Jason c229c47c00 fix(auto-launch): use AutoLaunchBuilder for cross-platform compatibility
The auto-launch crate has different API signatures on each platform:
- Windows/Linux: AutoLaunch::new() takes 3 arguments
- macOS: AutoLaunch::new() takes 4 arguments (includes hidden param)

The previous code used #[cfg(not(target_os = "windows"))] which incorrectly
applied macOS's 4-argument signature to Linux, causing build failures.

Switch to AutoLaunchBuilder which handles platform differences internally.
2025-11-28 22:59:09 +08:00
Jason 6c477a60f9 chore: bump version to v3.8.0 2025-11-28 22:37:32 +08:00
Jason 7eac809689 feat(preset): add MiniMax international version and split promotions
- Add MiniMax en preset with international API endpoint (minimaxi.io)
- Split MiniMax partner promotion into CN and EN versions
- Remove unnecessary icon config from Aliyun and Alibaba Lingma presets
2025-11-28 22:27:23 +08:00
Jason dafa77897b docs: add v3.8.0 release documentation and Japanese README
- Add CHANGELOG entry for v3.8.0
- Update README.md and README_ZH.md with v3.8.0 features
- Add Japanese README (README_JA.md)
- Add release notes in English, Chinese, and Japanese
2025-11-28 21:47:05 +08:00
YoVinchen 0f959112b1 refactor(skill): remove skillsPath configuration (#310)
Remove the skillsPath field from SkillRepo and Skill structs since
recursive scanning now automatically discovers skills in all directories.
Simplify the UI by removing the path input field.
2025-11-28 16:26:28 +08:00
Jason f4c284f86c fix(test): update component tests to match current implementation
- Add JsonEditor mock to McpFormModal tests (component uses CodeMirror
  instead of Textarea)
- Fix assertion for missing command error message
- Update ImportExportSection tests for new button behavior and file
  display format
2025-11-28 16:20:18 +08:00
Jason 3878a16c4f fix: pre-release fixes for code formatting, i18n, and test suite
- Run cargo fmt to fix Rust code formatting (lib.rs)
- Add missing i18n keys: migration.success, agents.title (zh/en/ja)
- Replace hardcoded strings "Agents" and "MCP" with t() calls in App.tsx
- Fix test mocks and assertions:
  - Add providersApi.updateTrayMenu to useSettings.test.tsx mock
  - Update SettingsPage mock path in App.test.tsx
  - Fix toast message assertion in integration/SettingsDialog.test.tsx
  - Add autoSaveSettings to SettingsDialog component test mock
  - Fix loading state test to check spinner instead of title
  - Update import button name matching for selected file state
  - Fix save button test to switch to advanced tab first
  - Remove obsolete cancel button test (button no longer exists)

Test results improved from 99 passed / 17 failed to 104 passed / 11 failed
2025-11-28 15:53:25 +08:00
100 changed files with 3789 additions and 1471 deletions
+68
View File
@@ -5,6 +5,74 @@ 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.3] - 2025-12-24
### Added
- **macOS tray template icon** - Use system template icon for better dark/light mode adaptation
- **AiGoCode partner** - Added AiGoCode icon and partner promotion
- **Skip first-run confirmation** - New setting to skip Claude Code first-run confirmation dialog
- **DMXAPI partner** - Added DMXAPI as official partner
- **Provider icons** - Added icons for OpenRouter, LongCat, ModelScope, AiHubMix
### Fixed
- **UI header layout** - Fixed content being covered by fixed header (added padding-top)
- **Dark mode visibility** - Improved text visibility in dark mode
- **Azure link** - Corrected Azure website link (#407)
- **Skill installation** - Use directory basename for skill installation path (#358)
- **SQL import refresh** - Refresh all providers immediately after SQL import
- **MCP sync safety** - Skip sync when target CLI app is not installed
### Changed
- **Model versions** - Updated model versions for provider presets
- **Provider switch notification** - Removed restart prompt from notification
- **SQL import restriction** - Restrict SQL import to CC Switch exported backups only
- **GLM partner images** - Updated partner banner images
---
## [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
+51 -20
View File
@@ -2,19 +2,14 @@
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
[![Version](https://img.shields.io/badge/version-3.7.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)
[![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![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_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.
English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANGELOG.md)
</div>
@@ -22,11 +17,7 @@ Unified management for Claude Code, Codex & Gemini CLI provider configurations,
![Zhipu GLM](assets/partners/banners/glm-en.jpg)
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.
GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.
Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)!
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)!
---
@@ -41,6 +32,12 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
<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>
<tr>
<td width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses.AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, youll receive an extra 10% bonus credit on your first top-up!
</td>
</tr>
</table>
## Screenshots
@@ -51,9 +48,42 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
## Features
### Current Version: v3.7.0 | [Full Changelog](CHANGELOG.md) | [📋 Release Notes](docs/release-note-v3.7.0-en.md)
### Current Version: v3.8.2 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
**v3.7.0 Major Update (2025-11-19)**
**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**
@@ -226,10 +256,10 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
- MCP servers: `~/.gemini/settings.json``mcpServers`
- Tray quick switch: Each provider switch rewrites `~/.gemini/.env`, no need to restart Gemini CLI
**CC Switch Storage**
**CC Switch Storage (v3.8.0 New Architecture)**
- Main config (SSOT): `~/.cc-switch/config.json` (includes providers, MCP, Prompts presets, etc.)
- Settings: `~/.cc-switch/settings.json`
- Database (SSOT): `~/.cc-switch/cc-switch.db` (SQLite, stores providers, MCP, Prompts, Skills)
- Local settings: `~/.cc-switch/settings.json` (device-level settings)
- Backups: `~/.cc-switch/backups/` (auto-rotate, keep 10)
### Cloud Sync Setup
@@ -265,11 +295,12 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
**Core Design Patterns**
- **SSOT** (Single Source of Truth): All provider configs stored in `~/.cc-switch/config.json`
- **SSOT** (Single Source of Truth): All data stored in `~/.cc-switch/cc-switch.db` (SQLite)
- **Dual-layer Storage**: SQLite for syncable data, JSON for device-level settings
- **Dual-way Sync**: Write to live files on switch, backfill from live when editing active provider
- **Atomic Writes**: Temp file + rename pattern prevents config corruption
- **Concurrency Safe**: RwLock with scoped guards avoids deadlocks
- **Layered Architecture**: Clear separation (Commands → Services → Models)
- **Concurrency Safe**: Mutex-protected database connection avoids race conditions
- **Layered Architecture**: Clear separation (Commands → Services → DAO → Database)
**Key Components**
+474
View File
@@ -0,0 +1,474 @@
<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)
[![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)
</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>
<tr>
<td width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!
</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
+50 -20
View File
@@ -2,19 +2,14 @@
# Claude Code / Codex / Gemini CLI 全方位辅助工具
[![Version](https://img.shields.io/badge/version-3.7.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)
[![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![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) | 中文 | [更新日志](CHANGELOG.md) | [📋 v3.7.0 发布说明](docs/release-note-v3.7.0-zh.md)
**从供应商切换器到 AI CLI 一体化管理平台**
统一管理 Claude Code、Codex 与 Gemini CLI 的供应商配置、MCP 服务器、Skills 扩展和系统提示词。
[English](README.md) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md) | [v3.8.0 发布说明](docs/release-note-v3.8.0-zh.md)
</div>
@@ -22,11 +17,7 @@
![智谱 GLM](assets/partners/banners/glm-zh.jpg)
感谢智谱AI的 GLM CODING PLAN 赞助了本项目!
GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。
CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。
感谢智谱AI的 GLM CODING PLAN 赞助了本项目!GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。
---
@@ -41,6 +32,11 @@ CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编
<td>感谢闪电说赞助了本项目!闪电说是本地优先的 AI 语音输入法:毫秒级响应,数据不离设备;打字速度提升 4 倍,AI 智能纠错;绝对隐私安全,完全免费,配合 Claude Code 写代码效率翻倍!支持 Mac/Win 双平台,<a href="https://www.shandianshuo.cn">免费下载</a></td>
</tr>
<tr>
<td width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
</tr>
</table>
## 界面预览
@@ -51,9 +47,42 @@ CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编
## 功能特性
### 当前版本:v3.7.0 | [完整更新日志](CHANGELOG.md)
### 当前版本:v3.8.2 | [完整更新日志](CHANGELOG.md)
**v3.7.0 重大更新(2025-11-19**
**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+ 行新增代码**
@@ -226,10 +255,10 @@ paru -S cc-switch-bin
- MCP 服务器:`~/.gemini/settings.json``mcpServers`
- 托盘快速切换:每次切换供应商都会重写 `~/.gemini/.env`,无需重启 Gemini CLI 即可生效
**CC Switch 存储**
**CC Switch 存储v3.8.0 新架构)**
- 主配置SSOT):`~/.cc-switch/config.json`(包含供应商、MCP、Prompts 预设等
- 设置:`~/.cc-switch/settings.json`
- 数据库SSOT):`~/.cc-switch/cc-switch.db`SQLite,存储供应商、MCP、Prompts、Skills
- 本地设置:`~/.cc-switch/settings.json`(设备级设置)
- 备份:`~/.cc-switch/backups/`(自动轮换,保留 10 个)
### 云同步设置
@@ -265,11 +294,12 @@ paru -S cc-switch-bin
**核心设计模式**
- **SSOT**(单一事实源):所有供应商配置存储在 `~/.cc-switch/config.json`
- **SSOT**(单一事实源):所有数据存储在 `~/.cc-switch/cc-switch.db`SQLite
- **双层存储**SQLite 存储可同步数据,JSON 存储设备级设置
- **双向同步**:切换时写入 live 文件,编辑当前供应商时从 live 回填
- **原子写入**:临时文件 + 重命名模式防止配置损坏
- **并发安全**RwLock 与作用域守卫避免死锁
- **分层架构**:清晰分离(Commands → Services → Models
- **并发安全**Mutex 保护的数据库连接避免竞态条件
- **分层架构**:清晰分离(Commands → Services → DAO → Database
**核心组件**
-77
View File
@@ -1,77 +0,0 @@
# CC Switch 国际化功能说明
## 已完成的工作
1. **安装依赖**:添加了 `react-i18next``i18next`
2. **配置国际化**:在 `src/i18n/` 目录下创建了配置文件
3. **翻译文件**:创建了英文、中文、日文翻译文件
4. **组件更新**:替换了主要组件中的硬编码文案
5. **语言切换器**:添加了语言切换按钮
## 文件结构
```
src/
├── i18n/
│ ├── index.ts # 国际化配置文件
│ └── locales/
│ ├── en.json # 英文翻译
│ ├── ja.json # 日文翻译
│ └── zh.json # 中文翻译
├── components/
│ └── LanguageSwitcher.tsx # 语言切换组件
└── main.tsx # 导入国际化配置
```
## 默认语言设置
- **默认语言**:中文 (zh)(无首选时根据浏览器/系统语言选择 zh/en/ja)
- **回退语言**:英文 (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: 102 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 225 KiB

+369
View File
@@ -0,0 +1,369 @@
# CC Switch v3.8.0
> Persistence Architecture Upgrade, Laying the Foundation for Cloud Sync
**[中文版 →](release-note-v3.8.0-zh.md) | [日本語版 →](release-note-v3.8.0-ja.md)**
---
## Overview
CC Switch v3.8.0 is a major architectural upgrade that restructures the data persistence layer and user interface, laying the foundation for future cloud sync and local proxy features.
**Release Date**: 2025-11-28
**Commits**: 51 commits since v3.7.1
**Code Changes**: 207 files, +17,297 / -6,870 lines
---
## Major Updates
### Persistence Architecture Upgrade
Migrated from single JSON file storage to SQLite + JSON dual-layer architecture for hierarchical data management.
**Architecture Changes**:
```
v3.7.x (Old) v3.8.0 (New)
┌─────────────────┐ ┌─────────────────────────────────┐
│ config.json │ │ SQLite (Syncable Data) │
│ ┌───────────┐ │ │ ├─ providers Provider cfg │
│ │ providers │ │ │ ├─ mcp_servers MCP servers │
│ │ mcp │ │ ──> │ ├─ prompts Prompts │
│ │ prompts │ │ │ ├─ skills Skills │
│ │ settings │ │ │ └─ settings General cfg │
│ └───────────┘ │ ├─────────────────────────────────┤
└─────────────────┘ │ JSON (Device-level Data) │
│ └─ settings.json Local settings│
│ ├─ Window position │
│ ├─ Path overrides │
│ └─ Current provider ID │
└─────────────────────────────────┘
```
**Dual-layer Structure Design**:
| Layer | Storage | Data Types | Sync Strategy |
| ---------- | ------- | ------------------------------- | --------------- |
| Cloud Sync | SQLite | Providers, MCP, Prompts, Skills | Future syncable |
| Device | JSON | Window state, local paths | Stays local |
**Technical Implementation**:
- **Schema Version Management** - Supports database structure upgrade migrations
- **SQL Import/Export** - `backup.rs` supports SQL dump for cloud storage
- **Transaction Support** - SQLite native transactions ensure data consistency
- **Auto Migration** - Automatically migrates from `config.json` on first launch
**Modular Refactoring**:
```
database/
├── mod.rs Core Database struct and initialization
├── schema.rs Table definitions, schema version migrations
├── backup.rs SQL import/export, binary snapshot backup
├── migration.rs JSON → SQLite data migration engine
└── dao/ Data Access Object layer
├── providers.rs Provider CRUD
├── mcp.rs MCP server CRUD
├── prompts.rs Prompts CRUD
├── skills.rs Skills CRUD
└── settings.rs Key-value settings storage
```
---
### Brand New User Interface
Completely redesigned UI providing a more modern visual experience.
**Visual Improvements**:
- Redesigned interface layout
- Unified component styles
- Smoother transition animations
- Optimized visual hierarchy
**Interaction Enhancements**:
- Redesigned header toolbar
- Unified ConfirmDialog styling
- Disabled overscroll bounce effect on main view
- Improved form validation feedback
**Compatibility Adjustments**:
- Downgraded Tailwind CSS from v4 to v3.4 for better browser compatibility
---
### Japanese Language Support
Added Japanese interface support, expanding internationalization to three languages.
**Supported Languages**:
- Simplified Chinese
- English
- Japanese (New)
---
## New Features
### Skills Recursive Scanning
Skills management system now supports recursive scanning of repository directories, automatically discovering nested skill files.
**Improvements**:
- Support for multi-level directory structures
- Automatic discovery of all `SKILL.md` files
- Allow same-named skills from different repositories (using full path for deduplication)
---
### Provider Icon Configuration
Provider presets now support custom icon configuration.
**Features**:
- Preset providers include default icons
- Icon settings preserved when duplicating providers
- Custom icon colors
---
### Enhanced Form Validation
Provider forms now include required field validation with friendlier error messages.
**Improvements**:
- Real-time validation for required fields
- Unified Toast notifications for validation errors
- Clearer error messages
---
### Auto Launch on Startup
Added auto-launch functionality supporting Windows, macOS, and Linux platforms.
**Features**:
- One-click enable/disable in settings
- Implemented using platform-native APIs
- Windows uses Registry, macOS uses LaunchAgent, Linux uses XDG autostart
---
### New Provider Presets
- **MiniMax** - Official partner
---
## Bug Fixes
### Critical Fixes
**Custom Endpoints Lost Issue**
Fixed an issue where custom request URLs were unexpectedly lost when updating providers.
- Root Cause: `INSERT OR REPLACE` executes `DELETE + INSERT` under the hood in SQLite, triggering foreign key cascade deletion
- Fix: Changed to use `UPDATE` statement for existing providers
**Gemini Configuration Issues**
- Fixed custom provider environment variables not correctly written to `.env` file
- Fixed security auth config incorrectly written to other config files
**Provider Validation Issues**
- Fixed validation error when current provider ID doesn't exist
- Fixed icon fields lost when duplicating providers
### Platform Compatibility
**Linux**
- Resolved WebKitGTK DMA-BUF rendering issue
- Preserve user `.desktop` file customizations
### Other Fixes
- Fixed redundant usage queries when switching apps
- Fixed DMXAPI preset using wrong auth token field
- Fixed missing translation keys in deeplink components
- Fixed usage script template initialization logic
---
## Technical Improvements
### Architecture Refactoring
**Provider Service Modularization**:
```
services/provider/
├── mod.rs Core service - add/update/delete/switch/validate
├── live.rs Live config file operations
├── gemini_auth.rs Gemini auth type detection
├── endpoints.rs Custom endpoint management
└── usage.rs Usage script execution
```
**Deeplink Modularization**:
```
deeplink/
├── mod.rs Module exports
├── parser.rs URL parsing
├── provider.rs Provider import logic
├── mcp.rs MCP import logic
├── prompt.rs Prompt import
├── skill.rs Skills import
└── utils.rs Utility functions
```
### Code Quality
**Cleanup**:
- Removed legacy JSON-era import/export dead code
- Removed unused MCP type exports
- Unified error handling approach
**Test Updates**:
- Migrated tests to SQLite database architecture
- Updated component tests to match current implementation
- Fixed MSW handlers to adapt to new API
---
## Technical Statistics
```
Overall Changes:
- Commits: 51
- Files: 207 files changed
- Additions: +17,297 lines
- Deletions: -6,870 lines
- Net: +10,427 lines
Commit Type Distribution:
- fix: 25 (Bug fixes)
- refactor: 11 (Code refactoring)
- feat: 9 (New features)
- test: 1 (Testing)
- other: 5
Change Area Distribution:
- Frontend source: 112 files
- Rust backend: 63 files
- Test files: 20 files
- i18n files: 3 files
```
---
## Migration Guide
### Upgrading from v3.7.x
**Auto Migration** - Executes automatically on first launch:
1. Detects if `config.json` exists
2. Migrates all data to SQLite within a transaction
3. Migrates device-level settings to `settings.json`
4. Shows migration success notification
**Data Safety**:
- Original `config.json` file is preserved (not deleted)
- Error dialog displayed on migration failure, `config.json` preserved
- Supports Dry-run mode to verify migration logic
---
## Download & Installation
### System Requirements
- **Windows**: Windows 10+
- **macOS**: macOS 10.15 (Catalina)+
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+
### Download Links
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download:
- **Windows**: `CC-Switch-v3.8.0-Windows.msi` or `-Portable.zip`
- **macOS**: `CC-Switch-v3.8.0-macOS.tar.gz` or `.zip`
- **Linux**: `CC-Switch-v3.8.0-Linux.AppImage` or `.deb`
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
Update:
```bash
brew upgrade --cask cc-switch
```
## Acknowledgments
### Contributors
Thanks to all contributors who made this release possible:
- [@YoVinchen](https://github.com/YoVinchen) - UI and database refactoring
- [@farion1231](https://github.com/farion1231) - Bug fixes and feature enhancements
- Community members for testing and feedback
### Sponsors
**Zhipu AI** - GLM CODING PLAN Sponsor
[Get 10% off with this link](https://z.ai/subscribe?ic=8JVLJQFSKB)
**PackyCode** - API Relay Service Partner
[Use code "cc-switch" for 10% off registration](https://www.packyapi.com/register?aff=cc-switch)
**ShandianShuo** - Local-first AI Voice Input
[Free download](https://shandianshuo.cn) for Mac/Windows
**MiniMax** - MiniMax M2 CODING PLAN Sponsor
[Black Friday sale, plans starting at $2](https://platform.minimax.io/subscribe/coding-plan)
---
## Feedback & Support
- **Issue Reports**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
- **Documentation**: [README](../README.md)
- **Changelog**: [CHANGELOG.md](../CHANGELOG.md)
---
## Future Roadmap
**v3.9.0 Preview** (Tentative):
- Local proxy feature
Stay tuned for more updates!
---
**Happy Coding!**
+315
View File
@@ -0,0 +1,315 @@
# CC Switch v3.8.0
> 永続化アーキテクチャを刷新し、クラウド同期の土台を構築
**[English →](release-note-v3.8.0-en.md) | [中文版 →](release-note-v3.8.0-zh.md)**
---
## 概要
CC Switch v3.8.0 はデータ永続化レイヤーと UI を大幅に作り替え、今後のクラウド同期やローカルプロキシ機能に向けた基盤を整えたメジャーアップデートです。
**リリース日**: 2025-11-28
**コミット数**: v3.7.1 以降 51 commits
**変更量**: 207 files, +17,297 / -6,870 lines
---
## 主要アップデート
### 永続化アーキテクチャの刷新
単一の JSON 保存から、階層化された SQLite + JSON の二層構造へ移行。
**アーキテクチャ変更**:
```
v3.7.x (旧) v3.8.0 (新)
┌─────────────────┐ ┌─────────────────────────────────┐
│ config.json │ │ SQLite (同期対象データ) │
│ ┌───────────┐ │ │ ├─ providers プロバイダ設定 │
│ │ providers │ │ │ ├─ mcp_servers MCP サーバー │
│ │ mcp │ │ ──> │ ├─ prompts プロンプト │
│ │ prompts │ │ │ ├─ skills Skills │
│ │ settings │ │ │ └─ settings 汎用設定 │
│ └───────────┘ │ ├─────────────────────────────────┤
└─────────────────┘ │ JSON (デバイス固有データ) │
│ └─ settings.json ローカル設定 │
│ ├─ ウィンドウ位置 │
│ ├─ パスの上書き │
│ └─ 現在のプロバイダ ID │
└─────────────────────────────────┘
```
**二層構造の設計**:
| レイヤー | ストレージ | データ種別 | 同期戦略 |
| -------- | ---------- | ----------------------------------- | ---------------- |
| クラウド | SQLite | Providers, MCP, Prompts, Skills | 将来同期対象 |
| デバイス | JSON | ウィンドウ状態、ローカルパス | ローカル保持 |
**実装ポイント**:
- **スキーマバージョン管理**: DB 構造のマイグレーションに対応
- **SQL インポート/エクスポート**: `backup.rs` が SQL ダンプをサポート
- **トランザクション対応**: SQLite ネイティブで整合性を確保
- **自動マイグレーション**: 初回起動で `config.json` から自動移行
**モジュール分割**:
```
database/
├── mod.rs Database 構造体と初期化
├── schema.rs テーブル定義とスキーマ移行
├── backup.rs SQL インポート/エクスポートとスナップショット
├── migration.rs JSON → SQLite 変換エンジン
└── dao/ DAO レイヤー
├── providers.rs プロバイダ CRUD
├── mcp.rs MCP CRUD
├── prompts.rs プロンプト CRUD
├── skills.rs Skills CRUD
└── settings.rs 設定 Key-Value 保存
```
---
### 新しいユーザーインターフェース
よりモダンな見た目と操作感に再設計。
- レイアウト全面刷新、コンポーネントスタイルを統一
- トランジションを滑らかにし、視覚的階層を最適化
- メインビューのオーバースクロールバウンスを無効化
- ブラウザ互換性向上のため Tailwind CSS を v4→v3.4 にダウングレード
---
### 日語化
UI が日本語に対応し、国際化が 3 言語(中/英/日)へ拡大。
---
## 新機能
### Skills 递帰スキャン
Skills 管理がリポジトリを再帰的に走査し、ネストされた `SKILL.md` を自動検出。
- 複数階層のディレクトリに対応
- すべての `SKILL.md` を自動発見
- パスをキーにした重複排除で同名スキルを許容
### プロバイダアイコン設定
プリセットがデフォルトアイコンを持ち、複製してもアイコンを保持。カスタム色も設定可能。
### フォームバリデーション強化
必須項目にリアルタイム検証と分かりやすいエラーメッセージを追加し、トースト通知を統一。
### 自動起動
Windows/macOS/Linux で自動起動をサポート。
- 設定画面からワンクリックで ON/OFF
- Registry / LaunchAgent / XDG autostart を使用
### 新プロバイダプリセット
- **MiniMax** - 公式パートナー
---
## バグ修正
### 重要修正
**カスタムエンドポイント消失**
- 原因: SQLite の `INSERT OR REPLACE` が内部で `DELETE + INSERT` を実行し、外部キーのカスケード削除が発生
- 対応: 既存プロバイダ更新を `UPDATE` に変更
**Gemini 設定**
- カスタム環境変数が `.env` に正しく書き込まれない問題を修正
- 認証設定が他ファイルに誤って書き込まれる問題を修正
**プロバイダ検証**
- 現在プロバイダ ID が欠落している場合のバリデーションエラーを修正
- 複製時にアイコンフィールドが失われる問題を修正
### プラットフォーム互換性
**Linux**
- WebKitGTK の DMA-BUF 描画問題を解消
- ユーザーの `.desktop` カスタマイズを保持
### その他修正
- アプリ切り替え時の不要な使用量クエリを削減
- DMXAPI プリセットの誤ったトークンフィールドを修正
- Deeplink コンポーネントの欠損翻訳キーを補完
- 使用量スクリプトテンプレート初期化を修正
---
## 技術的改善
### アーキテクチャ再編
**Provider Service のモジュール化**:
```
services/provider/
├── mod.rs 追加/更新/削除/切替/検証の中核
├── live.rs ライブ設定ファイル操作
├── gemini_auth.rs Gemini 認証タイプ検出
├── endpoints.rs カスタムエンドポイント管理
└── usage.rs 使用量スクリプト実行
```
**Deeplink のモジュール化**:
```
deeplink/
├── mod.rs エクスポート
├── parser.rs URL パース
├── provider.rs プロバイダ取り込み
├── mcp.rs MCP 取り込み
├── prompt.rs プロンプト取り込み
├── skill.rs Skills 取り込み
└── utils.rs ユーティリティ
```
### コード品質
- レガシーな JSON 時代のインポート/エクスポート死蔵コードを削除
- 使われていない MCP 型を削除し、エラーハンドリングを統一
- テストを SQLite バックエンドに移行し、MSW ハンドラを最新 API に合わせて更新
---
## 技術統計
```
全体変更:
- コミット: 51
- 変更ファイル: 207
- 追加: +17,297 行
- 削除: -6,870 行
- 純増: +10,427 行
コミット種別:
- fix: 25
- refactor: 11
- feat: 9
- test: 1
- other: 5
変更箇所:
- フロントエンド: 112 files
- Rust バックエンド: 63 files
- テスト: 20 files
- i18n: 3 files
```
---
## マイグレーションガイド
### v3.7.x からのアップグレード
**自動マイグレーション**(初回起動時):
1. `config.json` の存在を検出
2. 全データをトランザクションで SQLite に移行
3. デバイス設定を `settings.json` へ移行
4. 移行成功の通知を表示
**データ保護**:
- 元の `config.json` は保持(削除しない)
- 失敗時はエラーダイアログを表示し、`config.json` を温存
- Dry-run モードで検証可能
---
## ダウンロード & インストール
### システム要件
- **Windows**: Windows 10+
- **macOS**: macOS 10.15 (Catalina)+
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+
### ダウンロード
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から入手:
- **Windows**: `CC-Switch-v3.8.0-Windows.msi` または `-Portable.zip`
- **macOS**: `CC-Switch-v3.8.0-macOS.tar.gz` または `.zip`
- **Linux**: `CC-Switch-v3.8.0-Linux.AppImage` または `.deb`
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
アップデート:
```bash
brew upgrade --cask cc-switch
```
---
## 謝辞
### コントリビューター
- [@YoVinchen](https://github.com/YoVinchen) - UI とデータベースリファクタ
- [@farion1231](https://github.com/farion1231) - バグ修正と機能拡張
- コミュニティの皆さん - テストとフィードバック
### スポンサー
**Zhipu AI** - GLM CODING PLAN スポンサー
[10% オフリンク](https://z.ai/subscribe?ic=8JVLJQFSKB)
**PackyCode** - API リレーサービスパートナー
[登録時に「cc-switch」で 10% オフ](https://www.packyapi.com/register?aff=cc-switch)
**ShandianShuo** - ローカルファースト音声入力
[Mac/Windows 無料ダウンロード](https://shandianshuo.cn)
**MiniMax** - MiniMax M2 CODING PLAN スポンサー
[ブラックフライデーセール中、$2 から](https://platform.minimax.io/subscribe/coding-plan)
---
## フィードバック & サポート
- **Issue**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
- **ドキュメント**: [README](../README.md)
- **更新履歴**: [CHANGELOG.md](../CHANGELOG.md)
---
## 今後のロードマップ
**v3.9.0 予告(予定)**:
- ローカルプロキシ機能
続報にご期待ください!
---
**Happy Coding!**
+369
View File
@@ -0,0 +1,369 @@
# CC Switch v3.8.0
> 持久化架构升级,为云同步奠定基础
**[English Version →](release-note-v3.8.0-en.md)**
---
## 概览
CC Switch v3.8.0 是一次重大的架构升级版本,重构了数据持久化层和用户界面,为未来的云同步和本地代理功能奠定基础。
**发布日期**2025-11-28
**提交数量**:从 v3.7.1 开始 51 个提交
**代码变更**207 个文件,+17,297 / -6,870 行
---
## 重大更新
### 持久化架构升级
从单一 JSON 文件存储迁移到 SQLite + JSON 双层架构,实现数据分层管理。
**架构变更**
```
v3.7.x (旧) v3.8.0 (新)
┌─────────────────┐ ┌─────────────────────────────────┐
│ config.json │ │ SQLite (可同步数据) │
│ ┌───────────┐ │ │ ├─ providers 供应商配置 │
│ │ providers │ │ │ ├─ mcp_servers MCP 服务器 │
│ │ mcp │ │ ──> │ ├─ prompts 提示词 │
│ │ prompts │ │ │ ├─ skills 技能 │
│ │ settings │ │ │ └─ settings 通用设置 │
│ └───────────┘ │ ├─────────────────────────────────┤
└─────────────────┘ │ JSON (设备级数据) │
│ └─ settings.json 本地设置 │
│ ├─ 窗口位置 │
│ ├─ 路径覆盖 │
│ └─ 当前选中供应商 ID │
└─────────────────────────────────┘
```
**双层结构设计**
| 层级 | 存储方式 | 数据类型 | 同步策略 |
| -------- | -------- | ---------------------------- | ---------- |
| 云同步层 | SQLite | 供应商、MCP、Prompts、Skills | 未来可同步 |
| 设备层 | JSON | 窗口状态、本地路径、当前选择 | 保持本地 |
**技术实现**
- **Schema 版本管理** - 支持数据库结构升级迁移
- **SQL 导入导出** - `backup.rs` 支持 SQL dump,便于云端存储
- **事务支持** - SQLite 原生事务保证数据一致性
- **自动迁移** - 首次启动自动从 `config.json` 迁移数据
**模块化重构**
```
database/
├── mod.rs 核心 Database 结构体和初始化
├── schema.rs 表结构定义、Schema 版本迁移
├── backup.rs SQL 导入导出、二进制快照备份
├── migration.rs JSON → SQLite 数据迁移引擎
└── dao/ 数据访问对象层
├── providers.rs 供应商 CRUD
├── mcp.rs MCP 服务器 CRUD
├── prompts.rs 提示词 CRUD
├── skills.rs Skills CRUD
└── settings.rs 键值对设置存储
```
---
### 全新用户界面
完整重构的 UI 设计,提供更现代化的视觉体验。
**视觉改进**
- 重新设计的界面布局
- 统一的组件样式
- 更流畅的过渡动画
- 优化的视觉层次
**交互优化**
- Header toolbar 重新设计
- ConfirmDialog 样式统一
- 禁用主视图 overscroll 弹跳效果
- 改进的表单验证反馈
**兼容性调整**
- Tailwind CSS 从 v4 降级到 v3.4,提升浏览器兼容性
---
### 日语支持
新增日语(日本語)界面支持,国际化语言扩展到三种。
**支持语言**
- 简体中文
- English
- 日本語(新增)
---
## 新增功能
### Skills 递归扫描
Skills 管理系统支持递归扫描仓库目录,自动发现嵌套的技能文件。
**改进内容**
- 支持多层目录结构
- 自动发现所有 `SKILL.md` 文件
- 允许不同仓库的同名技能(使用完整路径去重)
---
### 供应商图标配置
供应商预设支持自定义图标配置。
**功能特性**
- 预设供应商包含默认图标
- 复制供应商时保留图标设置
- 图标颜色自定义
---
### 表单验证增强
供应商表单新增必填字段验证,提供更友好的错误提示。
**改进内容**
- 必填字段实时校验
- 统一使用 Toast 通知显示验证错误
- 更清晰的错误信息
---
### 开机自启
新增开机自动启动功能,支持 Windows、macOS 和 Linux 三个平台。
**功能特性**
- 在设置中一键开启/关闭
- 使用平台原生 API 实现
- Windows 使用注册表、macOS 使用 LaunchAgent、Linux 使用 XDG autostart
---
### 新增供应商预设
- **MiniMax** - 官方合作伙伴
---
## Bug 修复
### 关键修复
**自定义端点丢失问题**
修复更新供应商时自定义请求地址意外丢失的问题。
- 根因:`INSERT OR REPLACE` 在 SQLite 底层执行 `DELETE + INSERT`,触发外键级联删除
- 修复:改用 `UPDATE` 语句更新已存在的供应商
**Gemini 配置问题**
- 修复自定义供应商环境变量未正确写入 `.env` 文件
- 修复安全认证配置错误写入到其他配置文件
**供应商验证问题**
- 修复当前供应商 ID 不存在时的验证错误
- 修复供应商复制时图标字段丢失
### 平台兼容性
**Linux**
- 解决 WebKitGTK DMA-BUF 渲染问题
- 保留用户 `.desktop` 文件自定义
### 其他修复
- 修复切换应用时的冗余用量查询
- 修复 DMXAPI 预设使用错误的认证令牌字段
- 修复深链接组件缺少翻译键
- 修复用量脚本模板初始化逻辑
---
## 技术改进
### 架构重构
**供应商服务模块化**
```
services/provider/
├── mod.rs 核心服务 - add/update/delete/switch/validate
├── live.rs Live 配置文件操作
├── gemini_auth.rs Gemini 认证类型检测
├── endpoints.rs 自定义端点管理
└── usage.rs 用量脚本执行
```
**深链接模块化**
```
deeplink/
├── mod.rs 模块导出
├── parser.rs URL 解析
├── provider.rs 供应商导入逻辑
├── mcp.rs MCP 导入逻辑
├── prompt.rs 提示词导入
├── skill.rs Skills 导入
└── utils.rs 工具函数
```
### 代码质量
**清理工作**
- 移除 JSON 时代遗留的导入导出死代码
- 移除未使用的 MCP 类型导出
- 统一错误处理方式
**测试更新**
- 迁移测试到 SQLite 数据库架构
- 更新组件测试匹配当前实现
- 修复 MSW handlers 适配新 API
---
## 技术统计
```
总体变更:
- 提交数:51
- 文件数:207 个文件变更
- 新增:+17,297 行
- 删除:-6,870 行
- 净增:+10,427 行
提交类型分布:
- 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!**
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.7.1",
"version": "3.8.3",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"scripts": {
"dev": "pnpm tauri dev",
+57 -5
View File
@@ -509,6 +509,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.10.1"
@@ -618,7 +624,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.7.1"
version = "3.8.3"
dependencies = [
"anyhow",
"auto-launch",
@@ -2050,7 +2056,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
dependencies = [
"byteorder",
"png",
"png 0.17.16",
]
[[package]]
@@ -2166,6 +2172,19 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.0",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2589,6 +2608,16 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "moxcms"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fbdd3d7436f8b5e892b8b7ea114271ff0fa00bc5acae845d53b07d498616ef6"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "muda"
version = "0.17.1"
@@ -2604,7 +2633,7 @@ dependencies = [
"objc2-core-foundation",
"objc2-foundation 0.3.1",
"once_cell",
"png",
"png 0.17.16",
"serde",
"thiserror 2.0.17",
"windows-sys 0.60.2",
@@ -3332,6 +3361,19 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "png"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
dependencies = [
"bitflags 2.9.4",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
@@ -3463,6 +3505,15 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "pxfm"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84"
dependencies = [
"num-traits",
]
[[package]]
name = "quick-xml"
version = "0.37.5"
@@ -4686,6 +4737,7 @@ dependencies = [
"heck 0.5.0",
"http",
"http-range",
"image",
"jni",
"libc",
"log",
@@ -4754,7 +4806,7 @@ dependencies = [
"ico",
"json-patch",
"plist",
"png",
"png 0.17.16",
"proc-macro2",
"quote",
"semver",
@@ -5470,7 +5522,7 @@ dependencies = [
"objc2-core-graphics",
"objc2-foundation 0.3.1",
"once_cell",
"png",
"png 0.17.16",
"serde",
"thiserror 2.0.17",
"windows-sys 0.59.0",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.7.1"
version = "3.8.3"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
@@ -26,7 +26,7 @@ serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
chrono = { version = "0.4", features = ["serde"] }
tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset"] }
tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset", "image-png"] }
tauri-plugin-log = "2"
tauri-plugin-opener = "2"
tauri-plugin-process = "2"
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+10 -8
View File
@@ -1,5 +1,5 @@
use crate::error::AppError;
use auto_launch::AutoLaunch;
use auto_launch::{AutoLaunch, AutoLaunchBuilder};
/// 初始化 AutoLaunch 实例
fn get_auto_launch() -> Result<AutoLaunch, AppError> {
@@ -7,13 +7,15 @@ fn get_auto_launch() -> Result<AutoLaunch, AppError> {
let app_path =
std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?;
// Windows 平台的 AutoLaunch::new 只接受 3 个参数
// Linux/macOS 平台需要 4 个参数(包含 hidden 参数
#[cfg(target_os = "windows")]
let auto_launch = AutoLaunch::new(app_name, &app_path.to_string_lossy(), &[] as &[&str]);
#[cfg(not(target_os = "windows"))]
let auto_launch = AutoLaunch::new(app_name, &app_path.to_string_lossy(), false, &[] as &[&str]);
// 使用 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)
}
+49
View File
@@ -105,6 +105,55 @@ pub fn read_mcp_json() -> Result<Option<String>, AppError> {
Ok(Some(content))
}
/// 在 ~/.claude.json 根对象写入 hasCompletedOnboarding=true(用于跳过 Claude Code 初次安装确认)
/// 仅增量写入该字段,其他字段保持不变
pub fn set_has_completed_onboarding() -> Result<bool, AppError> {
let path = user_config_path();
let mut root = if path.exists() {
read_json_value(&path)?
} else {
serde_json::json!({})
};
let obj = root
.as_object_mut()
.ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?;
let already = obj
.get("hasCompletedOnboarding")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if already {
return Ok(false);
}
obj.insert("hasCompletedOnboarding".into(), Value::Bool(true));
write_json_value(&path, &root)?;
Ok(true)
}
/// 删除 ~/.claude.json 根对象的 hasCompletedOnboarding 字段(恢复 Claude Code 初次安装确认)
/// 仅增量删除该字段,其他字段保持不变
pub fn clear_has_completed_onboarding() -> Result<bool, AppError> {
let path = user_config_path();
if !path.exists() {
return Ok(false);
}
let mut root = read_json_value(&path)?;
let obj = root
.as_object_mut()
.ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?;
let existed = obj.remove("hasCompletedOnboarding").is_some();
if !existed {
return Ok(false);
}
write_json_value(&path, &root)?;
Ok(true)
}
pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
if id.trim().is_empty() {
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
+12
View File
@@ -34,3 +34,15 @@ pub async fn apply_claude_plugin_config(official: bool) -> Result<bool, String>
pub async fn is_claude_plugin_applied() -> Result<bool, String> {
crate::claude_plugin::is_claude_config_applied().map_err(|e| e.to_string())
}
/// Claude Code:跳过初次安装确认(写入 ~/.claude.json 的 hasCompletedOnboarding=true
#[tauri::command]
pub async fn apply_claude_onboarding_skip() -> Result<bool, String> {
crate::claude_mcp::set_has_completed_onboarding().map_err(|e| e.to_string())
}
/// Claude Code:恢复初次安装确认(删除 ~/.claude.json 的 hasCompletedOnboarding 字段)
#[tauri::command]
pub async fn clear_claude_onboarding_skip() -> Result<bool, String> {
crate::claude_mcp::clear_has_completed_onboarding().map_err(|e| e.to_string())
}
-1
View File
@@ -90,7 +90,6 @@ pub async fn install_skill(
.clone()
.unwrap_or_else(|| "main".to_string()),
enabled: true,
skills_path: skill.skills_path.clone(), // 使用技能记录的 skills_path
};
service
+23 -22
View File
@@ -13,6 +13,8 @@ use std::fs;
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
impl Database {
/// 导出为 SQLite 兼容的 SQL 文本
pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
@@ -36,7 +38,8 @@ impl Database {
}
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 sql_content = sql_raw.trim_start_matches('\u{feff}');
Self::validate_cc_switch_sql_export(sql_content)?;
// 导入前备份现有数据库
let backup_path = self.backup_database_file()?;
@@ -51,7 +54,7 @@ impl Database {
Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?;
temp_conn
.execute_batch(&sql_content)
.execute_batch(sql_content)
.map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?;
// 补齐缺失表/索引并进行基础校验
@@ -93,26 +96,17 @@ impl Database {
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");
fn validate_cc_switch_sql_export(sql: &str) -> Result<(), AppError> {
let trimmed = sql.trim_start();
if trimmed.starts_with(CC_SWITCH_SQL_EXPORT_HEADER) {
return Ok(());
}
cleaned
Err(AppError::localized(
"backup.sql.invalid_format",
"仅支持导入由 CC Switch 导出的 SQL 备份文件。",
"Only SQL backups exported by CC Switch are supported.",
))
}
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
@@ -129,8 +123,15 @@ impl Database {
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 base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
let mut backup_id = base_id.clone();
let mut backup_path = backup_dir.join(format!("{backup_id}.db"));
let mut counter = 1;
while backup_path.exists() {
backup_id = format!("{base_id}_{counter}");
backup_path = backup_dir.join(format!("{backup_id}.db"));
counter += 1;
}
{
let conn = lock_conn!(self.conn);
+5 -4
View File
@@ -58,7 +58,9 @@ impl Database {
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, skills_path FROM skill_repos ORDER BY owner ASC, name ASC")
.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
@@ -68,7 +70,6 @@ impl Database {
name: row.get(1)?,
branch: row.get(2)?,
enabled: row.get(3)?,
skills_path: row.get(4)?,
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -84,8 +85,8 @@ impl Database {
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, skills_path) VALUES (?1, ?2, ?3, ?4, ?5)",
params![repo.owner, repo.name, repo.branch, repo.enabled, repo.skills_path],
"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(())
}
+2 -2
View File
@@ -202,8 +202,8 @@ impl Database {
for repo in &config.skills.repos {
tx.execute(
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled, skills_path) VALUES (?1, ?2, ?3, ?4, ?5)",
params![repo.owner, repo.name, repo.branch, repo.enabled, repo.skills_path],
"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}")))?;
}
+1 -2
View File
@@ -104,7 +104,6 @@ impl Database {
name TEXT NOT NULL,
branch TEXT NOT NULL DEFAULT 'main',
enabled BOOLEAN NOT NULL DEFAULT 1,
skills_path TEXT,
PRIMARY KEY (owner, name)
)",
[],
@@ -233,7 +232,7 @@ impl Database {
"TEXT NOT NULL DEFAULT 'main'",
)?;
Self::add_column_if_missing(conn, "skill_repos", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
Self::add_column_if_missing(conn, "skill_repos", "skills_path", "TEXT")?;
// 注意: skills_path 字段已被移除,因为现在支持全仓库递归扫描
Ok(())
}
-4
View File
@@ -100,12 +100,8 @@ pub struct DeepLinkImportRequest {
/// Skill directory name
#[serde(skip_serializing_if = "Option::is_none")]
pub directory: Option<String>,
/// Repository branch (default: "main")
#[serde(skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
/// Skills subdirectory path (e.g., "skills")
#[serde(skip_serializing_if = "Option::is_none")]
pub skills_path: Option<String>,
// ============ Config file fields (v3.8+) ============
/// Base64 encoded config content
-8
View File
@@ -143,7 +143,6 @@ fn parse_provider_deeplink(
repo: None,
directory: None,
branch: None,
skills_path: None,
config,
config_format,
config_url,
@@ -204,7 +203,6 @@ fn parse_prompt_deeplink(
repo: None,
directory: None,
branch: None,
skills_path: None,
config: None,
config_format: None,
config_url: None,
@@ -262,7 +260,6 @@ fn parse_mcp_deeplink(
repo: None,
directory: None,
branch: None,
skills_path: None,
config_url: None,
})
}
@@ -287,10 +284,6 @@ fn parse_skill_deeplink(
let directory = params.get("directory").cloned();
let branch = params.get("branch").cloned();
let skills_path = params
.get("skills_path")
.or_else(|| params.get("skillsPath"))
.cloned();
Ok(DeepLinkImportRequest {
version,
@@ -298,7 +291,6 @@ fn parse_skill_deeplink(
repo: Some(repo),
directory,
branch,
skills_path,
icon: None,
app: Some("claude".to_string()), // Skills are Claude-only
name: None,
-1
View File
@@ -40,7 +40,6 @@ pub fn import_skill_from_deeplink(
name: name.clone(),
branch: request.branch.unwrap_or_else(|| "main".to_string()),
enabled: request.enabled.unwrap_or(true),
skills_path: request.skills_path,
};
// Save using Database
+1 -6
View File
@@ -142,7 +142,6 @@ fn test_build_gemini_provider_with_model() {
repo: None,
directory: None,
branch: None,
skills_path: None,
content: None,
description: None,
enabled: None,
@@ -189,7 +188,6 @@ fn test_build_gemini_provider_without_model() {
repo: None,
directory: None,
branch: None,
skills_path: None,
content: None,
description: None,
enabled: None,
@@ -231,7 +229,6 @@ fn test_parse_and_merge_config_claude() {
repo: None,
directory: None,
branch: None,
skills_path: None,
content: None,
description: None,
enabled: None,
@@ -275,7 +272,6 @@ fn test_parse_and_merge_config_url_override() {
repo: None,
directory: None,
branch: None,
skills_path: None,
content: None,
description: None,
enabled: None,
@@ -372,12 +368,11 @@ fn test_parse_mcp_deeplink() {
#[test]
fn test_parse_skill_deeplink() {
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev&skills_path=src";
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");
assert_eq!(request.skills_path.unwrap(), "src");
}
-10
View File
@@ -38,16 +38,6 @@ 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 结构的兼容性:
+48 -302
View File
@@ -9,7 +9,7 @@ mod config;
mod database;
mod deeplink;
mod error;
mod gemini_config; // 新增
mod gemini_config;
mod gemini_mcp;
mod init_status;
mod mcp;
@@ -20,6 +20,7 @@ mod provider_defaults;
mod services;
mod settings;
mod store;
mod tray;
mod usage_script;
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
@@ -46,262 +47,13 @@ use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use std::sync::Arc;
use tauri::{
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
tray::{TrayIconBuilder, TrayIconEvent},
};
#[cfg(target_os = "macos")]
use tauri::{ActivationPolicy, RunEvent};
use tauri::image::Image;
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
#[cfg(target_os = "macos")]
use tauri::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",
},
"ja" => Self {
show_main: "メインウィンドウを開く",
no_provider_hint: " (プロバイダーがまだありません。メイン画面から追加してください)",
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 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() {
let app_type_str = section.app_type.as_str();
let providers = app_state.db.get_all_providers(app_type_str)?;
// 使用有效的当前供应商 ID(验证存在性,自动清理失效 ID)
let current_id =
crate::settings::get_effective_current_provider(&app_state.db, &section.app_type)?
.unwrap_or_default();
let manager = crate::provider::ProviderManager {
providers,
current: current_id,
};
menu_builder =
append_provider_section(app, menu_builder, Some(&manager), 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
@@ -361,50 +113,13 @@ 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 create_tray_menu(&app, state.inner()) {
match tray::create_tray_menu(&app, state.inner()) {
Ok(new_menu) => {
if let Some(tray) = app.tray_by_id("main") {
tray.set_menu(Some(new_menu))
@@ -420,6 +135,19 @@ async fn update_tray_menu(
}
}
#[cfg(target_os = "macos")]
fn macos_tray_icon() -> Option<Image<'static>> {
const ICON_BYTES: &[u8] = include_bytes!("../icons/tray/macos/statusbar_template_3x.png");
match Image::from_bytes(ICON_BYTES) {
Ok(icon) => Some(icon),
Err(err) => {
log::warn!("Failed to load macOS tray icon: {err}");
None
}
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let mut builder = tauri::Builder::default();
@@ -472,7 +200,7 @@ pub fn run() {
}
#[cfg(target_os = "macos")]
{
apply_tray_policy(window.app_handle(), false);
tray::apply_tray_policy(window.app_handle(), false);
}
} else {
window.app_handle().exit(0);
@@ -521,7 +249,8 @@ pub fn run() {
unsafe {
use objc2::msg_send;
let _: () = msg_send![&*ns_window, setBackgroundColor: &*bg_color];
let _: () =
msg_send![&*ns_window, setBackgroundColor: &*bg_color];
}
} else {
log::warn!("Failed to retain NSWindow reference");
@@ -771,7 +500,7 @@ pub fn run() {
log::info!("✓ Deep-link URL handler registered");
// 创建动态托盘菜单
let menu = create_tray_menu(app.handle(), &app_state)?;
let menu = tray::create_tray_menu(app.handle(), &app_state)?;
// 构建托盘
let mut tray_builder = TrayIconBuilder::with_id("main")
@@ -782,15 +511,30 @@ pub fn run() {
})
.menu(&menu)
.on_menu_event(|app, event| {
handle_tray_menu_event(app, &event.id.0);
tray::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");
// 使用平台对应的托盘图标(macOS 使用模板图标适配深浅色)
#[cfg(target_os = "macos")]
{
if let Some(icon) = macos_tray_icon() {
tray_builder = tray_builder.icon(icon).icon_as_template(true);
} else if let Some(icon) = app.default_window_icon() {
log::warn!("Falling back to default window icon for tray");
tray_builder = tray_builder.icon(icon.clone());
} else {
log::warn!("Failed to load macOS tray icon for tray");
}
}
#[cfg(not(target_os = "macos"))]
{
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");
}
}
let _tray = tray_builder.build(app)?;
@@ -842,6 +586,8 @@ pub fn run() {
commands::read_claude_plugin_config,
commands::apply_claude_plugin_config,
commands::is_claude_plugin_applied,
commands::apply_claude_onboarding_skip,
commands::clear_claude_onboarding_skip,
// Claude MCP management
commands::get_claude_mcp_status,
commands::read_claude_mcp_config,
@@ -925,7 +671,7 @@ pub fn run() {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
apply_tray_policy(app_handle, true);
tray::apply_tray_policy(app_handle, true);
}
}
// 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...
+146
View File
@@ -0,0 +1,146 @@
//! 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};
fn should_sync_claude_mcp() -> bool {
// Claude 未安装/未初始化时:通常 ~/.claude 目录与 ~/.claude.json 都不存在。
// 按用户偏好:此时跳过写入/删除,不创建任何文件或目录。
crate::config::get_claude_config_dir().exists() || crate::config::get_claude_mcp_path().exists()
}
/// 返回已启用的 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> {
if !should_sync_claude_mcp() {
return Ok(());
}
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> {
if !should_sync_claude_mcp() {
return Ok(());
}
// 读取现有的 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> {
if !should_sync_claude_mcp() {
return Ok(());
}
// 读取现有的 MCP 配置
let mut current = crate::claude_mcp::read_mcp_servers_map()?;
// 移除指定服务器
current.remove(id);
// 写回
crate::claude_mcp::set_mcp_servers_map(&current)
}
@@ -1,200 +1,22 @@
//! Codex MCP 同步和导入模块
//!
//! 包含 Codex 的 MCP 配置管理:
//! - 从 ~/.codex/config.toml 导入
//! - 同步到 ~/.codex/config.toml
//! - JSON 到 TOML 的转换逻辑
use serde_json::{json, Value};
use std::collections::HashMap;
use crate::app_config::{AppType, McpConfig, MultiAppConfig};
use crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig};
use crate::error::AppError;
/// 基础校验:允许 stdio/http/sse;或省略 type(视为 stdio)。对应必填字段存在
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);
use super::validation::{extract_server_spec, validate_server_spec};
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(())
}
#[allow(dead_code)] // v3.7.0: 旧的验证逻辑,保留用于未来可能的迁移
fn validate_mcp_entry(entry: &Value) -> Result<(), 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()))?;
validate_server_spec(server)?;
for key in ["name", "description", "homepage", "docs"] {
if let Some(val) = obj.get(key) {
if !val.is_string() {
return Err(AppError::McpValidation(format!(
"MCP 服务器 {key} 必须为字符串"
)));
}
}
}
if let Some(tags) = obj.get("tags") {
let arr = tags
.as_array()
.ok_or_else(|| AppError::McpValidation("MCP 服务器 tags 必须为字符串数组".into()))?;
if !arr.iter().all(|item| item.is_string()) {
return Err(AppError::McpValidation(
"MCP 服务器 tags 必须为字符串数组".into(),
));
}
}
if let Some(enabled) = obj.get("enabled") {
if !enabled.is_boolean() {
return Err(AppError::McpValidation(
"MCP 服务器 enabled 必须为布尔值".into(),
));
}
}
Ok(())
}
fn normalize_server_keys(map: &mut HashMap<String, Value>) -> usize {
let mut change_count = 0usize;
let mut renames: Vec<(String, String)> = Vec::new();
for (key_ref, value) in map.iter_mut() {
let key = key_ref.clone();
let Some(obj) = value.as_object_mut() else {
continue;
};
let id_value = obj.get("id").cloned();
let target_id: String;
match id_value {
Some(id_val) => match id_val.as_str() {
Some(id_str) => {
let trimmed = id_str.trim();
if trimmed.is_empty() {
obj.insert("id".into(), json!(key.clone()));
change_count += 1;
target_id = key.clone();
} else {
if trimmed != id_str {
obj.insert("id".into(), json!(trimmed));
change_count += 1;
}
target_id = trimmed.to_string();
}
}
None => {
obj.insert("id".into(), json!(key.clone()));
change_count += 1;
target_id = key.clone();
}
},
None => {
obj.insert("id".into(), json!(key.clone()));
change_count += 1;
target_id = key.clone();
}
}
if target_id != key {
renames.push((key, target_id));
}
}
for (old_key, new_key) in renames {
if old_key == new_key {
continue;
}
if map.contains_key(&new_key) {
log::warn!("MCP 条目 '{old_key}' 的内部 id '{new_key}' 与现有键冲突,回退为原键");
if let Some(value) = map.get_mut(&old_key) {
if let Some(obj) = value.as_object_mut() {
if obj
.get("id")
.and_then(|v| v.as_str())
.map(|s| s != old_key)
.unwrap_or(true)
{
obj.insert("id".into(), json!(old_key.clone()));
change_count += 1;
}
}
}
continue;
}
if let Some(mut value) = map.remove(&old_key) {
if let Some(obj) = value.as_object_mut() {
obj.insert("id".into(), json!(new_key.clone()));
}
log::info!("MCP 条目键名已自动修复: '{old_key}' -> '{new_key}'");
map.insert(new_key, value);
change_count += 1;
}
}
change_count
}
pub fn normalize_servers_for(config: &mut MultiAppConfig, app: &AppType) -> usize {
let servers = &mut config.mcp_for_mut(app).servers;
normalize_server_keys(servers)
}
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())
fn should_sync_codex_mcp() -> bool {
// Codex 未安装/未初始化时:~/.codex 目录不存在。
// 按用户偏好:目录缺失时跳过写入/删除,不创建任何文件或目录。
crate::codex_config::get_codex_config_dir().exists()
}
/// 返回已启用的 MCP 服务器(过滤 enabled==true
@@ -220,185 +42,6 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
out
}
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
pub fn get_servers_snapshot_for(
config: &mut MultiAppConfig,
app: &AppType,
) -> (HashMap<String, Value>, usize) {
let normalized = normalize_servers_for(config, app);
let mut snapshot = config.mcp_for(app).servers.clone();
snapshot.retain(|id, value| {
let Some(obj) = value.as_object_mut() else {
log::warn!("跳过无效的 MCP 条目 '{id}': 必须为 JSON 对象");
return false;
};
obj.entry(String::from("id")).or_insert(json!(id));
match validate_mcp_entry(value) {
Ok(()) => true,
Err(err) => {
log::error!("config.json 中存在无效的 MCP 条目 '{id}': {err}");
false
}
}
});
(snapshot, normalized)
}
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
pub fn upsert_in_config_for(
config: &mut MultiAppConfig,
app: &AppType,
id: &str,
spec: Value,
) -> Result<bool, AppError> {
if id.trim().is_empty() {
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
}
normalize_servers_for(config, app);
validate_mcp_entry(&spec)?;
let mut entry_obj = spec
.as_object()
.cloned()
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
if let Some(existing_id) = entry_obj.get("id") {
let Some(existing_id_str) = existing_id.as_str() else {
return Err(AppError::McpValidation("MCP 服务器 id 必须为字符串".into()));
};
if existing_id_str != id {
return Err(AppError::McpValidation(format!(
"MCP 服务器条目中的 id '{existing_id_str}' 与参数 id '{id}' 不一致"
)));
}
} else {
entry_obj.insert(String::from("id"), json!(id));
}
let value = Value::Object(entry_obj);
let servers = &mut config.mcp_for_mut(app).servers;
let before = servers.get(id).cloned();
servers.insert(id.to_string(), value);
Ok(before.is_none())
}
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
pub fn delete_in_config_for(
config: &mut MultiAppConfig,
app: &AppType,
id: &str,
) -> Result<bool, AppError> {
if id.trim().is_empty() {
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
}
normalize_servers_for(config, app);
let existed = config.mcp_for_mut(app).servers.remove(id).is_some();
Ok(existed)
}
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
/// 设置启用状态(不执行落盘或文件同步)
pub fn set_enabled_flag_for(
config: &mut MultiAppConfig,
app: &AppType,
id: &str,
enabled: bool,
) -> Result<bool, AppError> {
if id.trim().is_empty() {
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
}
normalize_servers_for(config, app);
if let Some(spec) = config.mcp_for_mut(app).servers.get_mut(id) {
// 写入 enabled 字段
let mut obj = spec
.as_object()
.cloned()
.ok_or_else(|| AppError::McpValidation("MCP 服务器定义必须为 JSON 对象".into()))?;
obj.insert("enabled".into(), json!(enabled));
*spec = Value::Object(obj);
} else {
// 若不存在则直接返回 false
return Ok(false);
}
Ok(true)
}
/// 将 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> {
use crate::app_config::{McpApps, McpServer};
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)
}
/// 从 ~/.codex/config.toml 导入 MCP 到统一结构(v3.7.0+
///
/// 格式支持:
@@ -407,8 +50,6 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
///
/// 已存在的服务器将启用 Codex 应用,不覆盖其他字段和应用状态
pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError> {
use crate::app_config::{McpApps, McpServer};
let text = crate::codex_config::read_and_validate_codex_config_text()?;
if text.trim().is_empty() {
return Ok(0);
@@ -638,6 +279,9 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
/// - 仅更新 `mcp_servers` 表,保留其它键
/// - 仅写入启用项;无启用项时清理 mcp_servers 表
pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
if !should_sync_codex_mcp() {
return Ok(());
}
use toml_edit::{Item, Table};
// 1) 收集启用项(Codex 维度)
@@ -697,111 +341,101 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
Ok(())
}
/// 将 config.json 中 enabled==true 的项投影写入 ~/.gemini/settings.json
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/settings.json 导入 mcpServers 到统一结构(v3.7.0+
/// 已存在的服务器将启用 Gemini 应用,不覆盖其他字段和应用状态
pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError> {
use crate::app_config::{McpApps, McpServer};
let text_opt = crate::gemini_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!("解析 ~/.gemini/settings.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) {
// 已存在:仅启用 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)
}
// ============================================================================
// v3.7.0 新增:单个服务器同步和删除函数
// ============================================================================
/// 将单个 MCP 服务器同步到 Claude live 配置
pub fn sync_single_server_to_claude(
/// 将单个 MCP 服务器同步到 Codex live 配置
/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers]
pub fn sync_single_server_to_codex(
_config: &MultiAppConfig,
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
// 读取现有的 MCP 配置
let current = crate::claude_mcp::read_mcp_servers_map()?;
if !should_sync_codex_mcp() {
return Ok(());
}
use toml_edit::Item;
// 创建新的 HashMap,包含现有的所有服务器 + 当前要同步的服务器
let mut updated = current;
updated.insert(id.to_string(), server_spec.clone());
// 读取现有的 config.toml
let config_path = crate::codex_config::get_codex_config_path();
// 写回
crate::claude_mcp::set_mcp_servers_map(&updated)
let mut doc = if config_path.exists() {
let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
content
.parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?
} else {
toml_edit::DocumentMut::new()
};
// 清理可能存在的错误格式 [mcp.servers]
if let Some(mcp_item) = doc.get_mut("mcp") {
if let Some(tbl) = mcp_item.as_table_like_mut() {
if tbl.contains_key("servers") {
log::warn!("检测到错误的 MCP 格式 [mcp.servers],正在清理并迁移到 [mcp_servers]");
tbl.remove("servers");
}
}
}
// 确保 [mcp_servers] 表存在
if !doc.contains_key("mcp_servers") {
doc["mcp_servers"] = toml_edit::table();
}
// 将 JSON 服务器规范转换为 TOML 表
let toml_table = json_server_to_toml_table(server_spec)?;
// 使用唯一正确的格式:[mcp_servers]
doc["mcp_servers"][id] = Item::Table(toml_table);
// 写回文件
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
Ok(())
}
/// 从 Claude live 配置中移除单个 MCP 服务器
pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> {
// 读取现有的 MCP 配置
let mut current = crate::claude_mcp::read_mcp_servers_map()?;
/// 从 Codex live 配置中移除单个 MCP 服务器
/// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据
pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
if !should_sync_codex_mcp() {
return Ok(());
}
let config_path = crate::codex_config::get_codex_config_path();
// 移除指定服务器
current.remove(id);
if !config_path.exists() {
return Ok(()); // 文件不存在,无需删除
}
// 写回
crate::claude_mcp::set_mcp_servers_map(&current)
let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
let mut doc = content
.parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?;
// 从正确的位置删除:[mcp_servers]
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
mcp_servers.remove(id);
}
// 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在)
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) {
if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) {
if servers.remove(id).is_some() {
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
}
}
}
// 写回文件
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
Ok(())
}
// ============================================================================
// TOML 转换辅助函数
// ============================================================================
/// 通用 JSON 值到 TOML 值转换器(支持简单类型和浅层嵌套)
///
/// 支持的类型转换:
@@ -1030,117 +664,3 @@ fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError>
Ok(t)
}
/// 将单个 MCP 服务器同步到 Codex live 配置
/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers]
pub fn sync_single_server_to_codex(
_config: &MultiAppConfig,
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
use toml_edit::Item;
// 读取现有的 config.toml
let config_path = crate::codex_config::get_codex_config_path();
let mut doc = if config_path.exists() {
let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
content
.parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?
} else {
toml_edit::DocumentMut::new()
};
// 清理可能存在的错误格式 [mcp.servers]
if let Some(mcp_item) = doc.get_mut("mcp") {
if let Some(tbl) = mcp_item.as_table_like_mut() {
if tbl.contains_key("servers") {
log::warn!("检测到错误的 MCP 格式 [mcp.servers],正在清理并迁移到 [mcp_servers]");
tbl.remove("servers");
}
}
}
// 确保 [mcp_servers] 表存在
if !doc.contains_key("mcp_servers") {
doc["mcp_servers"] = toml_edit::table();
}
// 将 JSON 服务器规范转换为 TOML 表
let toml_table = json_server_to_toml_table(server_spec)?;
// 使用唯一正确的格式:[mcp_servers]
doc["mcp_servers"][id] = Item::Table(toml_table);
// 写回文件
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
Ok(())
}
/// 从 Codex live 配置中移除单个 MCP 服务器
/// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据
pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
let config_path = crate::codex_config::get_codex_config_path();
if !config_path.exists() {
return Ok(()); // 文件不存在,无需删除
}
let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
let mut doc = content
.parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?;
// 从正确的位置删除:[mcp_servers]
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
mcp_servers.remove(id);
}
// 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在)
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) {
if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) {
if servers.remove(id).is_some() {
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
}
}
}
// 写回文件
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
Ok(())
}
/// 将单个 MCP 服务器同步到 Gemini live 配置
pub fn sync_single_server_to_gemini(
_config: &MultiAppConfig,
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
// 读取现有的 MCP 配置
let current = crate::gemini_mcp::read_mcp_servers_map()?;
// 创建新的 HashMap,包含现有的所有服务器 + 当前要同步的服务器
let mut updated = current;
updated.insert(id.to_string(), server_spec.clone());
// 写回
crate::gemini_mcp::set_mcp_servers_map(&updated)
}
/// 从 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)
}
+141
View File
@@ -0,0 +1,141 @@
//! 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};
fn should_sync_gemini_mcp() -> bool {
// Gemini 未安装/未初始化时:~/.gemini 目录不存在。
// 按用户偏好:目录缺失时跳过写入/删除,不创建任何文件或目录。
crate::gemini_config::get_gemini_dir().exists()
}
/// 返回已启用的 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> {
if !should_sync_gemini_mcp() {
return Ok(());
}
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> {
if !should_sync_gemini_mcp() {
return Ok(());
}
// 读取现有的 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> {
if !should_sync_gemini_mcp() {
return Ok(());
}
// 读取现有的 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
@@ -0,0 +1,28 @@
//! 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
@@ -0,0 +1,69 @@
//! 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())
}
-41
View File
@@ -13,8 +13,6 @@ use serde::Deserialize;
use serde_json::Value;
use crate::app_config::AppType;
use crate::codex_config::write_codex_live_atomic;
use crate::config::{get_claude_settings_path, write_json_file};
use crate::error::AppError;
use crate::provider::{Provider, UsageResult};
use crate::services::mcp::McpService;
@@ -329,36 +327,6 @@ impl ProviderService {
.await
}
#[allow(dead_code)]
fn write_codex_live(provider: &Provider) -> Result<(), AppError> {
let settings = provider
.settings_config
.as_object()
.ok_or_else(|| AppError::Config("Codex 配置必须是 JSON 对象".into()))?;
let auth = settings
.get("auth")
.ok_or_else(|| AppError::Config(format!("供应商 {} 缺少 auth 配置", provider.id)))?;
if !auth.is_object() {
return Err(AppError::Config(format!(
"供应商 {} 的 auth 必须是对象",
provider.id
)));
}
let cfg_text = settings.get("config").and_then(Value::as_str);
write_codex_live_atomic(auth, cfg_text)?;
Ok(())
}
#[allow(dead_code)]
fn write_claude_live(provider: &Provider) -> Result<(), AppError> {
let settings_path = get_claude_settings_path();
let mut content = provider.settings_config.clone();
let _ = normalize_claude_models_in_value(&mut content);
write_json_file(&settings_path, &content)?;
Ok(())
}
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
write_gemini_live(provider)
}
@@ -557,15 +525,6 @@ impl ProviderService {
}
}
}
#[allow(dead_code)]
fn app_not_found(app_type: &AppType) -> AppError {
AppError::localized(
"provider.app_not_found",
format!("应用类型不存在: {app_type:?}"),
format!("App type not found: {app_type:?}"),
)
}
}
/// Normalize Claude model keys in a JSON value
+32 -48
View File
@@ -34,9 +34,6 @@ pub struct Skill {
/// 分支名称
#[serde(rename = "repoBranch")]
pub repo_branch: Option<String>,
/// 技能所在的子目录路径 (可选, 如 "skills")
#[serde(rename = "skillsPath")]
pub skills_path: Option<String>,
}
/// 仓库配置
@@ -50,9 +47,6 @@ pub struct SkillRepo {
pub branch: String,
/// 是否启用
pub enabled: bool,
/// 技能所在的子目录路径 (可选, 如 "skills", "my-skills/subdir")
#[serde(rename = "skillsPath")]
pub skills_path: Option<String>,
}
/// 技能安装状态
@@ -84,21 +78,18 @@ impl Default for SkillStore {
name: "awesome-claude-skills".to_string(),
branch: "main".to_string(),
enabled: true,
skills_path: None, // 扫描根目录
},
SkillRepo {
owner: "anthropics".to_string(),
name: "skills".to_string(),
branch: "main".to_string(),
enabled: true,
skills_path: None, // 扫描根目录
},
SkillRepo {
owner: "cexll".to_string(),
name: "myclaude".to_string(),
branch: "master".to_string(),
enabled: true,
skills_path: Some("skills".to_string()), // 扫描 skills 子目录
},
],
}
@@ -194,25 +185,8 @@ impl SkillService {
})??;
let mut skills = Vec::new();
// 确定要扫描的目录路径
let scan_dir = if let Some(ref skills_path) = repo.skills_path {
// 如果指定了 skillsPath,则扫描该子目录
let subdir = temp_dir.join(skills_path.trim_matches('/'));
if !subdir.exists() {
log::warn!(
"仓库 {}/{} 中指定的技能路径 '{}' 不存在",
repo.owner,
repo.name,
skills_path
);
let _ = fs::remove_dir_all(&temp_dir);
return Ok(skills);
}
subdir
} else {
// 否则扫描仓库根目录
temp_dir.clone()
};
// 扫描仓库根目录(支持全仓库递归扫描)
let scan_dir = temp_dir.clone();
// 递归扫描目录查找所有技能
self.scan_dir_recursive(&scan_dir, &scan_dir, repo, &mut skills)?;
@@ -284,11 +258,7 @@ impl SkillService {
let meta = self.parse_skill_metadata(skill_md)?;
// 构建 README URL
let readme_path = if let Some(ref skills_path) = repo.skills_path {
format!("{}/{}", skills_path.trim_matches('/'), directory)
} else {
directory.to_string()
};
let readme_path = directory.to_string();
Ok(Skill {
key: format!("{}/{}:{}", repo.owner, repo.name, directory),
@@ -303,7 +273,6 @@ impl SkillService {
repo_owner: Some(repo.owner.clone()),
repo_name: Some(repo.name.clone()),
repo_branch: Some(repo.branch.clone()),
skills_path: repo.skills_path.clone(),
})
}
@@ -347,9 +316,20 @@ impl SkillService {
let directory = &local_skill.directory;
// 更新已安装状态(匹配远程技能)
// 使用目录最后一段进行比较,因为安装时只使用最后一段作为目录名
let mut found = false;
let local_install_name = Path::new(directory)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| directory.clone());
for skill in skills.iter_mut() {
if skill.directory.eq_ignore_ascii_case(directory) {
let remote_install_name = Path::new(&skill.directory)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| skill.directory.clone());
if remote_install_name.eq_ignore_ascii_case(&local_install_name) {
skill.installed = true;
found = true;
break;
@@ -405,7 +385,6 @@ impl SkillService {
repo_owner: None,
repo_name: None,
repo_branch: None,
skills_path: None,
});
}
@@ -549,7 +528,14 @@ impl SkillService {
/// 安装技能(仅负责下载和文件操作,状态更新由上层负责)
pub async fn install_skill(&self, directory: String, repo: SkillRepo) -> Result<()> {
let dest = self.install_dir.join(&directory);
// 使用技能目录的最后一段作为安装目录名,避免嵌套路径问题
// 例如: "skills/codex" -> "codex"
let install_name = Path::new(&directory)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| directory.clone());
let dest = self.install_dir.join(&install_name);
// 若目标目录已存在,则视为已安装,避免重复下载
if dest.exists() {
@@ -574,16 +560,8 @@ impl SkillService {
))
})??;
// 根据 skills_path 确定源目录路径
let source = if let Some(ref skills_path) = repo.skills_path {
// 如果指定了 skills_path,源路径为: temp_dir/skills_path/directory
temp_dir
.join(skills_path.trim_matches('/'))
.join(&directory)
} else {
// 否则源路径为: temp_dir/directory
temp_dir.join(&directory)
};
// 确定源目录路径(技能相对于仓库根目录的路径)
let source = temp_dir.join(&directory);
if !source.exists() {
let _ = fs::remove_dir_all(&temp_dir);
@@ -629,7 +607,13 @@ impl SkillService {
/// 卸载技能(仅负责文件操作,状态更新由上层负责)
pub fn uninstall_skill(&self, directory: String) -> Result<()> {
let dest = self.install_dir.join(&directory);
// 使用技能目录的最后一段作为安装目录名,与 install_skill 保持一致
let install_name = Path::new(&directory)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| directory.clone());
let dest = self.install_dir.join(&install_name);
if dest.exists() {
fs::remove_dir_all(&dest)?;
+8
View File
@@ -31,6 +31,9 @@ pub struct AppSettings {
/// 是否启用 Claude 插件联动
#[serde(default)]
pub enable_claude_plugin_integration: bool,
/// 是否跳过 Claude Code 初次安装确认
#[serde(default = "default_true")]
pub skip_claude_onboarding: bool,
/// 是否开机自启
#[serde(default)]
pub launch_on_startup: bool,
@@ -65,12 +68,17 @@ fn default_minimize_to_tray_on_close() -> bool {
true
}
fn default_true() -> bool {
true
}
impl Default for AppSettings {
fn default() -> Self {
Self {
show_in_tray: true,
minimize_to_tray_on_close: true,
enable_claude_plugin_integration: false,
skip_claude_onboarding: true,
launch_on_startup: false,
language: None,
claude_config_dir: None,
+300
View File
@@ -0,0 +1,300 @@
//! 托盘菜单管理模块
//!
//! 负责系统托盘图标和菜单的创建、更新和事件处理。
use tauri::menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem};
use tauri::{Emitter, Manager};
use crate::app_config::AppType;
use crate::error::AppError;
use crate::store::AppState;
/// 托盘菜单文本(国际化)
#[derive(Clone, Copy)]
pub struct TrayTexts {
pub show_main: &'static str,
pub no_provider_hint: &'static str,
pub quit: &'static str,
}
impl TrayTexts {
pub 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",
},
"ja" => Self {
show_main: "メインウィンドウを開く",
no_provider_hint:
" (プロバイダーがまだありません。メイン画面から追加してください)",
quit: "終了",
},
_ => Self {
show_main: "打开主界面",
no_provider_hint: " (无供应商,请在主界面添加)",
quit: "退出",
},
}
}
}
/// 托盘应用分区配置
pub struct TrayAppSection {
pub app_type: AppType,
pub prefix: &'static str,
pub header_id: &'static str,
pub empty_id: &'static str,
pub header_label: &'static str,
pub log_name: &'static str,
}
pub 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)
}
/// 处理供应商托盘事件
pub 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
}
/// 创建动态托盘菜单
pub 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 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() {
let app_type_str = section.app_type.as_str();
let providers = app_state.db.get_all_providers(app_type_str)?;
// 使用有效的当前供应商 ID(验证存在性,自动清理失效 ID)
let current_id =
crate::settings::get_effective_current_provider(&app_state.db, &section.app_type)?
.unwrap_or_default();
let manager = crate::provider::ProviderManager {
providers,
current: current_id,
};
menu_builder =
append_provider_section(app, menu_builder, Some(&manager), 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")]
pub fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
use tauri::ActivationPolicy;
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}");
}
}
/// 处理托盘菜单事件
pub 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}");
}
}
}
/// 内部切换供应商函数
pub fn switch_provider_internal(
app: &tauri::AppHandle,
app_type: 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(())
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.7.1",
"version": "3.8.3",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
+84 -1
View File
@@ -154,6 +154,12 @@ fn sync_enabled_to_codex_writes_enabled_servers() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
// 模拟 Codex 已安装/已初始化:存在 ~/.codex 目录
let path = cc_switch_lib::get_codex_config_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create codex dir");
}
let mut config = MultiAppConfig::default();
config.mcp.codex.servers.insert(
"stdio-enabled".into(),
@@ -170,7 +176,6 @@ fn sync_enabled_to_codex_writes_enabled_servers() {
cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex");
let path = cc_switch_lib::get_codex_config_path();
assert!(path.exists(), "config.toml should be created");
let text = fs::read_to_string(&path).expect("read config.toml");
assert!(
@@ -594,6 +599,11 @@ command = "echo"
fn sync_claude_enabled_mcp_projects_to_user_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 模拟 Claude 已安装/已初始化:存在 ~/.claude 目录
fs::create_dir_all(home.join(".claude")).expect("create claude dir");
let mut config = MultiAppConfig::default();
config.mcp.claude.servers.insert(
@@ -993,3 +1003,76 @@ fn export_sql_returns_error_for_invalid_path() {
other => panic!("expected IoContext or Io error, got {other:?}"),
}
}
#[test]
fn import_sql_rejects_non_cc_switch_backup() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let state = create_test_state().expect("create test state");
let import_path = home.join("not-cc-switch.sql");
fs::write(&import_path, "CREATE TABLE x (id INTEGER);").expect("write import sql");
let err = state
.db
.import_sql(&import_path)
.expect_err("non-cc-switch sql should be rejected");
match err {
AppError::Localized { key, .. } => {
assert_eq!(key, "backup.sql.invalid_format");
}
other => panic!("expected Localized error, got {other:?}"),
}
}
#[test]
fn import_sql_accepts_cc_switch_exported_backup() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// Create a database with some data and export it.
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "test-provider".to_string();
manager.providers.insert(
"test-provider".to_string(),
Provider::with_id(
"test-provider".to_string(),
"Test Provider".to_string(),
json!({"env": {"ANTHROPIC_API_KEY": "test-key"}}),
None,
),
);
}
let state = create_test_state_with_config(&config).expect("create test state");
let export_path = home.join("cc-switch-export.sql");
state
.db
.export_sql(&export_path)
.expect("export should succeed");
// Reset database, then import into a fresh one.
reset_test_fs();
let state = create_test_state().expect("create test state");
state
.db
.import_sql(&export_path)
.expect("import should succeed");
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("load providers");
assert!(
providers.contains_key("test-provider"),
"imported providers should contain test-provider"
);
}
+308
View File
@@ -246,3 +246,311 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
"codex config should include the enabled server definition"
);
}
#[test]
fn enabling_codex_mcp_skips_when_codex_dir_missing() {
use support::create_test_state;
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 确认 Codex 配置目录不存在(模拟“未安装/未运行过 Codex CLI”)
assert!(
!home.join(".codex").exists(),
"~/.codex should not exist in fresh test environment"
);
let state = create_test_state().expect("create test state");
// 先插入一个未启用 Codex 的 MCP 服务器(避免 upsert 触发同步)
McpService::upsert_server(
&state,
McpServer {
id: "codex-server".to_string(),
name: "Codex Server".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("insert server without syncing");
// 启用 Codex:目录缺失时应跳过写入(不创建 ~/.codex/config.toml
McpService::toggle_app(&state, "codex-server", AppType::Codex, true)
.expect("toggle codex should succeed even when ~/.codex is missing");
assert!(
!home.join(".codex").exists(),
"~/.codex should still not exist after skipped sync"
);
}
#[test]
fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 模拟 Claude 已安装/已初始化:存在 ~/.claude 目录
fs::create_dir_all(home.join(".claude")).expect("create ~/.claude dir");
// 先创建一个启用 Claude 的 MCP 服务器
let state = support::create_test_state().expect("create test state");
McpService::upsert_server(
&state,
McpServer {
id: "echo".to_string(),
name: "echo".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: true,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("upsert should sync to Claude live config");
// 确认已写入 ~/.claude.json
let mcp_path = get_claude_mcp_path();
let text = fs::read_to_string(&mcp_path).expect("read ~/.claude.json");
let v: serde_json::Value = serde_json::from_str(&text).expect("parse ~/.claude.json");
assert!(
v.pointer("/mcpServers/echo").is_some(),
"echo should exist in Claude live config after enabling"
);
// 再次 upsert:取消勾选 Claudeapps.claude=false),应从 Claude live 配置中移除
McpService::upsert_server(
&state,
McpServer {
id: "echo".to_string(),
name: "echo".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("upsert disabling app should remove from Claude live config");
let text = fs::read_to_string(&mcp_path).expect("read ~/.claude.json after disable");
let v: serde_json::Value = serde_json::from_str(&text).expect("parse ~/.claude.json");
assert!(
v.pointer("/mcpServers/echo").is_none(),
"echo should be removed from Claude live config after disabling"
);
}
#[test]
fn import_mcp_from_multiple_apps_merges_enabled_flags() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 1) Claude: ~/.claude.json
let mcp_path = get_claude_mcp_path();
let claude_json = json!({
"mcpServers": {
"shared": {
"type": "stdio",
"command": "echo"
}
}
});
fs::write(
&mcp_path,
serde_json::to_string_pretty(&claude_json).expect("serialize claude mcp"),
)
.expect("seed ~/.claude.json");
// 2) Codex: ~/.codex/config.toml
let codex_dir = home.join(".codex");
fs::create_dir_all(&codex_dir).expect("create codex dir");
fs::write(
codex_dir.join("config.toml"),
r#"[mcp_servers.shared]
type = "stdio"
command = "echo"
"#,
)
.expect("seed ~/.codex/config.toml");
let state = support::create_test_state().expect("create test state");
McpService::import_from_claude(&state).expect("import from claude");
McpService::import_from_codex(&state).expect("import from codex");
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let entry = servers.get("shared").expect("shared server exists");
assert!(entry.apps.claude, "shared should enable Claude");
assert!(entry.apps.codex, "shared should enable Codex");
}
#[test]
fn import_mcp_from_gemini_sse_url_only_is_valid() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// Gemini MCP 位于 ~/.gemini/settings.json
let gemini_dir = home.join(".gemini");
fs::create_dir_all(&gemini_dir).expect("create gemini dir");
let settings_path = gemini_dir.join("settings.json");
// Gemini SSE:只包含 urlGemini 不使用 type 字段)
let gemini_settings = json!({
"mcpServers": {
"sse-server": {
"url": "https://example.com/sse"
}
}
});
fs::write(
&settings_path,
serde_json::to_string_pretty(&gemini_settings).expect("serialize gemini settings"),
)
.expect("seed ~/.gemini/settings.json");
let state = support::create_test_state().expect("create test state");
let changed = McpService::import_from_gemini(&state).expect("import from gemini");
assert!(changed > 0, "should import at least 1 server");
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let entry = servers.get("sse-server").expect("sse-server exists");
assert!(entry.apps.gemini, "imported server should enable Gemini");
assert_eq!(
entry.server.get("type").and_then(|v| v.as_str()),
Some("sse"),
"Gemini url-only server should be normalized to type=sse in unified structure"
);
}
#[test]
fn enabling_gemini_mcp_skips_when_gemini_dir_missing() {
use support::create_test_state;
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 确认 Gemini 配置目录不存在(模拟“未安装/未运行过 Gemini CLI”)
assert!(
!home.join(".gemini").exists(),
"~/.gemini should not exist in fresh test environment"
);
let state = create_test_state().expect("create test state");
// 先插入一个未启用 Gemini 的 MCP 服务器(避免 upsert 触发同步)
McpService::upsert_server(
&state,
McpServer {
id: "gemini-server".to_string(),
name: "Gemini Server".to_string(),
server: json!({
"type": "sse",
"url": "https://example.com/sse"
}),
apps: McpApps {
claude: false,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("insert server without syncing");
// 启用 Gemini:目录缺失时应跳过写入(不创建 ~/.gemini/settings.json
McpService::toggle_app(&state, "gemini-server", AppType::Gemini, true)
.expect("toggle gemini should succeed even when ~/.gemini is missing");
assert!(
!home.join(".gemini").exists(),
"~/.gemini should still not exist after skipped sync"
);
}
#[test]
fn enabling_claude_mcp_skips_when_claude_config_absent() {
use support::create_test_state;
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 确认 Claude 相关目录/文件都不存在(模拟“未安装/未运行过 Claude”)
assert!(
!home.join(".claude").exists(),
"~/.claude should not exist in fresh test environment"
);
assert!(
!home.join(".claude.json").exists(),
"~/.claude.json should not exist in fresh test environment"
);
let state = create_test_state().expect("create test state");
// 先插入一个未启用 Claude 的 MCP 服务器(避免 upsert 触发同步)
McpService::upsert_server(
&state,
McpServer {
id: "claude-server".to_string(),
name: "Claude Server".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("insert server without syncing");
// 启用 Claude:配置缺失时应跳过写入(不创建 ~/.claude.json
McpService::toggle_app(&state, "claude-server", AppType::Claude, true)
.expect("toggle claude should succeed even when ~/.claude is missing");
assert!(
!home.join(".claude.json").exists(),
"~/.claude.json should still not exist after skipped sync"
);
}
+50 -28
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { invoke } from "@tauri-apps/api/core";
import { useQueryClient } from "@tanstack/react-query";
import {
Plus,
Settings,
@@ -43,8 +44,14 @@ import { Button } from "@/components/ui/button";
type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents";
// 顶部拖拽区域和 header 的高度常量
const DRAG_BAR_HEIGHT = 28; // px
const HEADER_HEIGHT = 64; // px
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
function App() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [activeApp, setActiveApp] = useState<AppId>("claude");
const [currentView, setCurrentView] = useState<View>("providers");
@@ -258,7 +265,20 @@ function App() {
// 导入配置成功后刷新
const handleImportSuccess = async () => {
await refetch();
try {
// 导入会影响所有应用的供应商数据:刷新所有 providers 缓存
await queryClient.invalidateQueries({
queryKey: ["providers"],
refetchType: "all",
});
await queryClient.refetchQueries({
queryKey: ["providers"],
type: "all",
});
} catch (error) {
console.error("[App] Failed to refresh providers after import", error);
await refetch();
}
try {
await providersApi.updateTrayMenu();
} catch (error) {
@@ -303,20 +323,25 @@ function App() {
return <AgentsPanel onOpenChange={() => setCurrentView("providers")} />;
default:
return (
<div className="mx-auto max-w-[56rem] px-6 space-y-4">
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
appId={activeApp}
isLoading={isLoading}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
onCreate={() => setIsAddOpen(true)}
/>
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
<div className="space-y-4">
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
appId={activeApp}
isLoading={isLoading}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
onCreate={() => setIsAddOpen(true)}
/>
</div>
</div>
</div>
);
}
@@ -325,7 +350,7 @@ function App() {
return (
<div
className="flex min-h-screen flex-col bg-background text-foreground selection:bg-primary/30"
style={{ overflowX: "hidden" }}
style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }}
>
{/* 全局拖拽区域(顶部 4px),避免上边框无法拖动 */}
<div
@@ -391,7 +416,7 @@ function App() {
t("prompts.title", { appName: t(`apps.${activeApp}`) })}
{currentView === "skills" && t("skills.title")}
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
{currentView === "agents" && "Agents"}
{currentView === "agents" && t("agents.title")}
</h1>
</div>
) : (
@@ -421,14 +446,14 @@ function App() {
</div>
<div
className="flex items-center gap-2"
className="flex items-center gap-2 h-[32px]"
style={{ WebkitAppRegion: "no-drag" } as any}
>
{currentView === "prompts" && (
<Button
size="icon"
onClick={() => promptPanelRef.current?.openAdd()}
className={addActionButtonClass}
className={`ml-auto ${addActionButtonClass}`}
title={t("prompts.add")}
>
<Plus className="h-5 w-5" />
@@ -438,7 +463,7 @@ function App() {
<Button
size="icon"
onClick={() => mcpPanelRef.current?.openAdd()}
className={addActionButtonClass}
className={`ml-auto ${addActionButtonClass}`}
title={t("mcp.unifiedPanel.addServer")}
>
<Plus className="h-5 w-5" />
@@ -512,7 +537,7 @@ function App() {
size="sm"
onClick={() => setCurrentView("mcp")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="MCP"
title={t("mcp.title")}
>
<Server className="h-4 w-4" />
</Button>
@@ -531,13 +556,10 @@ function App() {
</div>
</header>
<main
className={`flex-1 overflow-y-auto pb-12 animate-fade-in scroll-overlay ${
currentView === "providers" ? "pt-24" : "pt-20"
}`}
style={{ overflowX: "hidden" }}
>
{renderContent()}
<main className="flex-1 pb-12 animate-fade-in ">
<div className="pb-12">
{renderContent()}
</div>
</main>
<AddProviderDialog
+1 -5
View File
@@ -40,11 +40,7 @@ export function ConfirmDialog({
}
}}
>
<DialogContent
className="max-w-sm"
zIndex="alert"
overlayClassName="bg-background/80"
>
<DialogContent className="max-w-sm" zIndex="alert">
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
<AlertTriangle className="h-5 w-5 text-destructive" />
+1 -1
View File
@@ -56,7 +56,7 @@ export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
"
aria-label={t("common.close")}
>
<X className="w-3 h-3 text-gray-400 dark:text-gray-500" />
<X className="w-3 h-3 text-muted-foreground" />
</button>
</div>
);
+3 -3
View File
@@ -114,7 +114,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
{/* 第一行:更新时间和刷新按钮 */}
<div className="flex items-center gap-2 justify-end">
{/* 上次查询时间 */}
<span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1">
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
<Clock size={10} />
{lastQueriedAt
? formatRelativeTime(lastQueriedAt, now, t)
@@ -128,7 +128,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
refetch();
}}
disabled={loading}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0 text-gray-400 dark:text-gray-500"
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0 text-muted-foreground"
title={t("usage.refreshUsage")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
@@ -191,7 +191,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
<div className="flex items-center gap-2">
{/* 自动查询时间提示 */}
{lastQueriedAt && (
<span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1">
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
<Clock size={10} />
{formatRelativeTime(lastQueriedAt, now, t)}
</span>
+5 -16
View File
@@ -30,22 +30,11 @@ export function SkillConfirmation({
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-muted-foreground">
{t("deeplink.skill.branch")}
</label>
<div className="mt-1 text-sm">{request.branch || "main"}</div>
</div>
{request.skillsPath && (
<div>
<label className="block text-sm font-medium text-muted-foreground">
{t("deeplink.skill.skillsPath")}
</label>
<div className="mt-1 text-sm">{request.skillsPath}</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-muted-foreground">
{t("deeplink.skill.branch")}
</label>
<div className="mt-1 text-sm">{request.branch || "main"}</div>
</div>
<div className="text-blue-600 dark:text-blue-400 text-sm bg-blue-50 dark:bg-blue-950/30 p-3 rounded border border-blue-200 dark:border-blue-800">
+1 -1
View File
@@ -197,7 +197,7 @@ export function EnvWarningBanner({
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 break-all">
{t("env.field.value")}: {conflict.varValue}
</p>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
<p className="text-xs text-muted-foreground mt-1">
{t("env.field.source")}:{" "}
{getSourceDescription(conflict)}
</p>
+28 -22
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
@@ -224,16 +225,21 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{t("mcp.wizard.title")}</DialogTitle>
<DialogContent
className="max-w-2xl max-h-[90vh] flex flex-col"
zIndex="alert"
>
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
<DialogTitle className="text-lg font-semibold">
{t("mcp.wizard.title")}
</DialogTitle>
</DialogHeader>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* Hint */}
<div className="rounded-lg border border-border-active bg-blue-50 p-3 dark:bg-blue-900/20">
<p className="text-sm text-blue-800 dark:text-blue-200">
<div className="rounded-lg border border-border-default bg-gray-100/50 dark:bg-gray-800/50 p-3">
<p className="text-sm text-gray-500 dark:text-gray-400">
{t("mcp.wizard.hint")}
</p>
</div>
@@ -254,7 +260,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
onChange={(e) =>
setWizardType(e.target.value as "stdio" | "http" | "sse")
}
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
className="w-4 h-4 accent-blue-500"
/>
<span className="text-sm text-gray-900 dark:text-gray-100">
{t("mcp.wizard.typeStdio")}
@@ -268,7 +274,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
onChange={(e) =>
setWizardType(e.target.value as "stdio" | "http" | "sse")
}
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
className="w-4 h-4 accent-blue-500"
/>
<span className="text-sm text-gray-900 dark:text-gray-100">
{t("mcp.wizard.typeHttp")}
@@ -282,7 +288,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
onChange={(e) =>
setWizardType(e.target.value as "stdio" | "http" | "sse")
}
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
className="w-4 h-4 accent-blue-500"
/>
<span className="text-sm text-gray-900 dark:text-gray-100">
{t("mcp.wizard.typeSse")}
@@ -296,13 +302,13 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.form.title")} <span className="text-red-500">*</span>
</label>
<input
<Input
type="text"
value={wizardTitle}
onChange={(e) => setWizardTitle(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("mcp.form.titlePlaceholder")}
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100"
className="font-mono"
/>
</div>
@@ -315,13 +321,13 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
{t("mcp.wizard.command")}{" "}
<span className="text-red-500">*</span>
</label>
<input
<Input
type="text"
value={wizardCommand}
onChange={(e) => setWizardCommand(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("mcp.wizard.commandPlaceholder")}
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100"
className="font-mono"
/>
</div>
@@ -335,7 +341,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
onChange={(e) => setWizardArgs(e.target.value)}
placeholder={t("mcp.wizard.argsPlaceholder")}
rows={3}
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100 resize-y"
className="w-full rounded-md border border-border-default bg-white dark:bg-gray-800 px-3 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
/>
</div>
@@ -349,7 +355,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
onChange={(e) => setWizardEnv(e.target.value)}
placeholder={t("mcp.wizard.envPlaceholder")}
rows={3}
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100 resize-y"
className="w-full rounded-md border border-border-default bg-white dark:bg-gray-800 px-3 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
/>
</div>
</>
@@ -364,13 +370,13 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
{t("mcp.wizard.url")}{" "}
<span className="text-red-500">*</span>
</label>
<input
<Input
type="text"
value={wizardUrl}
onChange={(e) => setWizardUrl(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("mcp.wizard.urlPlaceholder")}
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100"
className="font-mono"
/>
</div>
@@ -384,7 +390,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
onChange={(e) => setWizardHeaders(e.target.value)}
placeholder={t("mcp.wizard.headersPlaceholder")}
rows={3}
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100 resize-y"
className="w-full rounded-md border border-border-default bg-white dark:bg-gray-800 px-3 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
/>
</div>
</>
@@ -397,11 +403,11 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
wizardEnv ||
wizardUrl ||
wizardHeaders) && (
<div className="space-y-2 border-t border-border-default pt-4 ">
<div className="space-y-2 border-t border-border-default pt-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.preview")}
</h3>
<pre className="overflow-x-auto rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
<pre className="overflow-x-auto rounded-lg bg-gray-100 dark:bg-gray-800 p-3 text-xs font-mono text-gray-700 dark:text-gray-300">
{preview}
</pre>
</div>
@@ -409,11 +415,11 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
</div>
{/* Footer */}
<DialogFooter className="gap-3 pt-4">
<Button type="button" variant="ghost" onClick={handleClose}>
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
<Button variant="outline" onClick={handleClose}>
{t("common.cancel")}
</Button>
<Button type="button" variant="mcp" onClick={handleApply}>
<Button variant="mcp" onClick={handleApply}>
<Save className="h-4 w-4" />
{t("mcp.wizard.apply")}
</Button>
+11 -11
View File
@@ -129,18 +129,18 @@ const UnifiedMcpPanel = React.forwardRef<
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
{isLoading ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
<div className="text-center py-12 text-muted-foreground">
{t("mcp.loading")}
</div>
) : serverEntries.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<Server size={24} className="text-gray-400 dark:text-gray-500" />
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
<Server size={24} className="text-muted-foreground" />
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
<h3 className="text-lg font-medium text-foreground mb-2">
{t("mcp.unifiedPanel.noServers")}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
<p className="text-muted-foreground text-sm">
{t("mcp.emptyDescription")}
</p>
</div>
@@ -237,7 +237,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
{/* 左侧:服务器信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
<h3 className="font-medium text-foreground">
{name}
</h3>
{docsUrl && (
@@ -253,12 +253,12 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
)}
</div>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
<p className="text-sm text-muted-foreground line-clamp-2">
{description}
</p>
)}
{!description && tags && tags.length > 0 && (
<p className="text-xs text-gray-400 dark:text-gray-500 truncate">
<p className="text-xs text-muted-foreground/70 truncate">
{tags.join(", ")}
</p>
)}
@@ -269,7 +269,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-claude`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
className="text-sm text-foreground/80 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.claude")}
</label>
@@ -285,7 +285,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-codex`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
className="text-sm text-foreground/80 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.codex")}
</label>
@@ -301,7 +301,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-gemini`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
className="text-sm text-foreground/80 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.gemini")}
</label>
+2 -2
View File
@@ -36,11 +36,11 @@ const PromptListItem: React.FC<PromptListItemProps> = ({
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-1">
<h3 className="font-medium text-foreground mb-1">
{prompt.name}
</h3>
{prompt.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">
<p className="text-sm text-muted-foreground truncate">
{prompt.description}
</p>
)}
+5 -5
View File
@@ -108,21 +108,21 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
<div className="flex-1 overflow-y-auto pb-16">
{loading ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
<div className="text-center py-12 text-muted-foreground">
{t("prompts.loading")}
</div>
) : promptEntries.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
<FileText
size={24}
className="text-gray-400 dark:text-gray-500"
className="text-muted-foreground"
/>
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
<h3 className="text-lg font-medium text-foreground mb-2">
{t("prompts.empty")}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
<p className="text-muted-foreground text-sm">
{t("prompts.emptyDescription")}
</p>
</div>
+1 -3
View File
@@ -116,9 +116,7 @@ export function ProviderCard({
className={cn(
"glass-card relative overflow-hidden rounded-xl p-4 transition-all duration-300",
"group hover:bg-black/[0.02] dark:hover:bg-white/[0.02] hover:border-primary/50",
isCurrent
? "border-primary/50 bg-primary/5 shadow-[0_0_20px_rgba(59,130,246,0.15)]"
: "hover:scale-[1.01]",
isCurrent ? "glass-card-active" : "hover:scale-[1.01]",
dragHandleProps?.isDragging &&
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
)}
@@ -30,7 +30,7 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${
disabled
? "bg-gray-100 dark:bg-gray-800 border-border-default text-gray-400 dark:text-gray-500 cursor-not-allowed"
? "bg-muted border-border-default text-muted-foreground cursor-not-allowed"
: "border-border-default dark:bg-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20"
}`;
@@ -41,7 +41,7 @@ export function useBaseUrlState({
try {
const config = JSON.parse(settingsConfig || "{}");
const envUrl: unknown = config?.env?.ANTHROPIC_BASE_URL;
if (typeof envUrl === "string" && envUrl && envUrl !== baseUrl) {
if (typeof envUrl === "string" && envUrl && envUrl.trim() !== baseUrl) {
setBaseUrl(envUrl.trim());
}
} catch {
@@ -73,8 +73,7 @@ export function useBaseUrlState({
try {
const config = JSON.parse(settingsConfig || "{}");
const envUrl: unknown = config?.env?.GOOGLE_GEMINI_BASE_URL;
const nextUrl =
typeof envUrl === "string" ? envUrl.trim().replace(/\/+$/, "") : "";
const nextUrl = typeof envUrl === "string" ? envUrl.trim() : "";
if (nextUrl !== geminiBaseUrl) {
setGeminiBaseUrl(nextUrl);
setBaseUrl(nextUrl); // 也更新 baseUrl 用于 UI
@@ -87,7 +86,7 @@ export function useBaseUrlState({
// 处理 Claude Base URL 变化
const handleClaudeBaseUrlChange = useCallback(
(url: string) => {
const sanitized = url.trim().replace(/\/+$/, "");
const sanitized = url.trim();
setBaseUrl(sanitized);
isUpdatingRef.current = true;
@@ -112,7 +111,7 @@ export function useBaseUrlState({
// 处理 Codex Base URL 变化
const handleCodexBaseUrlChange = useCallback(
(url: string) => {
const sanitized = url.trim().replace(/\/+$/, "");
const sanitized = url.trim();
setCodexBaseUrl(sanitized);
if (!sanitized || !onCodexConfigChange) {
@@ -136,7 +135,7 @@ export function useBaseUrlState({
// 处理 Gemini Base URL 变化
const handleGeminiBaseUrlChange = useCallback(
(url: string) => {
const sanitized = url.trim().replace(/\/+$/, "");
const sanitized = url.trim();
setGeminiBaseUrl(sanitized);
setBaseUrl(sanitized); // 也更新 baseUrl 用于 UI
isUpdatingRef.current = true;
@@ -162,7 +162,7 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
// 处理 Codex Base URL 变化
const handleCodexBaseUrlChange = useCallback(
(url: string) => {
const sanitized = url.trim().replace(/\/+$/, "");
const sanitized = url.trim();
setCodexBaseUrl(sanitized);
if (!sanitized) {
+2 -2
View File
@@ -151,7 +151,7 @@ export function SettingsPage({
const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]);
return (
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] px-6">
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] overflow-hidden px-6">
{isBusy ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
@@ -172,7 +172,7 @@ export function SettingsPage({
<TabsTrigger value="about">{t("common.about")}</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-y-auto pr-2">
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-2">
<TabsContent value="general" className="space-y-6 mt-0">
{settings ? (
<>
@@ -41,6 +41,13 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
onChange({ enableClaudePluginIntegration: value })
}
/>
<ToggleRow
title={t("settings.skipClaudeOnboarding")}
description={t("settings.skipClaudeOnboardingDescription")}
checked={!!settings.skipClaudeOnboarding}
onCheckedChange={(value) => onChange({ skipClaudeOnboarding: value })}
/>
</section>
);
}
-16
View File
@@ -34,7 +34,6 @@ export function RepoManager({
const { t } = useTranslation();
const [repoUrl, setRepoUrl] = useState("");
const [branch, setBranch] = useState("");
const [skillsPath, setSkillsPath] = useState("");
const [error, setError] = useState("");
const getSkillCount = (repo: SkillRepo) =>
@@ -80,12 +79,10 @@ export function RepoManager({
name: parsed.name,
branch: branch || "main",
enabled: true,
skillsPath: skillsPath.trim() || undefined, // 仅在有值时传递
});
setRepoUrl("");
setBranch("");
setSkillsPath("");
} catch (e) {
setError(e instanceof Error ? e.message : t("skills.repo.addFailed"));
}
@@ -130,13 +127,6 @@ export function RepoManager({
onChange={(e) => setBranch(e.target.value)}
className="flex-1"
/>
<Input
id="skills-path"
placeholder={t("skills.repo.pathPlaceholder")}
value={skillsPath}
onChange={(e) => setSkillsPath(e.target.value)}
className="flex-1"
/>
<Button
onClick={handleAdd}
className="w-full sm:w-auto sm:px-4"
@@ -171,12 +161,6 @@ export function RepoManager({
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t("skills.repo.branch")}: {repo.branch || "main"}
{repo.skillsPath && (
<>
<span className="mx-2"></span>
{t("skills.repo.path")}: {repo.skillsPath}
</>
)}
<span className="ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]">
{t("skills.repo.skillCount", {
count: getSkillCount(repo),
+14 -37
View File
@@ -26,7 +26,6 @@ export function RepoManagerPanel({
const { t } = useTranslation();
const [repoUrl, setRepoUrl] = useState("");
const [branch, setBranch] = useState("");
const [skillsPath, setSkillsPath] = useState("");
const [error, setError] = useState("");
const getSkillCount = (repo: SkillRepo) =>
@@ -67,12 +66,10 @@ export function RepoManagerPanel({
name: parsed.name,
branch: branch || "main",
enabled: true,
skillsPath: skillsPath.trim() || undefined,
});
setRepoUrl("");
setBranch("");
setSkillsPath("");
} catch (e) {
setError(e instanceof Error ? e.message : t("skills.repo.addFailed"));
}
@@ -93,7 +90,7 @@ export function RepoManagerPanel({
onClose={onClose}
>
{/* 添加仓库表单 */}
<div className="space-y-4 glass rounded-xl p-6 border border-white/10">
<div className="space-y-4 glass-card rounded-xl p-6">
<h3 className="text-base font-semibold text-foreground">
</h3>
@@ -110,31 +107,17 @@ export function RepoManagerPanel({
className="mt-2"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label htmlFor="branch" className="text-foreground">
{t("skills.repo.branch")}
</Label>
<Input
id="branch"
placeholder={t("skills.repo.branchPlaceholder")}
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="skills-path" className="text-foreground">
{t("skills.repo.path")}
</Label>
<Input
id="skills-path"
placeholder={t("skills.repo.pathPlaceholder")}
value={skillsPath}
onChange={(e) => setSkillsPath(e.target.value)}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="branch" className="text-foreground">
{t("skills.repo.branch")}
</Label>
<Input
id="branch"
placeholder={t("skills.repo.branchPlaceholder")}
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="mt-2"
/>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
@@ -156,7 +139,7 @@ export function RepoManagerPanel({
{t("skills.repo.list")}
</h3>
{repos.length === 0 ? (
<div className="text-center py-12 glass rounded-xl border border-white/10">
<div className="text-center py-12 glass-card rounded-xl">
<p className="text-sm text-muted-foreground">
{t("skills.repo.empty")}
</p>
@@ -166,7 +149,7 @@ export function RepoManagerPanel({
{repos.map((repo) => (
<div
key={`${repo.owner}/${repo.name}`}
className="flex items-center justify-between rounded-xl border border-white/10 bg-gray-900/40 px-4 py-3"
className="flex items-center justify-between glass-card rounded-xl px-4 py-3"
>
<div>
<div className="text-sm font-medium text-foreground">
@@ -174,12 +157,6 @@ export function RepoManagerPanel({
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t("skills.repo.branch")}: {repo.branch || "main"}
{repo.skillsPath && (
<>
<span className="mx-2"></span>
{t("skills.repo.path")}: {repo.skillsPath}
</>
)}
<span className="ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]">
{t("skills.repo.skillCount", {
count: getSkillCount(repo),
+2 -2
View File
@@ -57,7 +57,7 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
skill.directory.trim().toLowerCase() !== skill.name.trim().toLowerCase();
return (
<Card className="glass flex flex-col h-full border border-white/10 bg-gray-900/40 transition-all duration-300 hover:bg-gray-900/60 hover:border-white/20 hover:shadow-lg group relative overflow-hidden">
<Card className="glass-card flex flex-col h-full transition-all duration-300 hover:scale-[1.01] hover:shadow-lg group relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
@@ -96,7 +96,7 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
{skill.description || t("skills.noDescription")}
</p>
</CardContent>
<CardFooter className="flex gap-2 pt-3 border-t border-white/5 relative z-10">
<CardFooter className="flex gap-2 pt-3 border-t border-border/50 relative z-10">
{skill.readmeUrl && (
<Button
variant="ghost"
+6 -8
View File
@@ -205,12 +205,10 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
}, [skills, searchQuery, filterStatus]);
return (
<div className="flex flex-col h-full min-h-0 bg-background/50">
{/* 顶部操作栏(固定区域)已移除,由 App.tsx 接管 */}
<div className="mx-auto max-w-[56rem] px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden bg-background/50">
{/* 技能网格(可滚动详情区域) */}
<div className="flex-1 min-h-0 overflow-y-auto animate-fade-in">
<div className="mx-auto max-w-[56rem] px-6 py-4">
<div className="flex-1 overflow-y-auto overflow-x-hidden animate-fade-in">
<div className="py-4">
{loading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
@@ -263,19 +261,19 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
<SelectContent className="bg-card text-foreground shadow-lg">
<SelectItem
value="all"
className="text-left pr-3 [&[data-state=checked]>span]:hidden"
className="text-left pr-3 [&[data-state=checked]>span:first-child]:hidden"
>
{t("skills.filter.all")}
</SelectItem>
<SelectItem
value="installed"
className="text-left pr-3 [&[data-state=checked]>span]:hidden"
className="text-left pr-3 [&[data-state=checked]>span:first-child]:hidden"
>
{t("skills.filter.installed")}
</SelectItem>
<SelectItem
value="uninstalled"
className="text-left pr-3 [&[data-state=checked]>span]:hidden"
className="text-left pr-3 [&[data-state=checked]>span:first-child]:hidden"
>
{t("skills.filter.uninstalled")}
</SelectItem>
+94 -26
View File
@@ -69,10 +69,10 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api.deepseek.com/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "DeepSeek-V3.2-Exp",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "DeepSeek-V3.2-Exp",
ANTHROPIC_DEFAULT_SONNET_MODEL: "DeepSeek-V3.2-Exp",
ANTHROPIC_DEFAULT_OPUS_MODEL: "DeepSeek-V3.2-Exp",
ANTHROPIC_MODEL: "DeepSeek-V3.2",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "DeepSeek-V3.2",
ANTHROPIC_DEFAULT_SONNET_MODEL: "DeepSeek-V3.2",
ANTHROPIC_DEFAULT_OPUS_MODEL: "DeepSeek-V3.2",
},
},
category: "cn_official",
@@ -87,10 +87,10 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "glm-4.6",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.5-air",
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.6",
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.6",
ANTHROPIC_MODEL: "glm-4.7",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.7",
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.7",
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.7",
},
},
category: "cn_official",
@@ -107,10 +107,10 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api.z.ai/api/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "glm-4.6",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.5-air",
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.6",
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.6",
ANTHROPIC_MODEL: "glm-4.7",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.7",
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.7",
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.7",
},
},
category: "cn_official",
@@ -178,15 +178,15 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api-inference.modelscope.cn",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.6",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "ZhipuAI/GLM-4.6",
ANTHROPIC_DEFAULT_SONNET_MODEL: "ZhipuAI/GLM-4.6",
ANTHROPIC_DEFAULT_OPUS_MODEL: "ZhipuAI/GLM-4.6",
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.7",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "ZhipuAI/GLM-4.7",
ANTHROPIC_DEFAULT_SONNET_MODEL: "ZhipuAI/GLM-4.7",
ANTHROPIC_DEFAULT_OPUS_MODEL: "ZhipuAI/GLM-4.7",
},
},
category: "aggregator",
icon: "alibaba",
iconColor: "#FF6A00",
icon: "modelscope",
iconColor: "#624AFF",
},
{
name: "KAT-Coder",
@@ -230,26 +230,54 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "longcat",
iconColor: "#29E154",
},
{
name: "MiniMax",
websiteUrl: "https://platform.minimaxi.com",
apiKeyUrl: "https://platform.minimax.io/subscribe/coding-plan",
apiKeyUrl: "https://platform.minimaxi.com/subscribe/coding-plan",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.minimaxi.com/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
API_TIMEOUT_MS: "3000000",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,
ANTHROPIC_MODEL: "MiniMax-M2",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2",
ANTHROPIC_MODEL: "MiniMax-M2.1",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.1",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.1",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.1",
},
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "minimax",
partnerPromotionKey: "minimax_cn",
theme: {
backgroundColor: "#f64551",
textColor: "#FFFFFF",
},
icon: "minimax",
iconColor: "#FF6B6B",
},
{
name: "MiniMax en",
websiteUrl: "https://platform.minimax.io",
apiKeyUrl: "https://platform.minimax.io/subscribe/coding-plan",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.minimax.io/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
API_TIMEOUT_MS: "3000000",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,
ANTHROPIC_MODEL: "MiniMax-M2.1",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.1",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.1",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.1",
},
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "minimax_en",
theme: {
backgroundColor: "#f64551",
textColor: "#FFFFFF",
@@ -290,8 +318,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "alibaba",
iconColor: "#FF6A00",
},
{
name: "AiHubMix",
@@ -308,6 +334,8 @@ export const providerPresets: ProviderPreset[] = [
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
endpointCandidates: ["https://aihubmix.com", "https://api.aihubmix.com"],
category: "aggregator",
icon: "aihubmix",
iconColor: "#006FFB",
},
{
name: "DMXAPI",
@@ -322,6 +350,8 @@ export const providerPresets: ProviderPreset[] = [
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
endpointCandidates: ["https://www.dmxapi.cn", "https://api.dmxapi.cn"],
category: "aggregator",
isPartner: true, // 合作伙伴
partnerPromotionKey: "dmxapi", // 促销信息 i18n key
},
{
name: "PackyCode",
@@ -343,4 +373,42 @@ export const providerPresets: ProviderPreset[] = [
partnerPromotionKey: "packycode", // 促销信息 i18n key
icon: "packycode",
},
{
name: "AiGoCode",
websiteUrl: "https://aigocode.com",
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.aigocode.com/api",
ANTHROPIC_AUTH_TOKEN: "",
},
},
// 请求地址候选(用于地址管理/测速)
endpointCandidates: [
"https://api.aigocode.com",
],
category: "third_party",
isPartner: true, // 合作伙伴
partnerPromotionKey: "aigocode", // 促销信息 i18n key
icon: "aigocode",
iconColor: "#5B7FFF",
},
{
name: "OpenRouter",
websiteUrl: "https://openrouter.ai",
apiKeyUrl: "https://openrouter.ai/keys",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "",
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",
},
},
category: "aggregator",
icon: "openrouter",
iconColor: "#6566F1",
},
];
+21 -6
View File
@@ -40,7 +40,7 @@ export function generateThirdPartyAuth(apiKey: string): Record<string, any> {
export function generateThirdPartyConfig(
providerName: string,
baseUrl: string,
modelName = "gpt-5-codex",
modelName = "gpt-5.1-codex"
): string {
// 清理供应商名称,确保符合TOML键名规范
const cleanProviderName =
@@ -80,12 +80,12 @@ export const codexProviderPresets: CodexProviderPreset[] = [
{
name: "Azure OpenAI",
websiteUrl:
"https://learn.microsoft.com/azure/ai-services/openai/how-to/overview",
"https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/codex",
category: "third_party",
isOfficial: true,
auth: generateThirdPartyAuth(""),
config: `model_provider = "azure"
model = "gpt-5-codex"
model = "gpt-5.2"
model_reasoning_effort = "high"
disable_response_storage = true
@@ -113,7 +113,7 @@ requires_openai_auth = true`,
config: generateThirdPartyConfig(
"aihubmix",
"https://aihubmix.com/v1",
"gpt-5-codex",
"gpt-5.2"
),
endpointCandidates: [
"https://aihubmix.com/v1",
@@ -128,9 +128,11 @@ requires_openai_auth = true`,
config: generateThirdPartyConfig(
"dmxapi",
"https://www.dmxapi.cn/v1",
"gpt-5-codex",
"gpt-5.2"
),
endpointCandidates: ["https://www.dmxapi.cn/v1"],
isPartner: true, // 合作伙伴
partnerPromotionKey: "dmxapi", // 促销信息 i18n key
},
{
name: "PackyCode",
@@ -141,7 +143,7 @@ requires_openai_auth = true`,
config: generateThirdPartyConfig(
"packycode",
"https://www.packyapi.com/v1",
"gpt-5-codex",
"gpt-5.2"
),
endpointCandidates: [
"https://www.packyapi.com/v1",
@@ -151,4 +153,17 @@ requires_openai_auth = true`,
partnerPromotionKey: "packycode", // 促销信息 i18n key
icon: "packycode",
},
{
name: "AiGoCode",
websiteUrl: "https://aigocode.com",
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
category: "third_party",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig("aigocode", "https://api.aigocode.com/openai", "gpt-5.2"),
endpointCandidates: ["https://api.aigocode.com"],
isPartner: true, // 合作伙伴
partnerPromotionKey: "aigocode", // 促销信息 i18n key
icon: "aigocode",
iconColor: "#5B7FFF",
},
];
+1 -1
View File
@@ -14,7 +14,7 @@ export interface CodexTemplate {
*/
export function getCodexCustomTemplate(): CodexTemplate {
const config = `model_provider = "custom"
model = "gpt-5-codex"
model = "gpt-5.2"
model_reasoning_effort = "high"
disable_response_storage = true
+17 -10
View File
@@ -56,11 +56,11 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
settingsConfig: {
env: {
GOOGLE_GEMINI_BASE_URL: "https://www.packyapi.com",
GEMINI_MODEL: "gemini-3-pro-preview",
GEMINI_MODEL: "gemini-3-pro",
},
},
baseURL: "https://www.packyapi.com",
model: "gemini-3-pro-preview",
model: "gemini-3-pro",
description: "PackyCode",
category: "third_party",
isPartner: true,
@@ -70,19 +70,26 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
"https://www.packyapi.com",
],
icon: "packycode",
},
},
{
name: "自定义",
websiteUrl: "",
name: "AiGoCode",
websiteUrl: "https://aigocode.com",
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
settingsConfig: {
env: {
GOOGLE_GEMINI_BASE_URL: "",
GEMINI_MODEL: "gemini-3-pro-preview",
GOOGLE_GEMINI_BASE_URL: "https://api.aigocode.com/gemini",
GEMINI_MODEL: "gemini-3-pro",
},
},
model: "gemini-3-pro-preview",
description: "自定义 Gemini API 端点",
category: "custom",
baseURL: "https://api.aigocode.com/gemini",
model: "gemini-3-pro",
description: "AiGoCode",
category: "third_party",
isPartner: true,
partnerPromotionKey: "aigocode",
endpointCandidates: ["https://api.aigocode.com/gemini"],
icon: "aigocode",
iconColor: "#5B7FFF",
},
];
+5 -14
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { settingsApi } from "@/lib/api";
@@ -39,15 +39,6 @@ export function useImportExport(
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [backupId, setBackupId] = useState<string | null>(null);
const [isImporting, setIsImporting] = useState(false);
const successTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (successTimerRef.current) {
window.clearTimeout(successTimerRef.current);
}
};
}, []);
const clearSelection = useCallback(() => {
setSelectedFile("");
@@ -105,6 +96,10 @@ export function useImportExport(
}
setBackupId(result.backupId ?? null);
// 导入成功后立即触发外部刷新(与 live 同步结果解耦)
// - 避免 sync 失败时 UI 不刷新
// - 避免依赖 setTimeout(组件卸载会取消)
void onImportSuccess?.();
const syncResult = await syncCurrentProvidersLiveSafe();
if (syncResult.ok) {
@@ -114,10 +109,6 @@ export function useImportExport(
defaultValue: "配置导入成功",
}),
);
successTimerRef.current = window.setTimeout(() => {
void onImportSuccess?.();
}, 1500);
} else {
console.error(
"[useImportExport] Failed to sync live config",
+57
View File
@@ -160,6 +160,36 @@ export function useSettings(): UseSettingsResult {
}
}
// Claude Code 初次安装确认:开=写入 hasCompletedOnboarding=true;关=删除该字段
// 仅在本次更新包含 skipClaudeOnboarding 时触发,避免其它自动保存误触发
const nextSkipClaudeOnboarding = updates.skipClaudeOnboarding;
if (
nextSkipClaudeOnboarding !== undefined &&
nextSkipClaudeOnboarding !== (data?.skipClaudeOnboarding ?? false)
) {
try {
if (nextSkipClaudeOnboarding) {
await settingsApi.applyClaudeOnboardingSkip();
} else {
await settingsApi.clearClaudeOnboardingSkip();
}
} catch (error) {
console.warn(
"[useSettings] Failed to sync Claude onboarding skip",
error,
);
toast.error(
nextSkipClaudeOnboarding
? t("notifications.skipClaudeOnboardingFailed", {
defaultValue: "跳过 Claude Code 初次安装确认失败",
})
: t("notifications.clearClaudeOnboardingSkipFailed", {
defaultValue: "恢复 Claude Code 初次安装确认失败",
}),
);
}
}
// 持久化语言偏好
try {
if (typeof window !== "undefined" && updates.language) {
@@ -242,6 +272,33 @@ export function useSettings(): UseSettingsResult {
}
}
// Claude Code 初次安装确认:开=写入 hasCompletedOnboarding=true;关=删除该字段
const prevSkipClaudeOnboarding = data?.skipClaudeOnboarding ?? false;
const nextSkipClaudeOnboarding = payload.skipClaudeOnboarding ?? false;
if (nextSkipClaudeOnboarding !== prevSkipClaudeOnboarding) {
try {
if (nextSkipClaudeOnboarding) {
await settingsApi.applyClaudeOnboardingSkip();
} else {
await settingsApi.clearClaudeOnboardingSkip();
}
} catch (error) {
console.warn(
"[useSettings] Failed to sync Claude onboarding skip",
error,
);
toast.error(
nextSkipClaudeOnboarding
? t("notifications.skipClaudeOnboardingFailed", {
defaultValue: "跳过 Claude Code 初次安装确认失败",
})
: t("notifications.clearClaudeOnboardingSkipFailed", {
defaultValue: "恢复 Claude Code 初次安装确认失败",
}),
);
}
}
// 只在 Claude 插件集成状态真正改变时调用系统 API
if (
payload.enableClaudePluginIntegration !== undefined &&
+3
View File
@@ -83,6 +83,7 @@ export function useSettingsForm(): UseSettingsFormResult {
minimizeToTrayOnClose: data.minimizeToTrayOnClose ?? true,
enableClaudePluginIntegration:
data.enableClaudePluginIntegration ?? false,
skipClaudeOnboarding: data.skipClaudeOnboarding ?? true,
claudeConfigDir: sanitizeDir(data.claudeConfigDir),
codexConfigDir: sanitizeDir(data.codexConfigDir),
language: normalizedLanguage,
@@ -102,6 +103,7 @@ export function useSettingsForm(): UseSettingsFormResult {
showInTray: true,
minimizeToTrayOnClose: true,
enableClaudePluginIntegration: false,
skipClaudeOnboarding: true,
language: readPersistedLanguage(),
} as SettingsFormState);
@@ -136,6 +138,7 @@ export function useSettingsForm(): UseSettingsFormResult {
minimizeToTrayOnClose: serverData.minimizeToTrayOnClose ?? true,
enableClaudePluginIntegration:
serverData.enableClaudePluginIntegration ?? false,
skipClaudeOnboarding: serverData.skipClaudeOnboarding ?? true,
claudeConfigDir: sanitizeDir(serverData.claudeConfigDir),
codexConfigDir: sanitizeDir(serverData.codexConfigDir),
language: normalizedLanguage,
+18 -8
View File
@@ -112,7 +112,7 @@
"providerAdded": "Provider added",
"providerSaved": "Provider configuration saved",
"providerDeleted": "Provider deleted successfully",
"switchSuccess": "Switch successful! Please restart {{appName}} terminal to take effect",
"switchSuccess": "Switch successful!",
"switchFailedTitle": "Switch failed",
"switchFailed": "Switch failed: {{error}}",
"autoImported": "Default provider created from existing configuration",
@@ -122,6 +122,8 @@
"appliedToClaudePlugin": "Applied to Claude plugin",
"removedFromClaudePlugin": "Removed from Claude plugin",
"syncClaudePluginFailed": "Sync Claude plugin failed",
"skipClaudeOnboardingFailed": "Failed to skip Claude Code first-run confirmation",
"clearClaudeOnboardingSkipFailed": "Failed to restore Claude Code first-run confirmation",
"updateSuccess": "Provider updated successfully",
"updateFailed": "Failed to update provider: {{error}}",
"deleteSuccess": "Provider deleted",
@@ -146,7 +148,7 @@
"themeDark": "Dark",
"themeSystem": "System",
"importExport": "SQL Import/Export",
"importExportHint": "Import or export database SQL backups for migration or restore.",
"importExportHint": "Import or export database SQL backups for migration or restore (import supports only backups exported by CC Switch).",
"exportConfig": "Export SQL Backup",
"selectConfigFile": "Select SQL File",
"noFileSelected": "No configuration file selected.",
@@ -162,7 +164,7 @@
"selectFileFailed": "Please choose a valid SQL backup file",
"configCorrupted": "SQL file may be corrupted or invalid",
"backupId": "Backup ID",
"autoReload": "Data will refresh automatically in 2 seconds...",
"autoReload": "Data refreshed",
"languageOptionChinese": "中文",
"languageOptionEnglish": "English",
"languageOptionJapanese": "日本語",
@@ -175,6 +177,8 @@
"minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.",
"enableClaudePluginIntegration": "Apply to Claude Code extension",
"enableClaudePluginIntegrationDescription": "When enabled, the VS Code Claude Code extension provider will switch with this app",
"skipClaudeOnboarding": "Skip Claude Code first-run confirmation",
"skipClaudeOnboardingDescription": "When enabled, Claude Code will skip the first-run confirmation",
"configDirectoryOverride": "Configuration Directory Override (Advanced)",
"configDirectoryDescription": "When using Claude Code or Codex in environments like WSL, you can manually specify the configuration directory to the one in WSL to keep provider data consistent with the main environment.",
"appConfigDir": "CC Switch Configuration Directory",
@@ -263,7 +267,10 @@
"partnerPromotion": {
"zhipu": "Zhipu GLM is an official partner of CC Switch. Use this link to top up and get a 10% discount",
"packycode": "PackyCode is an official partner of CC Switch. Register using this link and enter \"cc-switch\" promo code during recharge to get 10% off",
"minimax": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)"
"minimax_cn": "MiniMax Coding Plan Special Offer, Starter from ¥9.9",
"minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)",
"dmxapi": "Claude Code exclusive model 66% OFF now!",
"aigocode": "AiGoCode is an official partner of CC Switch. Register using this link and get 10% bonus credit on your first top-up!"
},
"parameterConfig": "Parameter Config - {{name}} *",
"mainModel": "Main Model (optional)",
@@ -271,7 +278,7 @@
"fastModel": "Fast Model (optional)",
"fastModelPlaceholder": "e.g., GLM-4.5-Air",
"modelHint": "💡 Leave blank to use provider's default model",
"apiHint": "💡 Fill in Claude API compatible service endpoint",
"apiHint": "💡 Fill in Claude API compatible service endpoint, avoid trailing slash",
"codexApiHint": "💡 Fill in service endpoint compatible with OpenAI Response format",
"fillSupplierName": "Please fill in provider name",
"fillConfigContent": "Please fill in configuration content",
@@ -533,9 +540,6 @@
"env": "Environment (one per line, KEY=VALUE)",
"envPlaceholder": "FOO=bar\nHELLO=world",
"reset": "Reset",
"notice": {
"restartClaude": "Written. Restart Claude to take effect."
},
"msg": {
"saved": "Saved",
"deleted": "Deleted",
@@ -833,5 +837,11 @@
"colorLabel": "Icon Color",
"selectIcon": "Select Icon",
"preview": "Preview"
},
"migration": {
"success": "Configuration migrated successfully"
},
"agents": {
"title": "Agents"
}
}
+18 -8
View File
@@ -112,7 +112,7 @@
"providerAdded": "プロバイダーを追加しました",
"providerSaved": "プロバイダー設定を保存しました",
"providerDeleted": "プロバイダーを削除しました",
"switchSuccess": "切り替え成功! {{appName}} ターミナルを再起動すると反映されます",
"switchSuccess": "切り替え成功!",
"switchFailedTitle": "切り替えに失敗しました",
"switchFailed": "切り替えに失敗しました: {{error}}",
"autoImported": "既存設定からデフォルトプロバイダーを自動作成しました",
@@ -122,6 +122,8 @@
"appliedToClaudePlugin": "Claude プラグインに適用しました",
"removedFromClaudePlugin": "Claude プラグインから削除しました",
"syncClaudePluginFailed": "Claude プラグインとの同期に失敗しました",
"skipClaudeOnboardingFailed": "Claude Code の初回確認スキップに失敗しました",
"clearClaudeOnboardingSkipFailed": "Claude Code の初回確認の復元に失敗しました",
"updateSuccess": "プロバイダーを更新しました",
"updateFailed": "プロバイダーの更新に失敗しました: {{error}}",
"deleteSuccess": "プロバイダーを削除しました",
@@ -146,7 +148,7 @@
"themeDark": "ダーク",
"themeSystem": "システム",
"importExport": "SQL インポート/エクスポート",
"importExportHint": "移行や復元用にデータベースの SQL バックアップをインポート/エクスポートします。",
"importExportHint": "移行や復元用にデータベースの SQL バックアップをインポート/エクスポートします(インポートは CC Switch がエクスポートしたバックアップのみ対応)。",
"exportConfig": "SQL バックアップをエクスポート",
"selectConfigFile": "SQL ファイルを選択",
"noFileSelected": "ファイルが選択されていません。",
@@ -162,7 +164,7 @@
"selectFileFailed": "有効な SQL バックアップファイルを選択してください",
"configCorrupted": "SQL ファイルが壊れているか形式が無効な可能性があります",
"backupId": "バックアップ ID",
"autoReload": "2 秒後に自動で再読み込みします...",
"autoReload": "データを更新しました",
"languageOptionChinese": "中文",
"languageOptionEnglish": "English",
"languageOptionJapanese": "日本語",
@@ -175,6 +177,8 @@
"minimizeToTrayDescription": "チェックすると閉じるボタンでトレイに隠し、オフならアプリを終了します。",
"enableClaudePluginIntegration": "Claude Code 拡張に適用",
"enableClaudePluginIntegrationDescription": "オンにすると VS Code の Claude Code 拡張のプロバイダーも同期します",
"skipClaudeOnboarding": "Claude Code の初回確認をスキップ",
"skipClaudeOnboardingDescription": "オンにすると Claude Code の初回インストール確認をスキップします",
"configDirectoryOverride": "設定ディレクトリの上書き(詳細)",
"configDirectoryDescription": "WSL などで Claude Code や Codex を使う場合、ここで設定ディレクトリを WSL 側に合わせるとデータを揃えられます。",
"appConfigDir": "CC Switch 設定ディレクトリ",
@@ -263,7 +267,10 @@
"partnerPromotion": {
"zhipu": "Zhipu GLM は CC Switch の公式パートナーです。リンク経由でチャージすると 10% 割引",
"packycode": "PackyCode は CC Switch の公式パートナーです。登録後チャージ時に \"cc-switch\" を入力すると 10% オフ",
"minimax": "MiniMax Coding Plan Black Friday、Starter が月額 $280% OFF"
"minimax_cn": "MiniMax Coding Plan 特別価格、Starter ¥9.9 から",
"minimax_en": "MiniMax Coding Plan Black Friday、Starter が月額 $280% OFF",
"dmxapi": "Claude Code 専用モデル 66% OFF 実施中!",
"aigocode": "AiGoCode は CC Switch の公式パートナーです。このリンクから登録すると、初回チャージ時に 10% のボーナスクレジットがもらえます!"
},
"parameterConfig": "パラメーター設定 - {{name}} *",
"mainModel": "メインモデル(任意)",
@@ -271,7 +278,7 @@
"fastModel": "高速モデル(任意)",
"fastModelPlaceholder": "例: GLM-4.5-Air",
"modelHint": "💡 空欄ならプロバイダーのデフォルトモデルを使用します",
"apiHint": "💡 Claude API 互換サービスのエンドポイントを入力してください",
"apiHint": "💡 Claude API 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください",
"codexApiHint": "💡 OpenAI Response 互換のサービスエンドポイントを入力してください",
"fillSupplierName": "プロバイダー名を入力してください",
"fillConfigContent": "設定内容を入力してください",
@@ -533,9 +540,6 @@
"env": "環境変数(1 行に 1 件、KEY=VALUE",
"envPlaceholder": "FOO=bar\nHELLO=world",
"reset": "リセット",
"notice": {
"restartClaude": "書き込みました。Claude を再起動すると反映されます。"
},
"msg": {
"saved": "保存しました",
"deleted": "削除しました",
@@ -833,5 +837,11 @@
"colorLabel": "アイコンカラー",
"selectIcon": "アイコンを選択",
"preview": "プレビュー"
},
"migration": {
"success": "設定の移行が完了しました"
},
"agents": {
"title": "エージェント"
}
}
+18 -8
View File
@@ -112,7 +112,7 @@
"providerAdded": "供应商已添加",
"providerSaved": "供应商配置已保存",
"providerDeleted": "供应商删除成功",
"switchSuccess": "切换成功!请重启 {{appName}} 终端以生效",
"switchSuccess": "切换成功!",
"switchFailedTitle": "切换失败",
"switchFailed": "切换失败:{{error}}",
"autoImported": "已从现有配置创建默认供应商",
@@ -122,6 +122,8 @@
"appliedToClaudePlugin": "已应用到 Claude 插件",
"removedFromClaudePlugin": "已从 Claude 插件移除",
"syncClaudePluginFailed": "同步 Claude 插件失败",
"skipClaudeOnboardingFailed": "跳过 Claude Code 初次安装确认失败",
"clearClaudeOnboardingSkipFailed": "恢复 Claude Code 初次安装确认失败",
"updateSuccess": "供应商更新成功",
"updateFailed": "更新供应商失败:{{error}}",
"deleteSuccess": "供应商已删除",
@@ -146,7 +148,7 @@
"themeDark": "深色",
"themeSystem": "跟随系统",
"importExport": "SQL 导入导出",
"importExportHint": "导入/导出数据库 SQL 备份,便于备份或迁移。",
"importExportHint": "导入/导出数据库 SQL 备份(仅支持导入由 CC Switch 导出的备份),便于备份或迁移。",
"exportConfig": "导出 SQL 备份",
"selectConfigFile": "选择 SQL 文件",
"noFileSelected": "尚未选择配置文件。",
@@ -162,7 +164,7 @@
"selectFileFailed": "请选择有效的 SQL 备份文件",
"configCorrupted": "SQL 文件可能已损坏或格式不正确",
"backupId": "备份ID",
"autoReload": "数据将在2秒后自动刷新...",
"autoReload": "数据已刷新",
"languageOptionChinese": "中文",
"languageOptionEnglish": "English",
"languageOptionJapanese": "日本語",
@@ -175,6 +177,8 @@
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
"enableClaudePluginIntegration": "应用到 Claude Code 插件",
"enableClaudePluginIntegrationDescription": "开启后 Vscode Claude Code 插件的供应商将随本软件切换",
"skipClaudeOnboarding": "跳过 Claude Code 初次安装确认",
"skipClaudeOnboardingDescription": "开启后跳过 Claude Code 初次安装确认",
"configDirectoryOverride": "配置目录覆盖(高级)",
"configDirectoryDescription": "在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定为 WSL 里的配置目录,供应商数据与主环境保持一致。",
"appConfigDir": "CC Switch 配置目录",
@@ -263,7 +267,10 @@
"partnerPromotion": {
"zhipu": "智谱 GLM 是 CC Switch 的官方合作伙伴,使用此链接充值可以获得9折优惠",
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠",
"minimax": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)"
"minimax_cn": "MiniMax Coding Plan 特惠,Starter 套餐 9.9 元起",
"minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)",
"dmxapi": "Claude Code 专属模型 3.4 折优惠进行中!",
"aigocode": "AiGoCode 是 CC Switch 的官方合作伙伴,使用此链接注册首次充值时可以获得10%额度奖励!"
},
"parameterConfig": "参数配置 - {{name}} *",
"mainModel": "主模型 (可选)",
@@ -271,7 +278,7 @@
"fastModel": "快速模型 (可选)",
"fastModelPlaceholder": "例如: GLM-4.5-Air",
"modelHint": "💡 留空将使用供应商的默认模型",
"apiHint": "💡 填写兼容 Claude API 的服务端点地址",
"apiHint": "💡 填写兼容 Claude API 的服务端点地址,不要以斜杠结尾",
"codexApiHint": "💡 填写兼容 OpenAI Response 格式的服务端点地址",
"fillSupplierName": "请填写供应商名称",
"fillConfigContent": "请填写配置内容",
@@ -533,9 +540,6 @@
"env": "环境变量 (一行一个,KEY=VALUE)",
"envPlaceholder": "FOO=bar\nHELLO=world",
"reset": "重置",
"notice": {
"restartClaude": "已写入配置,重启 Claude 生效"
},
"msg": {
"saved": "已保存",
"deleted": "已删除",
@@ -833,5 +837,11 @@
"colorLabel": "图标颜色",
"selectIcon": "选择图标",
"preview": "预览"
},
"migration": {
"success": "配置迁移成功"
},
"agents": {
"title": "智能体"
}
}
+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AiHubMix</title><path d="M12 24c6.627 0 12-5.373 12-12S18.627 0 12 0 0 5.373 0 12s5.373 12 12 12z" fill="#006FFB"></path><path clip-rule="evenodd" d="M11.24 8.393c.095-.644.302-1.47.624-2.48L12 5.496l.136.417c.322 1.01.53 1.836.624 2.48.071.472.071 1.072 0 1.8-.072.731-.072 1.336 0 1.814.106.7.426 1.281.96 1.744a2.795 2.795 0 001.89.708 2.78 2.78 0 002.034-.84c.56-.559.842-1.234.848-2.024.003-.7.075-1.472.216-2.316.069-.422.14-.775.21-1.06l.095-.384.168.356a7.862 7.862 0 01.76 3.244v.16a7.84 7.84 0 01-.624 3.089 7.952 7.952 0 01-4.228 4.228 7.841 7.841 0 01-3.089.623 7.84 7.84 0 01-3.089-.623 7.952 7.952 0 01-4.228-4.228 7.84 7.84 0 01-.623-3.09v-.159a7.862 7.862 0 01.759-3.244l.169-.356.093.385c.072.284.143.637.211 1.059.141.844.213 1.616.216 2.316.006.79.29 1.465.848 2.024.563.56 1.241.84 2.035.84.715 0 1.345-.236 1.889-.708a2.79 2.79 0 00.96-1.744c.073-.478.073-1.083 0-1.814-.071-.728-.071-1.328 0-1.8zm.76 9.694c1.097 0 2.125-.26 3.085-.778a6.379 6.379 0 001.77-1.399c.063-.07-.01-.178-.101-.153-.37.1-.75.15-1.144.15a4.236 4.236 0 01-2.18-.59 4.253 4.253 0 01-1.35-1.233.099.099 0 00-.16 0 4.253 4.253 0 01-1.35 1.232 4.236 4.236 0 01-2.18.591c-.393 0-.774-.05-1.143-.15-.091-.025-.165.083-.102.153a6.38 6.38 0 001.77 1.399c.96.518 1.988.778 3.085.778z" fill="#fff" fill-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+51
View File
@@ -0,0 +1,51 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="648.000000pt" height="564.000000pt" viewBox="0 0 648.000000 564.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,564.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M5392 5379 c-26 -10 -36 -28 -56 -108 -24 -94 -47 -140 -101 -199
-56 -62 -117 -96 -219 -121 -101 -25 -116 -35 -116 -75 0 -45 21 -60 123 -89
190 -53 271 -147 336 -384 13 -46 38 -61 85 -49 25 6 36 28 56 111 7 28 23 72
36 99 13 28 21 54 18 58 -3 5 -2 7 3 6 4 -2 29 18 55 44 41 41 145 110 173
114 56 8 126 27 131 36 4 6 7 30 8 54 1 49 -5 54 -104 74 -164 33 -280 148
-320 320 -23 101 -52 129 -108 109z"/>
<path d="M1770 4814 c-138 -25 -301 -93 -425 -177 -80 -55 -227 -197 -280
-272 -98 -138 -161 -279 -201 -447 -16 -66 -18 -164 -21 -1098 -4 -1130 -4
-1144 57 -1331 80 -242 222 -434 444 -598 l56 -42 0 -337 c0 -309 2 -340 19
-379 31 -66 87 -93 158 -74 22 6 202 117 404 249 200 131 398 259 439 285 144
89 48 81 1095 88 912 5 932 6 1012 27 243 64 441 178 599 344 166 176 274 408
304 648 13 110 13 2016 0 2120 -25 190 -83 347 -183 498 -160 240 -384 400
-677 484 l-75 22 -1325 2 c-1086 2 -1339 0 -1400 -12z m1068 -1455 l-3 -751
-71 -18 c-71 -18 -154 -55 -205 -91 -14 -10 -29 -16 -33 -12 -3 3 -6 91 -6
195 l0 188 -306 0 -305 0 -21 -47 c-11 -27 -33 -75 -48 -108 -16 -33 -44 -96
-62 -140 l-34 -80 -172 -3 c-101 -1 -172 1 -172 7 0 5 14 40 31 78 31 68 101
227 254 578 335 769 358 814 434 874 94 74 122 79 449 80 l272 1 -2 -751z
m794 717 c41 -13 103 -42 139 -62 67 -40 202 -168 232 -221 l17 -31 -77 -65
c-43 -35 -101 -80 -129 -101 l-52 -36 -39 57 c-79 116 -204 177 -313 154 -66
-14 -105 -42 -130 -91 -19 -37 -20 -60 -20 -400 0 -339 1 -363 20 -399 26 -51
61 -78 128 -97 143 -42 327 52 366 186 l13 45 -163 3 -163 2 -3 157 c-2 111 0
157 9 160 6 2 263 3 570 1 487 -3 562 -5 593 -19 98 -45 125 -159 58 -244 -39
-50 -66 -55 -322 -55 l-236 0 -6 -27 c-18 -83 -29 -115 -62 -183 -69 -143
-230 -269 -402 -316 -81 -22 -271 -25 -350 -5 -173 43 -289 145 -341 298 -19
57 -20 81 -17 534 l3 474 33 67 c55 112 168 199 307 235 79 20 246 10 337 -21z
m828 -1515 c67 -71 67 -73 -62 -396 -45 -110 -102 -254 -128 -320 -254 -639
-272 -681 -298 -702 -74 -62 -192 -15 -192 77 0 12 39 116 86 233 48 117 97
239 110 272 119 311 336 833 354 852 36 37 85 32 130 -16z m-1574 -205 c16
-13 19 -29 22 -128 l3 -113 -195 -155 c-108 -85 -196 -158 -196 -161 0 -4 17
-19 38 -35 128 -96 327 -272 339 -301 16 -39 17 -143 2 -177 -15 -33 -34 -39
-67 -22 -48 26 -566 446 -584 474 -23 35 -23 89 1 128 16 27 150 143 376 326
39 31 79 65 90 75 44 41 128 103 139 103 7 0 21 -6 32 -14z m713 -25 c52 -37
53 -33 -92 -551 -36 -129 -80 -289 -98 -355 -39 -143 -62 -175 -129 -175 -47
0 -92 20 -114 52 -24 34 -19 90 18 217 19 64 78 271 131 461 53 190 98 353
101 364 6 16 14 18 79 14 54 -4 81 -11 104 -27z m1116 -351 c55 -45 117 -98
138 -120 61 -64 48 -115 -50 -192 -32 -25 -118 -94 -191 -152 -136 -108 -163
-120 -217 -100 -26 10 -47 62 -39 97 10 46 22 62 94 119 36 29 93 77 128 106
l62 55 -65 59 c-71 65 -77 84 -49 141 24 47 44 67 68 67 12 0 66 -36 121 -80z"/>
<path d="M2328 3755 c-37 -20 -54 -53 -153 -280 -48 -110 -96 -219 -106 -242
l-18 -43 234 0 235 0 0 290 0 290 -82 0 c-53 -1 -93 -6 -110 -15z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

+5
View File
@@ -2,6 +2,7 @@
// Do not edit manually
export const icons: Record<string, string> = {
aigocode: `<svg height="1em" width="1em" style="flex:none;line-height:1" viewBox="0 0 648 564" xmlns="http://www.w3.org/2000/svg"><title>AiGoCode</title><g transform="translate(0,564) scale(0.1,-0.1)"><path fill="#7C6AEF" d="M5392 5379 c-26 -10 -36 -28 -56 -108 -24 -94 -47 -140 -101 -199 -56 -62 -117 -96 -219 -121 -101 -25 -116 -35 -116 -75 0 -45 21 -60 123 -89 190 -53 271 -147 336 -384 13 -46 38 -61 85 -49 25 6 36 28 56 111 7 28 23 72 36 99 13 28 21 54 18 58 -3 5 -2 7 3 6 4 -2 29 18 55 44 41 41 145 110 173 114 56 8 126 27 131 36 4 6 7 30 8 54 1 49 -5 54 -104 74 -164 33 -280 148 -320 320 -23 101 -52 129 -108 109z"/><path fill="#5B7FFF" d="M1770 4814 c-138 -25 -301 -93 -425 -177 -80 -55 -227 -197 -280 -272 -98 -138 -161 -279 -201 -447 -16 -66 -18 -164 -21 -1098 -4 -1130 -4 -1144 57 -1331 80 -242 222 -434 444 -598 l56 -42 0 -337 c0 -309 2 -340 19 -379 31 -66 87 -93 158 -74 22 6 202 117 404 249 200 131 398 259 439 285 144 89 48 81 1095 88 912 5 932 6 1012 27 243 64 441 178 599 344 166 176 274 408 304 648 13 110 13 2016 0 2120 -25 190 -83 347 -183 498 -160 240 -384 400 -677 484 l-75 22 -1325 2 c-1086 2 -1339 0 -1400 -12z m1068 -1455 l-3 -751 -71 -18 c-71 -18 -154 -55 -205 -91 -14 -10 -29 -16 -33 -12 -3 3 -6 91 -6 195 l0 188 -306 0 -305 0 -21 -47 c-11 -27 -33 -75 -48 -108 -16 -33 -44 -96 -62 -140 l-34 -80 -172 -3 c-101 -1 -172 1 -172 7 0 5 14 40 31 78 31 68 101 227 254 578 335 769 358 814 434 874 94 74 122 79 449 80 l272 1 -2 -751z m794 717 c41 -13 103 -42 139 -62 67 -40 202 -168 232 -221 l17 -31 -77 -65 c-43 -35 -101 -80 -129 -101 l-52 -36 -39 57 c-79 116 -204 177 -313 154 -66 -14 -105 -42 -130 -91 -19 -37 -20 -60 -20 -400 0 -339 1 -363 20 -399 26 -51 61 -78 128 -97 143 -42 327 52 366 186 l13 45 -163 3 -163 2 -3 157 c-2 111 0 157 9 160 6 2 263 3 570 1 487 -3 562 -5 593 -19 98 -45 125 -159 58 -244 -39 -50 -66 -55 -322 -55 l-236 0 -6 -27 c-18 -83 -29 -115 -62 -183 -69 -143 -230 -269 -402 -316 -81 -22 -271 -25 -350 -5 -173 43 -289 145 -341 298 -19 57 -20 81 -17 534 l3 474 33 67 c55 112 168 199 307 235 79 20 246 10 337 -21z m828 -1515 c67 -71 67 -73 -62 -396 -45 -110 -102 -254 -128 -320 -254 -639 -272 -681 -298 -702 -74 -62 -192 -15 -192 77 0 12 39 116 86 233 48 117 97 239 110 272 119 311 336 833 354 852 36 37 85 32 130 -16z m-1574 -205 c16 -13 19 -29 22 -128 l3 -113 -195 -155 c-108 -85 -196 -158 -196 -161 0 -4 17 -19 38 -35 128 -96 327 -272 339 -301 16 -39 17 -143 2 -177 -15 -33 -34 -39 -67 -22 -48 26 -566 446 -584 474 -23 35 -23 89 1 128 16 27 150 143 376 326 39 31 79 65 90 75 44 41 128 103 139 103 7 0 21 -6 32 -14z m713 -25 c52 -37 53 -33 -92 -551 -36 -129 -80 -289 -98 -355 -39 -143 -62 -175 -129 -175 -47 0 -92 20 -114 52 -24 34 -19 90 18 217 19 64 78 271 131 461 53 190 98 353 101 364 6 16 14 18 79 14 54 -4 81 -11 104 -27z m1116 -351 c55 -45 117 -98 138 -120 61 -64 48 -115 -50 -192 -32 -25 -118 -94 -191 -152 -136 -108 -163 -120 -217 -100 -26 10 -47 62 -39 97 10 46 22 62 94 119 36 29 93 77 128 106 l62 55 -65 59 c-71 65 -77 84 -49 141 24 47 44 67 68 67 12 0 66 -36 121 -80z"/><path fill="#5B7FFF" d="M2328 3755 c-37 -20 -54 -53 -153 -280 -48 -110 -96 -219 -106 -242 l-18 -43 234 0 235 0 0 290 0 290 -82 0 c-53 -1 -93 -6 -110 -15z"/></g></svg>`,
alibaba: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Alibaba</title><path d="M24 14.014c-2.8 1.512-5.62 2.896-8.759 3.524-.7.139-1.476.139-2.187.043-.678-.085-1.017-.682-.776-1.31.23-.585.536-1.181.93-1.671.852-1.065 1.814-2.034 2.678-3.088a15.75 15.75 0 001.422-2.054c.306-.511.164-1.129-.372-1.384-.897-.437-1.859-.745-2.81-1.075-.11-.043-.274.074-.492.149.273.244.47.425.743.67-2.821.48-5.49 1.16-8.08 2.098-.012.053-.033.095-.023.117.383.585.208 1.032-.35 1.394a2.365 2.365 0 00-.568.522c1.706.5 3.226.213 4.68-.735-.087-.127-.175-.244-.262-.372.546.096.874.394.918.862.011.107-.054.213-.087.32-.077-.086-.175-.17-.24-.267-.045-.064-.056-.138-.088-.245-1.728 1.15-3.587 1.438-5.632.842 0 .404-.022.745.011 1.075.022.287-.098.415-.36.564-.591.362-1.204.735-1.696 1.214-.59.585-.371 1.299.427 1.597.907.34 1.859.35 2.81.234 1.126-.139 2.23-.32 3.456-.49-1.433.67-2.844 1.14-4.33 1.33-1.04.14-2.078.214-3.106-.084-1.476-.415-2.133-1.501-1.75-2.96.361-1.363 1.236-2.449 2.176-3.45 3.139-3.332 7.108-5.024 11.7-5.365 1.072-.074 2.155.064 3.16.511 1.411.639 2.002 1.99 1.313 3.354-.448.905-1.072 1.735-1.695 2.555-.612.809-1.301 1.554-1.946 2.331-.186.234-.361.48-.503.745-.274.5-.088.83.492.778 1.213-.118 2.45-.213 3.62-.511 1.716-.437 3.389-1.054 5.084-1.597.175-.043.339-.107.492-.17z" fill="#FF6003" fill-rule="evenodd"></path></svg>`,
anthropic: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>`,
aws: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AWS</title><path d="M6.763 11.212c0 .296.032.535.088.71.064.176.144.368.256.576.04.063.056.127.056.183 0 .08-.048.16-.152.24l-.503.335a.383.383 0 01-.208.072c-.08 0-.16-.04-.239-.112a2.47 2.47 0 01-.287-.375 6.18 6.18 0 01-.248-.471c-.622.734-1.405 1.101-2.347 1.101-.67 0-1.205-.191-1.596-.574-.39-.384-.59-.894-.59-1.533 0-.678.24-1.23.726-1.644.487-.415 1.133-.623 1.955-.623.272 0 .551.024.846.064.296.04.6.104.918.176v-.583c0-.607-.127-1.03-.375-1.277-.255-.248-.686-.367-1.3-.367-.28 0-.568.031-.863.103-.295.072-.583.16-.862.272a2.4 2.4 0 01-.28.104.488.488 0 01-.127.023c-.112 0-.168-.08-.168-.247v-.391c0-.128.016-.224.056-.28a.597.597 0 01.224-.167 4.577 4.577 0 011.005-.36 4.84 4.84 0 011.246-.151c.95 0 1.644.216 2.091.647.44.43.662 1.085.662 1.963v2.586h.016zm-3.24 1.214c.263 0 .534-.048.822-.144a1.78 1.78 0 00.758-.51 1.27 1.27 0 00.272-.512c.047-.191.08-.423.08-.694v-.335a6.66 6.66 0 00-.735-.136 6.02 6.02 0 00-.75-.048c-.535 0-.926.104-1.19.32-.263.215-.39.518-.39.917 0 .375.095.655.295.846.191.2.47.296.838.296zm6.41.862c-.144 0-.24-.024-.304-.08-.064-.048-.12-.16-.168-.311L7.586 6.726a1.398 1.398 0 01-.072-.32c0-.128.064-.2.191-.2h.783c.151 0 .255.025.31.08.065.048.113.16.16.312l1.342 5.284 1.245-5.284c.04-.16.088-.264.151-.312a.549.549 0 01.32-.08h.638c.152 0 .256.025.32.08.063.048.12.16.151.312l1.261 5.348 1.381-5.348c.048-.16.104-.264.16-.312a.52.52 0 01.311-.08h.743c.127 0 .2.065.2.2 0 .04-.009.08-.017.128a1.137 1.137 0 01-.056.2l-1.923 6.17c-.048.16-.104.263-.168.311a.51.51 0 01-.303.08h-.687c-.15 0-.255-.024-.32-.08-.063-.056-.119-.16-.15-.32L12.32 7.747l-1.23 5.14c-.04.16-.087.264-.15.32-.065.056-.177.08-.32.08l-.686.001zm10.256.215c-.415 0-.83-.048-1.229-.143-.399-.096-.71-.2-.918-.32-.128-.071-.215-.151-.247-.223a.563.563 0 01-.048-.224v-.407c0-.167.064-.247.183-.247.048 0 .096.008.144.024.048.016.12.048.2.08.271.12.566.215.878.279.32.064.63.096.95.096.502 0 .894-.088 1.165-.264a.86.86 0 00.415-.758.777.777 0 00-.215-.559c-.144-.151-.416-.287-.807-.415l-1.157-.36c-.583-.183-1.014-.454-1.277-.813a1.902 1.902 0 01-.4-1.158c0-.335.073-.63.216-.886.144-.255.335-.479.575-.654.24-.184.51-.32.83-.415.32-.096.655-.136 1.006-.136.175 0 .36.008.535.032.183.024.35.056.518.088.16.04.312.08.455.127.144.048.256.096.336.144a.69.69 0 01.24.2.43.43 0 01.071.263v.375c0 .168-.064.256-.184.256a.83.83 0 01-.303-.096 3.652 3.652 0 00-1.532-.311c-.455 0-.815.071-1.062.223-.248.152-.375.383-.375.71 0 .224.08.416.24.567.16.152.454.304.877.44l1.134.358c.574.184.99.44 1.237.767.247.327.367.702.367 1.117 0 .343-.072.655-.207.926a2.157 2.157 0 01-.583.703c-.248.2-.543.343-.886.447-.36.111-.734.167-1.142.167z"></path><path d="M.378 15.475c3.384 1.963 7.56 3.153 11.877 3.153 2.914 0 6.114-.607 9.06-1.852.44-.2.814.287.383.607-2.626 1.94-6.442 2.969-9.722 2.969-4.598 0-8.74-1.7-11.87-4.526-.247-.223-.024-.527.272-.351zm23.531-.2c.287.36-.08 2.826-1.485 4.007-.215.184-.423.088-.327-.151l.175-.439c.343-.88.802-2.198.52-2.555-.336-.43-2.22-.207-3.074-.103-.255.032-.295-.192-.063-.36 1.5-1.053 3.967-.75 4.254-.399z" fill="#F90"></path></svg>`,
@@ -45,6 +46,10 @@ export const icons: Record<string, string> = {
yi: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Yi</title><path d="M18.62 13.927c.611 0 1.107.505 1.107 1.128v5.817c0 .623-.496 1.128-1.108 1.128a1.118 1.118 0 01-1.108-1.128v-5.817c0-.623.496-1.128 1.108-1.128zM16.59 3.052a1.094 1.094 0 011.562-.129c.466.404.522 1.116.126 1.59l-5.938 7.111v9.147c0 .624-.496 1.129-1.108 1.129a1.118 1.118 0 01-1.108-1.129v-9.477l.003-.088.01-.087c.015-.232.102-.462.261-.654l6.192-7.413zM2.906 2.256a1.094 1.094 0 011.559.157l4.387 5.45a1.142 1.142 0 01-.155 1.587 1.094 1.094 0 01-1.559-.157l-4.387-5.45a1.144 1.144 0 01.06-1.498l.095-.09z"></path><ellipse cx="20.146" cy="10.692" fill="#00FF25" rx="1.354" ry="1.379"></ellipse></svg>`,
zeroone: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>01.AI</title><path d="M5.246 12c0 .837-.086 1.554-.257 2.151-.172.598-.45 1.055-.837 1.373-.386.317-.898.476-1.534.476-.901 0-1.563-.353-1.985-1.059C.211 14.235 0 13.255 0 12c0-.837.086-1.554.257-2.151.172-.598.45-1.055.832-1.373C1.472 8.16 1.981 8 2.618 8c.894 0 1.555.351 1.985 1.053.429.702.643 1.685.643 2.947zm-3.883 0c0 .956.09 1.668.273 2.134.183.467.51.7.982.7.465 0 .792-.23.981-.694.19-.463.285-1.176.285-2.14 0-.956-.095-1.668-.285-2.134-.19-.467-.516-.7-.981-.7-.472 0-.8.233-.982.7-.182.466-.273 1.178-.273 2.134zm8.52 3.771H8.517l.011-6.295-1.823.324V8.571l2.04-.457h1.136v7.657zm2.497-1.6h.543c.3 0 .543.256.543.572v.571a.558.558 0 01-.543.572h-.543a.558.558 0 01-.543-.572v-.571c0-.316.243-.572.543-.572zm10.317-6.057H24v7.772h-1.303V8.114zm-3.692 0l2.606 7.772h-1.303l-.69-2.058h-3.073l-.69 2.058h-1.303l2.606-7.772h1.847zm.191 4.457l-1.115-3.323-1.114 3.323h2.23z"></path></svg>`,
zhipu: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>`,
openrouter: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path></svg>`,
longcat: `<svg fill="currentColor" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LongCat</title><path clip-rule="evenodd" d="M.507 19.883a.507.507 0 01-.489-.642L4.29 3.745a1.013 1.013 0 011.533-.578l5.622 3.687a1.013 1.013 0 001.11 0L18.2 3.165a1.013 1.013 0 011.532.58l4.25 15.497a.506.506 0 01-.49.64H18.07a6.297 6.297 0 001.53-4.115v-.177a6.09 6.09 0 00-1.513-4.017l-.697-3.495a.438.438 0 00-.694-.266L14.07 9.781a.748.748 0 01-.654.121 5.156 5.156 0 00-2.833 0 .746.746 0 01-.653-.121L7.302 7.81a.435.435 0 00-.688.269l-.675 3.652a5.36 5.36 0 00-1.539 3.76v.333c0 1.474.527 2.9 1.488 4.02l.032.038H.507z" fill="#29E154" fill-rule="evenodd"></path><path d="M9.213 16.843h1.52v-3.546h-1.29l-.23 3.546zm5.573 0h-1.52v-3.546h1.29l.23 3.546z"></path></svg>`,
modelscope: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelScope</title><path d="M0 7.967h2.667v2.667H0zM8 10.633h2.667V13.3H8z" fill="#36CED0"></path><path d="M0 10.633h2.667V13.3H0zM2.667 13.3h2.666v2.667H8v2.666H2.667V13.3zM2.667 5.3H8v2.667H5.333v2.666H2.667V5.3zM10.667 13.3h2.667v2.667h-2.667z" fill="#624AFF"></path><path d="M24 7.967h-2.667v2.667H24zM16 10.633h-2.667V13.3H16z" fill="#36CED0"></path><path d="M24 10.633h-2.667V13.3H24zM21.333 13.3h-2.666v2.667H16v2.666h5.333V13.3zM21.333 5.3H16v2.667h2.667v2.666h2.666V5.3z" fill="#624AFF"></path></svg>`,
aihubmix: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AiHubMix</title><path d="M12 24c6.627 0 12-5.373 12-12S18.627 0 12 0 0 5.373 0 12s5.373 12 12 12z" fill="#006FFB"></path><path clip-rule="evenodd" d="M11.24 8.393c.095-.644.302-1.47.624-2.48L12 5.496l.136.417c.322 1.01.53 1.836.624 2.48.071.472.071 1.072 0 1.8-.072.731-.072 1.336 0 1.814.106.7.426 1.281.96 1.744a2.795 2.795 0 001.89.708 2.78 2.78 0 002.034-.84c.56-.559.842-1.234.848-2.024.003-.7.075-1.472.216-2.316.069-.422.14-.775.21-1.06l.095-.384.168.356a7.862 7.862 0 01.76 3.244v.16a7.84 7.84 0 01-.624 3.089 7.952 7.952 0 01-4.228 4.228 7.841 7.841 0 01-3.089.623 7.84 7.84 0 01-3.089-.623 7.952 7.952 0 01-4.228-4.228 7.84 7.84 0 01-.623-3.09v-.159a7.862 7.862 0 01.759-3.244l.169-.356.093.385c.072.284.143.637.211 1.059.141.844.213 1.616.216 2.316.006.79.29 1.465.848 2.024.563.56 1.241.84 2.035.84.715 0 1.345-.236 1.889-.708a2.79 2.79 0 00.96-1.744c.073-.478.073-1.083 0-1.814-.071-.728-.071-1.328 0-1.8zm.76 9.694c1.097 0 2.125-.26 3.085-.778a6.379 6.379 0 001.77-1.399c.063-.07-.01-.178-.101-.153-.37.1-.75.15-1.144.15a4.236 4.236 0 01-2.18-.59 4.253 4.253 0 01-1.35-1.233.099.099 0 00-.16 0 4.253 4.253 0 01-1.35 1.232 4.236 4.236 0 01-2.18.591c-.393 0-.774-.05-1.143-.15-.091-.025-.165.083-.102.153a6.38 6.38 0 001.77 1.399c.96.518 1.988.778 3.085.778z" fill="#fff" fill-rule="evenodd"></path></svg>`,
};
export const iconList = Object.keys(icons);
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LongCat</title><path clip-rule="evenodd" d="M.507 19.883a.507.507 0 01-.489-.642L4.29 3.745a1.013 1.013 0 011.533-.578l5.622 3.687a1.013 1.013 0 001.11 0L18.2 3.165a1.013 1.013 0 011.532.58l4.25 15.497a.506.506 0 01-.49.64H18.07a6.297 6.297 0 001.53-4.115v-.177a6.09 6.09 0 00-1.513-4.017l-.697-3.495a.438.438 0 00-.694-.266L14.07 9.781a.748.748 0 01-.654.121 5.156 5.156 0 00-2.833 0 .746.746 0 01-.653-.121L7.302 7.81a.435.435 0 00-.688.269l-.675 3.652a5.36 5.36 0 00-1.539 3.76v.333c0 1.474.527 2.9 1.488 4.02l.032.038H.507z" fill="#29E154" fill-rule="evenodd"></path><path d="M9.213 16.843h1.52v-3.546h-1.29l-.23 3.546zm5.573 0h-1.52v-3.546h1.29l.23 3.546z"></path></svg>

After

Width:  |  Height:  |  Size: 819 B

+35
View File
@@ -2,6 +2,13 @@
import { IconMetadata } from "@/types/icon";
export const iconMetadata: Record<string, IconMetadata> = {
aigocode: {
name: "aigocode",
displayName: "AiGoCode",
category: "ai-provider",
keywords: ["aigocode", "aigo", "code", "third-party"],
defaultColor: "#5B7FFF",
},
alibaba: {
name: "alibaba",
displayName: "Alibaba",
@@ -303,6 +310,34 @@ export const iconMetadata: Record<string, IconMetadata> = {
keywords: ["chatglm", "glm"],
defaultColor: "#0F62FE",
},
openrouter: {
name: "openrouter",
displayName: "OpenRouter",
category: "ai-provider",
keywords: ["openrouter", "router", "aggregator"],
defaultColor: "#6566F1",
},
longcat: {
name: "longcat",
displayName: "LongCat",
category: "ai-provider",
keywords: ["longcat", "long", "cat"],
defaultColor: "#29E154",
},
modelscope: {
name: "modelscope",
displayName: "ModelScope",
category: "ai-provider",
keywords: ["modelscope", "alibaba", "scope"],
defaultColor: "#624AFF",
},
aihubmix: {
name: "aihubmix",
displayName: "AiHubMix",
category: "ai-provider",
keywords: ["aihubmix", "hub", "mix", "aggregator"],
defaultColor: "#006FFB",
},
};
export function getIconMetadata(name: string): IconMetadata | undefined {
+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelScope</title><path d="M0 7.967h2.667v2.667H0zM8 10.633h2.667V13.3H8z" fill="#36CED0"></path><path d="M0 10.633h2.667V13.3H0zM2.667 13.3h2.666v2.667H8v2.666H2.667V13.3zM2.667 5.3H8v2.667H5.333v2.666H2.667V5.3zM10.667 13.3h2.667v2.667h-2.667z" fill="#624AFF"></path><path d="M24 7.967h-2.667v2.667H24zM16 10.633h-2.667V13.3H16z" fill="#36CED0"></path><path d="M24 10.633h-2.667V13.3H24zM21.333 13.3h-2.666v2.667H16v2.666h5.333V13.3zM21.333 5.3H16v2.667h2.667v2.666h2.666V5.3z" fill="#624AFF"></path></svg>

After

Width:  |  Height:  |  Size: 632 B

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path></svg>

After

Width:  |  Height:  |  Size: 906 B

+15 -11
View File
@@ -70,7 +70,6 @@
}
}
/* Glassmorphism Utilities */
/* Glassmorphism Utilities */
.glass {
background: rgba(255, 255, 255, 0.7);
@@ -104,6 +103,19 @@
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
}
/* 供应商卡片选中状态 */
.glass-card-active {
background: rgba(59, 130, 246, 0.08);
border: 1px solid rgba(59, 130, 246, 0.4);
box-shadow: 0 0 20px rgba(59, 130, 246, 0.15);
}
.dark .glass-card-active {
background: rgba(59, 130, 246, 0.12);
border: 1px solid rgba(59, 130, 246, 0.3);
box-shadow: 0 0 24px rgba(59, 130, 246, 0.2);
}
.glass-header {
background: hsl(var(--background));
backdrop-filter: none;
@@ -131,6 +143,8 @@
/* 全局基础样式 */
* {
box-sizing: border-box;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE 10+ */
}
html {
@@ -158,16 +172,6 @@ html.dark {
display: none;
}
/* Firefox */
* {
scrollbar-width: none;
}
/* IE 10+ */
* {
-ms-overflow-style: none;
}
/* 焦点样式 */
*:focus-visible {
@apply outline-2 outline-blue-500 outline-offset-2;
-1
View File
@@ -33,7 +33,6 @@ export interface DeepLinkImportRequest {
repo?: string;
directory?: string;
branch?: string;
skillsPath?: string;
// Config file fields
config?: string;
+8
View File
@@ -69,6 +69,14 @@ export const settingsApi = {
return await invoke("apply_claude_plugin_config", { official });
},
async applyClaudeOnboardingSkip(): Promise<boolean> {
return await invoke("apply_claude_onboarding_skip");
},
async clearClaudeOnboardingSkip(): Promise<boolean> {
return await invoke("clear_claude_onboarding_skip");
},
async saveFileDialog(defaultName: string): Promise<string | null> {
return await invoke("save_file_dialog", { defaultName });
},
-2
View File
@@ -10,7 +10,6 @@ export interface Skill {
repoOwner?: string;
repoName?: string;
repoBranch?: string;
skillsPath?: string; // 技能所在的子目录路径,如 "skills"
}
export interface SkillRepo {
@@ -18,7 +17,6 @@ export interface SkillRepo {
name: string;
branch: string;
enabled: boolean;
skillsPath?: string; // 可选:技能所在的子目录路径,如 "skills"
}
export const skillsApi = {
+1
View File
@@ -12,6 +12,7 @@ export const settingsSchema = z.object({
showInTray: z.boolean(),
minimizeToTrayOnClose: z.boolean(),
enableClaudePluginIntegration: z.boolean().optional(),
skipClaudeOnboarding: z.boolean().optional(),
launchOnStartup: z.boolean().optional(),
language: z.enum(["en", "zh", "ja"]).optional(),
+2
View File
@@ -106,6 +106,8 @@ export interface Settings {
minimizeToTrayOnClose: boolean;
// 启用 Claude 插件联动(写入 ~/.claude/config.json 的 primaryApiKey
enableClaudePluginIntegration?: boolean;
// 跳过 Claude Code 初次安装确认(写入 ~/.claude.json 的 hasCompletedOnboarding
skipClaudeOnboarding?: boolean;
// 是否开机自启
launchOnStartup?: boolean;
// 首选语言(可选,默认中文)
+1 -1
View File
@@ -453,7 +453,7 @@ export const setCodexBaseUrl = (
// 归一化原文本中的引号(既能匹配,也能输出稳定格式)
const normalizedText = normalizeQuotes(configText);
const normalizedUrl = trimmed.replace(/\s+/g, "").replace(/\/+$/, "");
const normalizedUrl = trimmed.replace(/\s+/g, "");
const replacementLine = `base_url = "${normalizedUrl}"`;
const pattern = /base_url\s*=\s*(["'])([^"']+)\1/;
+34
View File
@@ -8,6 +8,40 @@ export default {
theme: {
extend: {
colors: {
// shadcn/ui CSS 变量映射
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
// macOS 风格系统蓝
blue: {
400: '#409CFF',
@@ -32,17 +32,17 @@ describe("ImportExportSection Component", () => {
it("should disable import button and show placeholder when no file selected", () => {
render(<ImportExportSection {...baseProps} />);
expect(screen.getByText("settings.noFileSelected")).toBeInTheDocument();
// When no file selected, button shows "selectConfigFile" and clicking it opens file dialog
expect(
screen.getByRole("button", { name: "settings.import" }),
).toBeDisabled();
screen.getByRole("button", { name: /settings\.selectConfigFile/ }),
).toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", { name: "settings.exportConfig" }),
);
expect(baseProps.onExport).toHaveBeenCalledTimes(1);
fireEvent.click(
screen.getByRole("button", { name: "settings.selectConfigFile" }),
screen.getByRole("button", { name: /settings\.selectConfigFile/ }),
);
expect(baseProps.onSelectFile).toHaveBeenCalledTimes(1);
});
@@ -55,9 +55,9 @@ describe("ImportExportSection Component", () => {
/>,
);
expect(screen.getByText("config.json")).toBeInTheDocument();
expect(screen.getByText(/config\.json/)).toBeInTheDocument();
const importButton = screen.getByRole("button", {
name: "settings.import",
name: /settings\.import/,
});
expect(importButton).toBeEnabled();
fireEvent.click(importButton);
+12 -1
View File
@@ -79,6 +79,17 @@ vi.mock("@/components/ui/textarea", () => ({
),
}));
vi.mock("@/components/JsonEditor", () => ({
default: ({ value, onChange, placeholder, ...rest }: any) => (
<textarea
value={value}
placeholder={placeholder}
onChange={(event) => onChange?.(event.target.value)}
{...rest}
/>
),
}));
vi.mock("@/components/ui/checkbox", () => ({
Checkbox: ({ id, checked, onCheckedChange, ...rest }: any) => (
<input
@@ -256,7 +267,7 @@ describe("McpFormModal", () => {
await waitFor(() => expect(toastErrorMock).toHaveBeenCalled());
expect(upsertMock).not.toHaveBeenCalled();
const [message] = toastErrorMock.mock.calls.at(-1) ?? [];
expect(message).toBe("mcp.error.jsonInvalid");
expect(message).toBe("mcp.error.commandRequired");
});
it("支持向导生成配置并自动填充 ID", async () => {
+15 -24
View File
@@ -35,6 +35,7 @@ interface SettingsMock {
resetDirectory: ReturnType<typeof vi.fn>;
resetAppConfigDir: ReturnType<typeof vi.fn>;
saveSettings: ReturnType<typeof vi.fn>;
autoSaveSettings: ReturnType<typeof vi.fn>;
resetSettings: ReturnType<typeof vi.fn>;
acknowledgeRestart: ReturnType<typeof vi.fn>;
}
@@ -66,6 +67,7 @@ const createSettingsMock = (overrides: Partial<SettingsMock> = {}) => {
resetDirectory: vi.fn(),
resetAppConfigDir: vi.fn(),
saveSettings: vi.fn().mockResolvedValue({ requiresRestart: false }),
autoSaveSettings: vi.fn().mockResolvedValue({ requiresRestart: false }),
resetSettings: vi.fn(),
acknowledgeRestart: vi.fn(),
};
@@ -247,7 +249,8 @@ describe("SettingsPage Component", () => {
render(<SettingsPage open={true} onOpenChange={vi.fn()} />);
expect(screen.queryByText("language:zh")).not.toBeInTheDocument();
expect(screen.getByText("settings.title")).toBeInTheDocument();
// 加载状态下显示 spinner 而不是表单内容
expect(document.querySelector(".animate-spin")).toBeInTheDocument();
});
it("should reset import/export status when dialog transitions to open", () => {
@@ -264,6 +267,7 @@ describe("SettingsPage Component", () => {
it("should render general and advanced tabs and trigger child callbacks", () => {
const onOpenChange = vi.fn();
// 设置 selectedFile 后,按钮显示 settings.import(可执行导入)
importExportMock = createImportExportMock({
selectedFile: "/tmp/config.json",
});
@@ -284,21 +288,20 @@ describe("SettingsPage Component", () => {
});
fireEvent.click(screen.getByText("settings.tabAdvanced"));
fireEvent.click(
screen.getByRole("button", { name: "settings.selectConfigFile" }),
);
expect(importExportMock.selectImportFile).toHaveBeenCalled();
// 有文件时,点击导入按钮执行 importConfig
fireEvent.click(
screen.getByRole("button", { name: /settings\.import/ }),
);
expect(importExportMock.importConfig).toHaveBeenCalled();
fireEvent.click(
screen.getByRole("button", { name: "settings.exportConfig" }),
);
expect(importExportMock.exportConfig).toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "settings.import" }));
expect(importExportMock.importConfig).toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "common.clear" }));
// 清除选择按钮
fireEvent.click(screen.getByRole("button", { name: "Clear selection" }));
expect(importExportMock.clearSelection).toHaveBeenCalled();
});
@@ -330,7 +333,9 @@ describe("SettingsPage Component", () => {
render(<SettingsPage open={true} onOpenChange={onOpenChange} />);
fireEvent.click(screen.getByText("common.save"));
// 保存按钮在 advanced tab 中
fireEvent.click(screen.getByText("settings.tabAdvanced"));
fireEvent.click(screen.getByRole("button", { name: /common\.save/ }));
await waitFor(() => {
expect(settingsMock.saveSettings).toHaveBeenCalledTimes(1);
@@ -341,20 +346,6 @@ describe("SettingsPage Component", () => {
});
});
it("should reset settings and close dialog when clicking cancel", () => {
const onOpenChange = vi.fn();
render(<SettingsPage open={true} onOpenChange={onOpenChange} />);
fireEvent.click(screen.getByText("common.cancel"));
expect(settingsMock.resetSettings).toHaveBeenCalledTimes(1);
expect(settingsMock.acknowledgeRestart).toHaveBeenCalledTimes(1);
expect(importExportMock.clearSelection).toHaveBeenCalledTimes(1);
expect(importExportMock.resetStatus).toHaveBeenCalledTimes(2);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("should show restart prompt and allow immediate restart after save", async () => {
settingsMock = createSettingsMock({
requiresRestart: true,
-6
View File
@@ -110,12 +110,6 @@ describe("useImportExport Hook", () => {
expect(result.current.status).toBe("success");
expect(result.current.backupId).toBe("backup-123");
expect(toastSuccessMock).toHaveBeenCalledTimes(1);
// Skip delay to execute callback
await act(async () => {
vi.runOnlyPendingTimers();
});
expect(onImportSuccess).toHaveBeenCalledTimes(1);
});
+72
View File
@@ -7,7 +7,10 @@ const mutateAsyncMock = vi.fn();
const useSettingsQueryMock = vi.fn();
const setAppConfigDirOverrideMock = vi.fn();
const applyClaudePluginConfigMock = vi.fn();
const applyClaudeOnboardingSkipMock = vi.fn();
const clearClaudeOnboardingSkipMock = vi.fn();
const syncCurrentProvidersLiveMock = vi.fn();
const updateTrayMenuMock = vi.fn();
const toastErrorMock = vi.fn();
const toastSuccessMock = vi.fn();
@@ -49,9 +52,16 @@ vi.mock("@/lib/api", () => ({
setAppConfigDirOverrideMock(...args),
applyClaudePluginConfig: (...args: unknown[]) =>
applyClaudePluginConfigMock(...args),
applyClaudeOnboardingSkip: (...args: unknown[]) =>
applyClaudeOnboardingSkipMock(...args),
clearClaudeOnboardingSkip: (...args: unknown[]) =>
clearClaudeOnboardingSkipMock(...args),
syncCurrentProvidersLive: (...args: unknown[]) =>
syncCurrentProvidersLiveMock(...args),
},
providersApi: {
updateTrayMenu: (...args: unknown[]) => updateTrayMenuMock(...args),
},
}));
const createSettingsFormMock = (overrides: Record<string, unknown> = {}) => ({
@@ -59,6 +69,7 @@ const createSettingsFormMock = (overrides: Record<string, unknown> = {}) => ({
showInTray: true,
minimizeToTrayOnClose: true,
enableClaudePluginIntegration: false,
skipClaudeOnboarding: true,
claudeConfigDir: "/claude",
codexConfigDir: "/codex",
language: "zh",
@@ -107,6 +118,8 @@ describe("useSettings hook", () => {
useSettingsQueryMock.mockReset();
setAppConfigDirOverrideMock.mockReset();
applyClaudePluginConfigMock.mockReset();
applyClaudeOnboardingSkipMock.mockReset();
clearClaudeOnboardingSkipMock.mockReset();
syncCurrentProvidersLiveMock.mockReset();
toastErrorMock.mockReset();
toastSuccessMock.mockReset();
@@ -116,6 +129,7 @@ describe("useSettings hook", () => {
showInTray: true,
minimizeToTrayOnClose: true,
enableClaudePluginIntegration: false,
skipClaudeOnboarding: true,
claudeConfigDir: "/server/claude",
codexConfigDir: "/server/codex",
language: "zh",
@@ -138,6 +152,64 @@ describe("useSettings hook", () => {
mutateAsyncMock.mockResolvedValue(true);
setAppConfigDirOverrideMock.mockResolvedValue(true);
applyClaudePluginConfigMock.mockResolvedValue(true);
applyClaudeOnboardingSkipMock.mockResolvedValue(true);
clearClaudeOnboardingSkipMock.mockResolvedValue(true);
});
it("auto-saves and applies Claude onboarding skip when toggled on", async () => {
serverSettings = {
...serverSettings,
skipClaudeOnboarding: false,
};
useSettingsQueryMock.mockReturnValue({
data: serverSettings,
isLoading: false,
});
settingsFormMock = createSettingsFormMock({
settings: {
...serverSettings,
language: "zh",
skipClaudeOnboarding: false,
},
});
const { result } = renderHook(() => useSettings());
await act(async () => {
await result.current.autoSaveSettings({ skipClaudeOnboarding: true });
});
expect(applyClaudeOnboardingSkipMock).toHaveBeenCalledTimes(1);
expect(toastErrorMock).not.toHaveBeenCalled();
});
it("auto-saves and clears Claude onboarding skip when toggled off", async () => {
serverSettings = {
...serverSettings,
skipClaudeOnboarding: true,
};
useSettingsQueryMock.mockReturnValue({
data: serverSettings,
isLoading: false,
});
settingsFormMock = createSettingsFormMock({
settings: {
...serverSettings,
language: "zh",
skipClaudeOnboarding: true,
},
});
const { result } = renderHook(() => useSettings());
await act(async () => {
await result.current.autoSaveSettings({ skipClaudeOnboarding: false });
});
expect(clearClaudeOnboardingSkipMock).toHaveBeenCalledTimes(1);
expect(toastErrorMock).not.toHaveBeenCalled();
});
it("saves settings and flags restart when app config directory changes", async () => {
+2 -2
View File
@@ -109,8 +109,8 @@ vi.mock("@/components/ConfirmDialog", () => ({
) : null,
}));
vi.mock("@/components/settings/SettingsDialog", () => ({
SettingsDialog: ({ open, onOpenChange, onImportSuccess }: any) =>
vi.mock("@/components/settings/SettingsPage", () => ({
SettingsPage: ({ open, onOpenChange, onImportSuccess }: any) =>
open ? (
<div data-testid="settings-dialog">
<button onClick={() => onImportSuccess?.()}>
+1 -1
View File
@@ -264,7 +264,7 @@ describe("SettingsPage integration", () => {
await waitFor(() => expect(toastErrorMock).toHaveBeenCalled());
const cancelMessage = toastErrorMock.mock.calls.at(-1)?.[0] as string;
expect(cancelMessage).toMatch(
/settings\.selectFileFailed|选择保存位置失败/,
/settings\.selectFileFailed|选择.*保存路径/,
);
toastErrorMock.mockClear();
+4
View File
@@ -176,6 +176,10 @@ export const handlers = [
},
),
http.post(`${TAURI_ENDPOINT}/apply_claude_onboarding_skip`, () => success(true)),
http.post(`${TAURI_ENDPOINT}/clear_claude_onboarding_skip`, () => success(true)),
http.post(`${TAURI_ENDPOINT}/get_config_dir`, async ({ request }) => {
const { app } = await withJson<{ app: AppId }>(request);
return success(app === "claude" ? "/default/claude" : "/default/codex");