Compare commits

..

100 Commits

Author SHA1 Message Date
YoVinchen 9d01b9cd79 docs: add user manual documentation
Add comprehensive user manual covering:
- Getting started guide (introduction, installation, interface, quickstart, settings)
- Provider management (add, switch, edit, sort/duplicate, usage query)
- Extensions (MCP servers, prompts, skills)
- Proxy features (service, takeover, failover, usage, model test)
- FAQ (config files, common questions, deeplink, env conflict)
2026-01-08 01:35:45 +08:00
Jason 24a36df140 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
2026-01-06 23:05:22 +08:00
Jason 2c4c2ce83d fix(settings): navigate to About tab when clicking update badge
- Add defaultTab prop to SettingsPage for external tab control
- UpdateBadge click now opens settings directly to About tab
- Settings button still opens to General tab (default behavior)
- Change update badge text from version number to "Update available"
2026-01-06 11:27:32 +08:00
Jason 0020889a8f fix(prompts): allow saving prompts with empty content
Remove content validation requirement to allow users to save prompts
with empty content for placeholder or draft purposes.
2026-01-05 23:30:03 +08:00
Jason 671cda60d9 fix(database): show error dialog on initialization failure with retry option
- Add user-friendly dialog when database init or schema migration fails
- Support retry mechanism instead of crashing the app
- Include bilingual (Chinese/English) error messages with troubleshooting tips
- Add comprehensive test for v3.8 → current schema migration path
2026-01-05 22:39:39 +08:00
Jason efa653809b style: format code with Prettier 2026-01-05 21:53:01 +08:00
Jason 5aa35906d8 feat(release): add RPM and Flatpak packaging support for Linux
- Add RPM bundle to Linux build targets in CI workflow
- Add Flatpak manifest, desktop entry, and AppStream metainfo
- Update release workflow to build and publish .rpm and .flatpak artifacts
- Update README docs with new Linux package formats and installation instructions
- Add .gitignore rules for Flatpak build artifacts
2026-01-05 16:35:36 +08:00
Jason 4777c99b38 refactor(settings): reorder advanced tab items for better UX
Move Auto Failover section directly after Proxy section since they are
functionally related (failover depends on proxy service).
2026-01-05 16:35:36 +08:00
duskzhen 8912216cb2 fix(common): improve window dragging area in FullScreenPanel (#525)
Add data-tauri-drag-region and WebkitAppRegion styles to header area to match	App.tsx behavior, ensuring proper window dragging on macOS.
2026-01-05 16:25:38 +08:00
Jason 32149b1eeb chore(proxy): remove unused body filter helper
Remove unused `filter_recursive` function from body_filter.rs to fix
`dead-code` warning from `cargo clippy -- -D warnings`.
2026-01-04 23:00:13 +08:00
Jason 8979d964d6 fix(proxy): clean up model override env vars when switching providers in takeover mode
When proxy takeover is enabled, switching providers no longer writes to
the Live config. However, if model override fields (ANTHROPIC_MODEL,
ANTHROPIC_REASONING_MODEL, etc.) remain in the Live config, Claude Code
continues sending requests with the old model name, causing failures
when the new provider doesn't support that model.

This fix:
- Removes model override env keys from Claude Live config during takeover
- Adds cleanup when switching providers in takeover mode
- Fixes has_mapping() to include reasoning_model in the check
- Adds test coverage for reasoning-only model mapping scenarios
2026-01-04 16:09:37 +08:00
Jason 37396b9c70 fix(common-config): reset initialization flag when preset changes
The common config checkbox was only auto-enabled for the first preset
(custom) because hasInitializedNewMode ref was permanently locked to
true after first initialization.

Add selectedPresetId parameter to all three common config hooks and
reset the initialization flag when preset changes, allowing the
initialization logic to re-run for each preset switch.
2026-01-04 12:27:22 +08:00
Jason 2c35372ca0 fix(codex): remove entire model_providers table from common config extraction
Previously only removed base_url from model_providers.* tables. Now removes
the entire model_providers section since all its fields (name, base_url,
wire_api, requires_openai_auth) are provider-specific configuration.

MCP servers configuration remains preserved as it's provider-agnostic.
2026-01-04 12:27:22 +08:00
Jason 63bb673bf2 refactor(common-config): extract snippet from editor content instead of active provider
- Change extraction source from current active provider to editor's live content
- Add i18n support for JSON parse error messages via invalid_json_format_error()
- Simplify API by removing unused providerId parameter
- Update button labels and error messages in zh/en/ja locales
2026-01-04 12:27:22 +08:00
Jason 058f86aff3 fix(codex): prevent extract_common_config from removing MCP servers' base_url
- Replace regex patterns with toml_edit for precise field removal
- Only remove top-level model/model_provider/base_url fields
- Only remove base_url from [model_providers.*] tables
- Add regression test to ensure [mcp_servers.*] base_url is preserved
2026-01-04 12:27:22 +08:00
Jason 188c94f2e3 feat(common-config): add extract from current provider functionality
- Add backend command to extract common config snippet from current provider
- Automatically extract common config on first run after importing default provider
- Auto-enable common config checkbox in new provider mode when snippet exists
- Refactor Gemini common config to operate on .env instead of config.json
- Add "Extract from Current" button to all three common config modals
- Update i18n translations for new extraction feature
2026-01-04 12:27:22 +08:00
Dex Miller c049c5f2bb feat(proxy): update failover timeout and circuit breaker defaults (#521)
- Double all timeout values (streaming/non-streaming)
- Codex/Gemini: circuit_failure_threshold 5→4, error_rate 0.5→0.6
- Claude: circuit_error_rate_threshold 0.6→0.7
2026-01-04 11:52:12 +08:00
jwaterwater c71b030662 feat: change the default Qwen BaseUrl (#517) 2026-01-04 10:54:36 +08:00
Jason 2105f2d05b fix(i18n): remove hardcoded defaultValue and unused placeholder for reasoning model 2026-01-03 12:04:34 +08:00
Jason 0a9de282a3 fix(skills): skip hidden directories when scanning for skills
Filter out directories starting with '.' (e.g., .system) during skill
scanning to avoid exposing internal system directories from Codex.
2026-01-03 11:42:10 +08:00
Jason 0e085aa01a fix(prompts): change toggle color from blue to emerald
Align with the app-wide Switch component color scheme for
visual consistency across all toggle elements.
2026-01-03 10:15:28 +08:00
Jason 2c95f697cd fix(prompts): unify add button style with other panels
Replace circular orange icon button with ghost text button
to match Skills and MCP panel styling.
2026-01-03 10:13:32 +08:00
Jason ba97a5f373 fix(mcp): unify header buttons style with skills panel
Changed MCP panel buttons to match Skills panel styling:
- Replace circular orange icon button with ghost text button
- Update button labels: "Import" → "Import Existing", add "Add MCP"
2026-01-03 10:11:26 +08:00
Jason 86e802bd4b feat(mcp): add import button to import MCP servers from apps
- Add import_mcp_from_apps command that reuses existing import logic
- Add Import button in MCP panel header (consistent with Skills)
- Fix import count to only return truly new servers (not already in DB)
- Update translations for import success/no-import messages (zh/en/ja)
2026-01-03 10:04:46 +08:00
Jason 6460c1d5dd fix(skills): move import button to header for better discoverability
- Add import button next to discover button in skills page header
- Expose openImport method via UnifiedSkillsPanel ref
- Show toast instead of dialog when no unmanaged skills found
- Remove redundant buttons from empty state view
2026-01-03 09:21:15 +08:00
Jason 6b73e55bfe fix(ui): remove hover scale effect from skill cards
The hover:scale-[1.01] effect caused cards to overflow their container
boundaries. Keeping only hover:shadow-lg provides sufficient visual
feedback without the overflow issue.
2026-01-02 23:57:22 +08:00
Jason 47aa4c6bee fix(skills): show loading indicator when refreshing discovery list
Use isFetching instead of just isLoading to show the loading spinner.
isLoading is only true on initial load, while isFetching is true
during any fetch operation including refetch.
2026-01-02 23:51:03 +08:00
Jason 4dc59dff21 fix(skills): remove refresh button from installed skills panel
The refresh button is only needed in the discovery panel (to fetch
latest skills from GitHub). The installed skills panel uses local
database which auto-updates on install/uninstall operations.
2026-01-02 23:41:28 +08:00
Jason c8750f5550 fix(i18n): rename Skills title to be app-agnostic
- Remove "Claude" prefix from Skills management title
- Update descriptions to include Gemini alongside Claude Code/Codex
- Applied to all three locales: zh, en, ja
2026-01-02 23:15:22 +08:00
Jason 22460de976 perf(skills): use infinite cache for discoverable skills
Change staleTime from 5 minutes to Infinity. Cache is still properly
invalidated when repos are added/removed or skills are installed/uninstalled.
2026-01-02 23:04:40 +08:00
Jason e69c1bd8aa fix(ui): align FullScreenPanel header with App.tsx layout
- Use same DRAG_BAR_HEIGHT (28px) and HEADER_HEIGHT (64px) as App.tsx
- Remove border-b divider line from header
- Add rounded-lg class to back button for consistency
2026-01-02 22:35:33 +08:00
Jason a17fa8098b fix(skills): remove redundant navigation buttons in skills pages
- Remove duplicate "Repo Manager" button from installed skills view
  (should only appear in discovery view)
- Remove redundant back button from SkillsPage component
  (header already provides unified navigation)
- Clean up unused openRepoManagerOnDiscovery state and related useEffect
- Remove unused onClose prop and ArrowLeft import from SkillsPage
2026-01-02 22:33:33 +08:00
Jason ff03ca1e63 feat(skills): unified management architecture with SSOT and React Query
- Introduce SSOT (Single Source of Truth) at ~/.cc-switch/skills/
- Add three-app toggle support (Claude/Codex/Gemini) for each skill
- Refactor frontend to use TanStack Query hooks instead of manual state
- Add UnifiedSkillsPanel for managing installed skills with app toggles
- Add useSkills.ts with declarative data fetching hooks
- Extend skills.ts API with unified install/uninstall/toggle methods
- Support importing unmanaged skills from app directories
- Add v2→v3 database migration for new skills table structure
2026-01-02 22:04:02 +08:00
Jason cce6ae86a5 fix: prevent env check card border overflow on hover
Add horizontal padding to the grid container to accommodate the scale
transform effect when hovering over environment check cards.
2025-12-31 22:58:03 +08:00
Dex Miller 5376ea042b Feat/usage improvements (#508)
* i18n: update cache terminology across all languages

- Change 'Cache Read' to 'Cache Hit' in all languages
- Change 'Cache Write' to 'Cache Creation' in all languages
- Update zh: 缓存读取 → 缓存命中, 缓存写入 → 缓存创建
- Update en: Cache Read → Cache Hit, Cache Write → Cache Creation
- Update ja: キャッシュ読取 → キャッシュヒット, キャッシュ書込 → キャッシュ作成

Affected keys: cacheReadTokens, cacheCreationTokens, cacheReadCost,
cacheWriteCost, cacheRead, cacheWrite

* feat(usage): add cache metrics to trend chart

- Add cache creation tokens visualization (orange line)
- Add cache hit tokens visualization (purple line)
- Add gradient definitions for new cache metrics
- Include cache data in hourly aggregation
- Display cache metrics alongside input/output tokens

This provides better visibility into cache usage patterns over time.

* fix(usage): fix timezone handling in datetime picker

- Add timestampToLocalDatetime() to convert Unix timestamp to local datetime
- Add localDatetimeToTimestamp() with validation for incomplete input
- Fix issue where typing hours/minutes would jump to previous day
- Validate datetime format completeness before conversion
- Use local timezone instead of UTC for datetime-local input

This resolves the issue where users couldn't fine-tune time selection
and the input would jump unexpectedly when editing hours or minutes.

* feat(usage): add auto-refresh for usage statistics

- Add 30-second auto-refresh interval for all usage queries
- Disable background refresh to save resources
- Apply to: summary, trends, provider stats, model stats, request logs
- Queries automatically update when tab is active
- Pause refresh when user switches to another tab

This keeps usage data fresh without manual refresh.

* fix(proxy): improve usage logging and cache token parsing

- Log requests even when usage parsing fails (with default values)
- Add detailed debug logging for usage metrics
- Support cache_read_input_tokens field in Codex responses
- Fallback to input_tokens_details.cached_tokens if needed
- Add test case for cached_tokens in input_tokens_details
- Ensure all requests are tracked in database for analytics

This fixes missing request logs when API responses lack usage data
and improves cache token detection across different response formats.

* style(rust): use inline format args in format! macros

- Replace format!("...", var) with format!("...{var}")
- Update universal provider ID formatting
- Update error message formatting
- Update config.toml generation in Codex provider

Fixes clippy::uninlined_format_args warnings.

* feat(proxy): enhance provider router logging

- Add debug logs for failover queue provider count
- Log circuit breaker state for each provider check
- Add logs for missing current provider scenarios
- Log when no current provider is configured
- Use inline format args for better readability

This improves debugging of provider selection and failover behavior.

* feat(database): update model pricing data

- Update Claude models to full version format (e.g. claude-opus-4-5-20251101)
- Add GPT-5.2 series model pricing (10 models)
- Add GPT-5.1 series model pricing (10 models)
- Add GPT-5 series model pricing (12 models)
- Add Gemini 3 series model pricing (2 models)
- Update Gemini 2.5 series model ID format (use dot separator)
- Unify display names by removing thinking level suffixes

* fix(usage): correct Gemini output token calculation

Fix Gemini API output token parsing to use totalTokenCount - promptTokenCount
instead of candidatesTokenCount alone. This ensures thoughtsTokenCount is
included in output statistics.

- Update from_gemini_response to calculate output from total - input
- Update from_gemini_stream_chunks with same logic for consistency
- Fix from_codex_stream_events to use adjusted token calculation
- Add test case for responses with thoughtsTokenCount
- Update existing tests to match new calculation logic

* fix(usage): correct cache token billing and add Codex format auto-detection

- Avoid double-billing cache tokens by subtracting from input before calculation
- Add smart Codex parser that auto-detects OpenAI vs Codex API format
- Extract model name from Codex responses for accurate tracking

* fix(proxy): improve takeover detection with live config check

- Add live config takeover detection for hot-switch decision
- Rebuild takeover when backup is missing or placeholder remains
- Make detect_takeover_in_live_config_for_app public
- Fix is_takeover_active to use actual takeover status

* refactor(usage): simplify model pricing lookup by removing suffix fallback

Replace complex suffix-stripping fallback with direct prefix/suffix cleanup.
Model IDs are now cleaned by removing vendor prefix (before /) and colon
suffix (after :), then matched exactly against pricing table.

* feat(database): add Chinese AI model pricing data

Add pricing for domestic AI models (CNY/1M tokens):
- Doubao-Seed-Code (ByteDance)
- DeepSeek V3/V3.1/V3.2
- Kimi K2/K2-Thinking/K2-Turbo (Moonshot)
- MiniMax M2/M2.1/M2.1-Lightning
- GLM-4.6/4.7 (Zhipu)
- Mimo V2 Flash (Xiaomi)

Also fix test case to use correct model ID and remove invalid currency column.

* refactor(proxy): improve header forwarding with blacklist approach

Change from whitelist to blacklist mode for request header forwarding.
Only skip headers that will be overridden (auth, host, content-length).
This preserves client's original headers and improves compatibility.

* fix(proxy): bypass timeout and retry configs when failover is disabled

When auto_failover_enabled is false, timeout and retry configurations
should not affect normal request flow. This change ensures:

- create_forwarder: passes 0 for all timeout/retry params when failover
  is disabled, effectively bypassing these checks
- streaming_timeout_config: returns 0 for both first_byte_timeout and
  idle_timeout when failover is disabled

This prevents unnecessary timeout errors and retry attempts when users
have explicitly disabled the failover feature.

* fix(proxy): handle zero value input in failover config fields

* refactor(proxy): remove retry logic and add enabled check for failover

* refactor(proxy): distinguish circuit-open from no-provider errors

* Align usage stats to sliding windows

* feat(proxy): add body and header filtering for upstream requests

* feat(proxy): enable transparent passthrough for headers

- Passthrough anthropic-beta header as-is from client
- Passthrough anthropic-version header from client
- Passthrough client IP headers (x-forwarded-for, x-real-ip) by default
- Filter private params (underscore-prefixed fields) from request body
- No database changes required

* feat(proxy): extract session ID from client requests for logging

- Add SessionIdExtractor to parse session ID from Claude/Codex requests
- Support extraction from metadata.user_id, headers, previous_response_id
- Pass session_id through RequestContext to usage logger
- Enable request correlation by session in proxy_request_logs
2025-12-31 22:57:00 +08:00
Kjasn d0431b66ae fix wrong skill repo branch (#505)
Co-authored-by: yrs <yuruosheng@17paipai.cn>
2025-12-30 15:38:19 +08:00
Jason eaddcbedd7 chore: bump version to 3.9.0-3 2025-12-30 09:03:53 +08:00
Jason 83a5597756 fix: resolve test failures and clippy warnings
- tests/App.test.tsx: remove outdated SettingsPage mock, use dynamic import
- database/tests.rs: remove unused field, use struct init syntax
- deeplink/tests.rs: use idiomatic assert!() instead of assert_eq!(true)
- support.rs: add #[allow(dead_code)] for test utilities
- usage_stats.rs: code formatting
2025-12-30 08:54:48 +08:00
Dex Miller bcfc22514c fix: use local timezone and robust DST handling in usage stats (#500)
- Change from UTC to local timezone for daily/hourly trends
- Use SQLite 'localtime' modifier for date grouping
- Replace single().unwrap() with earliest().unwrap_or_else()
  to handle DST transition edge cases gracefully
2025-12-29 23:46:26 +08:00
Jason 443e23c77e fix(windows): wrap npx/npm commands with cmd /c for MCP export
On Windows, npx, npm, yarn, pnpm, node, bun, and deno are actually
.cmd batch files that require cmd /c wrapper to execute properly.
This fixes the Claude Code /doctor warning:
"Windows requires 'cmd /c' wrapper to execute npx"

The transformation is applied when exporting MCP config to ~/.claude.json:
- Before: {"command": "npx", "args": ["-y", "foo"]}
- After:  {"command": "cmd", "args": ["/c", "npx", "-y", "foo"]}

Uses conditional compilation (#[cfg(windows)]) for zero overhead on
other platforms.

Closes #453
2025-12-29 23:20:32 +08:00
Jason f26a01137d fix(windows): prevent terminal windows from appearing during version check
On Windows, opening the Settings > About section would spawn three
terminal windows when checking CLI tool versions (claude, codex, gemini).

Root cause:
- scan_cli_version() directly executed .cmd files, but child processes
  (node.exe) spawned by these batch scripts didn't inherit CREATE_NO_WINDOW
- PATH separator used Unix-style ":" instead of Windows ";"

Fix:
- Wrap command execution with `cmd /C` to ensure all child processes
  run within the same hidden console session
- Use platform-specific PATH separators via conditional compilation
2025-12-29 22:28:59 +08:00
Jason 1be9c56ec5 fix(ui): resolve Dialog/Modal not opening on first click
Two bugs were caused by ref synchronization race condition in commit 7d495aa:
- EditProviderDialog: provider prop was null on first render
- UsageScriptModal: conditional render guard was false on first click

Root cause: useEffect updates ref asynchronously, but render happens before
effect runs. On first click, ref is still null causing components to fail.

Solution: Create useLastValidValue hook that updates ref synchronously during
render phase instead of in useEffect. This ensures ref is always in sync with
state, eliminating the race condition.

Changes:
- Add useLastValidValue hook for preserving last valid value during animations
- Replace manual ref + useEffect pattern with the new hook
- Remove non-null assertions (!) that were needed as workaround
2025-12-29 22:14:34 +08:00
Dex Miller 2651b65b10 fix(schema): add missing base columns migration for proxy_config (#492)
* fix(schema): add missing base columns migration for proxy_config

Add compatibility migration for older databases that may be missing
the basic proxy_config columns (proxy_enabled, listen_address,
listen_port, enable_logging) before adding newer timeout fields.

* fix: add proxy_config base column patches for v3.9.0-2 upgrade

Add base config column patches in create_tables_on_conn():
- proxy_enabled
- listen_address
- listen_port
- enable_logging

Ensures v3.9.0-2 users (user_version=2 but missing columns)
can properly upgrade with all required fields added.

* fix: migrate proxy_config singleton to per-app on startup for v2 databases

Add startup migration for legacy proxy_config tables that still have
singleton structure (no app_type column) even with user_version=2.

This fixes the issue where v3.9.0-2 databases with v2 schema but legacy
proxy_config structure would fail with "no such column: app_type" error.

- Call migrate_proxy_config_to_per_app in create_tables_on_conn
- Add regression test to verify the fix

* style: cargo fmt

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-12-29 17:25:25 +08:00
TinsFox 7fdaeacb5b feat: open settings via command comma (#436) 2025-12-28 22:52:51 +08:00
TinsFox 9716eb797f chore: 更新 vite 版本 && 使用 code-inspector-plugin 方便从前端定位到代码位置 (#430)
* chore: 更新 vite 版本 && 使用 code-inspector-plugin 方便从前端定位到代码位置

* fix: update tailwind config path and conditionally load code-inspector-plugin

- Update components.json to reference tailwind.config.cjs instead of deleted tailwind.config.js
- Load codeInspectorPlugin only in dev mode to avoid unnecessary code in production builds

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-12-28 21:34:41 +08:00
lif 91deaf094e fix: 移除已废弃的 sync_enabled_to_codex 调用 (#460)
* fix(mcp): 移除同步Codex Provider时的旧MCP同步调用

sync_enabled_to_codex使用旧的config.mcp.codex结构,
在v3.7.0统一结构中该字段为空,导致MCP配置被错误清除。
MCP同步应通过McpService进行。

Fixes #403

* test(mcp): update test to reflect new MCP sync architecture

Remove MCP-related assertions from sync_codex_provider_writes_auth_and_config
test since provider switching no longer triggers MCP sync in v3.7.0+.

MCP synchronization is now handled independently by McpService,
not as part of the provider switch flow.

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-12-27 21:12:07 +08:00
lif 3a548152a9 fix: MCP同步时优雅处理无效的Codex config.toml (#461)
* fix(mcp): 移除同步Codex Provider时的旧MCP同步调用

sync_enabled_to_codex使用旧的config.mcp.codex结构,
在v3.7.0统一结构中该字段为空,导致MCP配置被错误清除。
MCP同步应通过McpService进行。

Fixes #403

* fix(mcp): 优雅处理Codex配置文件解析失败的情况

当~/.codex/config.toml存在但内容无效时,MCP同步操作会失败,
导致后续provider切换等操作也失败。

修改sync_single_server_to_codex和remove_server_from_codex函数,
在配置文件解析失败时进行容错处理而不是返回错误。

Fixes #393
2025-12-27 18:05:58 +08:00
lif a8f7cda167 fix(macos): use .app bundle path for autostart to prevent terminal window (#462)
On macOS, the auto-launch library requires the .app bundle path (e.g.,
/Applications/CC Switch.app) rather than the binary path inside the bundle
(e.g., .app/Contents/MacOS/CC Switch). Using the binary path directly
causes AppleScript login items to open a terminal window.

This fix extracts the .app bundle path from current_exe() on macOS,
ensuring proper integration with macOS login items.

Closes #375
2025-12-27 16:45:17 +08:00
Calcium-Ion 8fe5c1041a feat: add Universal Provider feature (#348)
* feat: add Universal Provider feature

- Add Universal Provider data structures and type definitions
- Implement backend CRUD operations and sync functionality
- Add frontend UI components (UniversalProviderPanel, Card, FormModal)
- Add NewAPI icon and preset configuration
- Support cross-app (Claude/Codex/Gemini) configuration sync
- Add website URL field for providers
- Implement real-time refresh via event notifications
- Add i18n support (Chinese/English/Japanese)

* feat: integrate universal provider presets into add provider dialog

- Add universal provider presets (NewAPI, Custom Gateway) to preset selector
- Show universal presets with Layers icon badge in preset selector
- Open UniversalProviderFormModal when universal preset is clicked
- Pass initialPreset to auto-fill form when opened from add dialog
- Add i18n keys for addSuccess/addFailed messages
- Keep separate universal provider panel for management

* refactor: move universal provider management to add dialog

- Remove Layers button from main navigation header
- Add 'Manage' button next to universal provider presets
- Open UniversalProviderPanel from within add provider dialog
- Add i18n keys for 'manage' in all locales

* style: display universal provider presets on separate line

- Move universal provider section to a new row with border separator
- Add label '统一供应商:' to clarify the section

* style: unify universal provider label style with preset label

- Use FormLabel component for consistent styling
- Add background to 'Manage' button matching preset buttons
- Update icon size and button padding for consistency

* feat: add sync functionality and JSON preview for Universal Provider

* fix: add missing in_failover_queue field to Provider structs

After rebasing to main, the Provider struct gained a new
`in_failover_queue` field. This fix adds the missing field
to the three to_*_provider() methods in UniversalProvider.

* refactor: redesign AddProviderDialog with tab-based layout

- Add tabs to separate app-specific providers and universal providers
- Move "Add Universal Provider" button from panel header to footer
- Remove unused handleAdd callback and clean up imports
- Update emptyHint i18n text to reference the footer button

* fix: append /v1 suffix to Codex base_url in Universal Provider

Codex uses OpenAI-compatible API which requires the /v1 endpoint suffix.
The Universal Provider now automatically appends /v1 to base_url when
generating Codex provider config if not already present.

- Handle trailing slashes to avoid double slashes
- Apply fix to both backend (to_codex_provider) and frontend preview

* feat: auto-sync universal provider to apps on creation

Previously, users had to manually click sync after adding a universal
provider. Now it automatically syncs to Claude/Codex/Gemini on creation,
providing a smoother user experience.

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-12-26 22:47:24 +08:00
Jason a24753f074 fix(i18n): add missing translations for reasoning model and OpenRouter compat mode
Add missing i18n keys introduced in commit e6f18ba:
- providerForm.anthropicReasoningModel
- providerForm.reasoningModelPlaceholder
- providerForm.openrouterCompatMode
- providerForm.openrouterCompatModeHint
- proxy.failover.proxyRequired
2025-12-26 00:01:59 +08:00
Jason 079ee687a8 docs: make sponsor logos clickable and update sponsor list
- Make all sponsor logos clickable links in README files (EN/ZH/JA)
- Replace ShanDianShuo with DMXAPI sponsor
- Add DMXAPI logo images (dmx-en.jpg, dmx-zh.jpeg)
- Unify sponsor list across all language versions
2025-12-25 23:30:45 +08:00
Jason e08c67b88f chore: update GLM partner banner images 2025-12-25 22:53:32 +08:00
Jason bb2756d0fb fix(ui): improve dark mode text contrast for form labels
Replace hardcoded Tailwind color classes with design system CSS variables
to improve text visibility in dark mode:

- text-gray-900 dark:text-gray-100 → text-foreground
- text-gray-500/600 dark:text-gray-400 → text-muted-foreground
- bg-white dark:bg-gray-800 → bg-background
2025-12-25 17:37:11 +08:00
Weiyi Xu c87bb43aaa feat: add xiaomi mimo icon and claude provider configuration (#470) 2025-12-25 17:30:29 +08:00
Jason 8f58c08d0d fix(database): add backward compatibility check for proxy_config seed insert
Add has_column check before inserting seed data into proxy_config table.
This prevents SQL errors when upgrading from older databases where
proxy_config was a singleton table without the app_type column.

The migration function will handle the table structure upgrade and
insert the three rows after converting to the new schema.
2025-12-25 16:04:34 +08:00
YoVinchen e6f18ba801 Feat/usage model extraction (#455)
* feat(proxy): extract model name from API response for accurate usage tracking

- Add model field extraction in TokenUsage parsing for Claude, OpenAI, and Codex
- Prioritize response model over request model in usage logging
- Update model extractors to use parsed usage.model first
- Add tests for model extraction in stream and non-stream responses

* feat(proxy): implement streaming timeout control with validation

- Add first byte timeout (0 or 1-180s) for streaming requests
- Add idle timeout (0 or 60-600s) for streaming data gaps
- Add non-streaming timeout (0 or 60-1800s) for total request
- Implement timeout logic in response processor
- Add 1800s global timeout fallback when disabled
- Add database schema migration for timeout fields
- Add i18n translations for timeout settings

* feat(proxy): add model mapping module for provider-based model substitution

- Add model_mapper.rs with ModelMapping struct to extract model configs from Provider
- Support ANTHROPIC_MODEL, ANTHROPIC_REASONING_MODEL, and default models for haiku/sonnet/opus
- Implement thinking mode detection for reasoning model priority
- Include comprehensive unit tests for all mapping scenarios

* fix(proxy): bypass circuit breaker for single provider scenario

When failover is disabled (single provider), circuit breaker open state
would block all requests causing poor UX. Now bypasses circuit breaker
check in this scenario. Also integrates model mapping into request flow.

* feat(ui): add reasoning model field to Claude provider form

Add ANTHROPIC_REASONING_MODEL configuration field for Claude providers,
allowing users to specify a dedicated model for thinking/reasoning tasks.

* feat(proxy): add openrouter_compat_mode for optional format conversion

Add configurable OpenRouter compatibility mode that enables Anthropic to
OpenAI format conversion. When enabled, rewrites endpoint to /v1/chat/completions
and transforms request/response formats. Defaults to enabled for OpenRouter.

* feat(ui): add OpenRouter compatibility mode toggle

Add UI toggle for OpenRouter providers to enable/disable compatibility
mode which uses OpenAI Chat Completions format with SSE conversion.

* feat(stream-check): use provider-configured model for health checks

Extract model from provider's settings_config (ANTHROPIC_MODEL, GEMINI_MODEL,
or Codex config.toml) instead of always using default test models.

* refactor(ui): remove timeout settings from AutoFailoverConfigPanel

Remove streaming/non-streaming timeout configuration from failover panel
as these settings have been moved to a dedicated location.

* refactor(database): migrate proxy_config to per-app three-row structure

Replace singleton proxy_config table with app_type primary key structure,
allowing independent proxy settings for Claude, Codex, and Gemini.
Add GlobalProxyConfig queries and per-app config management in DAO layer.

* feat(proxy): add GlobalProxyConfig and AppProxyConfig types

Add new type definitions for the refactored proxy configuration:
- GlobalProxyConfig: shared settings (enabled, address, port, logging)
- AppProxyConfig: per-app settings (failover, timeouts, circuit breaker)

* refactor(proxy): update service layer for per-app config structure

Adapt proxy service, handler context, and provider router to use
the new per-app configuration model. Read enabled/timeout settings
from proxy_config table instead of settings table.

* feat(commands): add global and per-app proxy config commands

Add new Tauri commands for the refactored proxy configuration:
- get_global_proxy_config / update_global_proxy_config
- get_proxy_config_for_app / update_proxy_config_for_app
Update startup restore logic to read from proxy_config table.

* feat(api): add frontend API and Query hooks for proxy config

Add TypeScript wrappers and TanStack Query hooks for:
- Global proxy config (address, port, logging)
- Per-app proxy config (failover, timeouts, circuit breaker)
- Proxy takeover status management

* refactor(ui): redesign proxy panel with inline config controls

Replace ProxySettingsDialog with inline controls in ProxyPanel.
Add per-app takeover switches and global address/port settings.
Simplify AutoFailoverConfigPanel by removing timeout settings.

* feat(i18n): add proxy takeover translations and update types

Add i18n strings for proxy takeover status in zh/en/ja.
Update TypeScript types for GlobalProxyConfig and AppProxyConfig.

* refactor(proxy): load circuit breaker config per-app instead of globally

Extract app_type from router key and read circuit breaker settings
from the corresponding proxy_config row for each application.
2025-12-25 10:40:11 +08:00
Jason 7d495aa772 feat(ui): add exit animation to FullScreenPanel dialogs
- Wrap FullScreenPanel content with AnimatePresence for exit animation
- Add exit={{ opacity: 0 }} to enable fade-out on close
- Use useRef + useEffect to preserve provider data during exit animation
- Follow React best practices by updating refs in useEffect instead of render

Affected components:
- EditProviderDialog
- UsageScriptModal
- AddProviderDialog
- McpFormModal
- ProxySettingsDialog
2025-12-23 16:18:28 +08:00
Jason db8180aa31 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 16:18:28 +08:00
YoVinchen 1586451862 Feat/auto failover switch (#440)
* feat(failover): add auto-failover master switch with proxy integration

- Add persistent auto_failover_enabled setting in database
- Add get/set_auto_failover_enabled commands
- Provider router respects master switch state
- Proxy shutdown automatically disables failover
- Enabling failover auto-starts proxy server
- Optimistic updates for failover queue toggle

* feat(proxy): persist proxy takeover state across app restarts

- Add proxy_takeover_{app_type} settings for per-app state tracking
- Restore proxy takeover state automatically on app startup
- Preserve state on normal exit, clear on manual stop
- Add stop_with_restore_keep_state method for graceful shutdown

* fix(proxy): set takeover state for all apps in start_with_takeover

* fix(windows): hide console window when checking CLI versions

Add CREATE_NO_WINDOW flag to prevent command prompt from flashing
when detecting claude/codex/gemini CLI versions on Windows.

* refactor(failover): make auto-failover toggle per-app independent

- Change setting key from 'auto_failover_enabled' to 'auto_failover_enabled_{app_type}'
- Update provider_router to check per-app failover setting
- When failover disabled, use current provider only; when enabled, use queue order
- Add unit tests for failover enabled/disabled behavior

* feat(failover): auto-switch to higher priority provider on recovery

- After circuit breaker reset, check if recovered provider has higher priority
- Automatically switch back if queue_order is lower (higher priority)
- Stream health check now resets circuit breaker on success/degraded

* chore: remove unused start_proxy_with_takeover command

- Remove command registration from lib.rs
- Add comment clarifying failover queue is preserved on proxy stop

* feat(ui): integrate failover controls into provider cards

- Add failover toggle button to provider card actions
- Show priority badge (P1, P2, ...) for queued providers
- Highlight active provider with green border in failover mode
- Sync drag-drop order with failover queue
- Move per-app failover toggle to FailoverQueueManager
- Simplify SettingsPage failover section

* test(providers): add mocks for failover hooks in ProviderList tests

* refactor(failover): merge failover_queue table into providers

- Add in_failover_queue field to providers table
- Remove standalone failover_queue table and related indexes
- Simplify queue ordering by reusing sort_index field
- Remove reorder_failover_queue and set_failover_item_enabled commands
- Update frontend to use simplified FailoverQueueItem type

* fix(database): ensure in_failover_queue column exists for v2 databases

Add column check in create_tables to handle existing v2 databases
that were created before the failover queue refactor.

* fix(ui): differentiate active provider border color by proxy mode

- Use green border/gradient when proxy takeover is active
- Use blue border/gradient in normal mode (no proxy)
- Improves visual distinction between proxy and non-proxy states

* fix(database): clear provider health record when removing from failover queue

When a provider is removed from the failover queue, its health monitoring
is no longer needed. This change ensures the health record is also deleted
from the database to prevent stale data.

* fix(failover): improve cache cleanup for provider health and circuit breaker

- Use removeQueries instead of invalidateQueries when stopping proxy to
  completely clear health and circuit breaker caches
- Clear provider health and circuit breaker caches when removing from
  failover queue
- Refresh failover queue after drag-sort since queue order depends on
  sort_index
- Only show health badge when provider is in failover queue

* style: apply prettier formatting to App.tsx and ProviderList.tsx

* fix(proxy): handle missing health records and clear health on proxy stop

- Return default healthy state when provider health record not found
- Add clear_provider_health_for_app to clear health for specific app
- Clear app health records when stopping proxy takeover

* fix(proxy): track actual provider used in forwarding for accurate logging

Introduce ForwardResult and ForwardError structs to return the actual
provider that handled the request. This ensures usage statistics and
error logs reflect the correct provider after failover.
2025-12-23 12:37:36 +08:00
Jason bf570b6d2a 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-22 16:54:49 +08:00
Jason 26c3f05daf feat(ui): add fade transition for view and panel switching
Add smooth fade animations when navigating between views (Settings,
MCP, Skills, Prompts) and opening full-screen panels (Add/Edit Provider).
2025-12-22 16:54:49 +08:00
TinsFox d303706d51 feat: add provider search filter (#435)
* feat: add provider search filter

* feat: add provider search overlay
2025-12-22 15:46:11 +08:00
TinsFox 97495d1550 Remove macOS titlebar tint and align custom header (#438) 2025-12-22 15:43:28 +08:00
YoVinchen a1537807eb feat(failover): add auto-failover master switch with proxy integration (#427)
* feat(failover): add auto-failover master switch with proxy integration

- Add persistent auto_failover_enabled setting in database
- Add get/set_auto_failover_enabled commands
- Provider router respects master switch state
- Proxy shutdown automatically disables failover
- Enabling failover auto-starts proxy server
- Optimistic updates for failover queue toggle

* feat(proxy): persist proxy takeover state across app restarts

- Add proxy_takeover_{app_type} settings for per-app state tracking
- Restore proxy takeover state automatically on app startup
- Preserve state on normal exit, clear on manual stop
- Add stop_with_restore_keep_state method for graceful shutdown

* fix(proxy): set takeover state for all apps in start_with_takeover
2025-12-21 22:39:50 +08:00
TinsFox f047960a33 Use macOS tray template icon (#434) 2025-12-21 21:10:00 +08:00
TinsFox ace9b38cee style: remove shadow (#431) 2025-12-21 21:09:37 +08:00
Jason b67cdbb18c feat(ui): add fade transition for app switching
- Add AnimatePresence + motion.div wrapper for provider list
- Use key={activeApp} to trigger enter/exit animations on app switch
- Remove redundant animate-slide-up from ProviderList to prevent
  animation conflicts and visual jitter
2025-12-21 10:27:56 +08:00
Jason c4f1e90893 style(settings): unify tab transition animations
Add framer-motion fade-in and slide-up animations to General and
Advanced tabs, matching the existing Usage and About tab animations.
2025-12-21 09:29:14 +08:00
Jason ddbff070d5 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.

- Add set/clear_has_completed_onboarding functions in claude_mcp.rs
- Add Tauri commands and frontend API integration
- Add toggle in WindowSettings with i18n support (en/zh/ja)
- Fix hardcoded Chinese text in tests to use i18n keys
2025-12-20 23:55:10 +08:00
Jason ca7cb398c2 i18n: complete usage panel and settings internationalization
- Add missing i18n keys for usage statistics panel (trends, cost, perMillion, etc.)
- Add i18n keys for settings advanced section (configDir, proxy, modelTest, etc.)
- Add streamCheck i18n keys for health check configuration
- Remove hardcoded Chinese fallback values from t() calls
- Add common keys (all, search, reset, actions, deleting)
2025-12-20 22:29:39 +08:00
Jason 2fb3b5405a i18n: complete internationalization for v3.8+ features
- Add health status translations (operational, degraded, failed, circuitOpen)
- Add proxy panel translations (serviceAddress, stats, stopped state)
- Add usage filter translations (appType, statusCode, searchPlaceholder)
- Add providerIcon click hints (clickToChange, clickToSelect)
- Add config load error translations for main.tsx
- Complete Japanese proxy section (failoverQueue, autoFailover)
- Fix date/time locale in usage charts and tables
- Use t() function in all hardcoded UI strings
2025-12-20 21:38:37 +08:00
Jason ec649e7718 style(switch): improve dark mode appearance
- Track (unchecked): lighten in light mode (gray-300 → gray-200),
  darken in dark mode (gray-700 → gray-900) to blend with background
- Thumb: soften in dark mode (white → gray-400) to reduce glare
2025-12-20 19:18:29 +08:00
Jason 3da5525c79 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-20 18:57:36 +08:00
Jason 44ca688253 chore: bump version to 3.9.0-2 for second test release
- Update version in package.json, Cargo.toml, tauri.conf.json
- Fix clippy too_many_arguments warning in forwarder.rs
2025-12-20 18:10:45 +08:00
Jason 5fe5ed98be style(header): unify height and styling of header toolbar sections
- Use consistent h-8 fixed height for all inner elements
- Standardize border-radius to rounded-xl across all sections
- Remove background from ProxyToggle for cleaner appearance
- Simplify ProxyToggle structure with nested container
2025-12-20 16:24:25 +08:00
Jason b2a9e91d70 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-20 13:23:15 +08:00
Jason 4a1a997935 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-20 12:43:59 +08:00
Jason c4535c894a refactor(proxy): switch OpenRouter to passthrough mode for native Claude API
OpenRouter now supports Claude Code compatible endpoint (/v1/messages),
eliminating the need for Anthropic ↔ OpenAI format conversion.

- Disable format transformation for OpenRouter (keep old logic as fallback)
- Pass through original endpoint instead of redirecting to /v1/chat/completions
- Add anthropic-version header for ClaudeAuth and Bearer strategies
- Update tests to reflect new passthrough behavior
2025-12-20 12:13:39 +08:00
Jason 64e0cabaa7 fix(window): add minWidth/minHeight to Windows platform config
Tauri 2.0 platform config merging is shallow, not deep. The Windows
config only specified titleBarStyle, causing minWidth/minHeight to
be missing on Windows. This allowed users to resize the window below
900px, causing header elements to misalign.
2025-12-20 11:19:26 +08:00
Jason 8ecb41d25e fix(proxy): respect existing token field when syncing Claude config
- Add support for ANTHROPIC_API_KEY in Claude auth extraction
- Only update existing token fields during sync, avoid adding fields
  that weren't originally configured by the user
- Add tests for both scenarios
2025-12-20 11:04:07 +08:00
Jason 3e8f84481d fix(proxy): add fallback recovery for orphaned takeover state
- Detect takeover residue in Live configs even when proxy is not running
- Implement 3-tier fallback: backup → SSOT → cleanup placeholders
- Only delete backup after successful restore to prevent data loss
- Fix EditProviderDialog to check current app's takeover status only
2025-12-20 10:07:04 +08:00
Jason ba59483b33 refactor(proxy): remove global auto-start flag
- Remove global proxy auto-start flag from config and UI.
- Simplify per-app takeover start/stop and stop server when the last takeover is disabled.
- Restore live takeover detection used for crash recovery.
- Keep proxy_config.enabled column but always write 0 for compatibility.
- Tests: not run (not requested).
2025-12-20 08:48:59 +08:00
Jason b6ff721d67 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-19 20:48:15 +08:00
Jason 1706c9a26f 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-19 20:48:15 +08:00
YoVinchen 5bce6d6020 Fix/about section UI (#419)
* fix(ui): improve AboutSection styling and version detection

- Add framer-motion animations for smooth page transitions
- Unify button sizes and add icons for consistency
- Add gradient backgrounds and hover effects to cards
- Add notInstalled i18n translations (zh/en/ja)
- Fix version detection when stdout/stderr is empty

* fix(proxy): persist per-app takeover state across app restarts

- Fix proxy toggle color to reflect current app's takeover state only
- Restore proxy service on startup if Live config is still in takeover state
- Preserve per-app backup records instead of clearing all on restart
- Only recover Live config when proxy service fails to start
2025-12-19 20:40:11 +08:00
Jason 6bdbb4df23 chore: update Cargo.lock for version 3.9.0-1 2025-12-18 23:41:45 +08:00
Jason 256903ee70 chore: rename version to 3.9.0-1 for MSI compatibility
MSI installer requires numeric-only pre-release identifiers.
Changed from 3.9.0-beta.1 to 3.9.0-1.
2025-12-18 22:02:41 +08:00
Jason f42f73ebb0 fix: import RunEvent for all platforms
The #[cfg(target_os = "macos")] restriction was a historical artifact
from when RunEvent was only used for macOS-specific events (Reopen, Opened).
After c9ea13a added ExitRequested handling for all platforms, the import
should have been updated but was overlooked.
2025-12-18 21:32:17 +08:00
Jason ec6e113cf2 chore: bump version to 3.9.0-beta.1
- Update version in package.json, Cargo.toml, tauri.conf.json
- Add CHANGELOG entry for v3.9.0-beta.1 with:
  - Local Proxy Server feature
  - Auto Failover with circuit breaker
  - Skills multi-app support
  - Provider icon colors
  - 25+ bug fixes
- Add proxy feature guide documentation (Chinese)
2025-12-18 20:53:02 +08:00
Jason ae837ade02 fix(ui): restore fade transition for Skills button
The Skills button transition was accidentally removed in commit 1fb2d5e
when adding multi-app support. This restores the smooth fade-in/out
animation when switching between apps that support Skills (Claude/Codex)
and those that don't (Gemini).
2025-12-18 17:34:16 +08:00
Jason a8fd1f0dd2 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-18 16:08:10 +08:00
Jason fa33330b3b fix(mcp): improve upsert and import robustness
- Remove server from live config when app is disabled during upsert
- Merge enabled flags instead of overwriting when importing from multiple apps
- Normalize Gemini MCP type field (url-only → sse, command → stdio)
- Use atomic write for Codex config updates
- Add tests for disable-removal, multi-app merge, and Gemini SSE import
2025-12-18 15:14:37 +08:00
Jason 18207771ad feat(proxy): implement per-app takeover mode
Replace global live takeover with granular per-app control:
- Add start_proxy_server command (start without takeover)
- Add get_proxy_takeover_status to query each app's state
- Add set_proxy_takeover_for_app for individual app control
- Use live backup existence as SSOT for takeover state
- Refactor sync_live_to_provider to eliminate code duplication
- Update ProxyToggle to show status per active app
2025-12-18 11:28:10 +08:00
Jason 0cd7d0756c fix(proxy): takeover Codex base_url via model_provider
- Update Codex `model_providers.<model_provider>.base_url` to the proxy origin with `/v1`
- Add route fallbacks for `/responses` and `/chat/completions` (plus double-`/v1` safeguard)
- Add unit tests for the TOML base_url takeover logic
2025-12-17 22:53:32 +08:00
Jason bca0997afa fix(proxy): harden crash recovery with fallback detection
- Set takeover flag before writing proxy config to fix race condition
  where crash during takeover left Live configs corrupted but flag unset
- Add fallback detection by checking for placeholder tokens in Live
  configs when backups exist but flag is false (handles legacy/edge cases)
- Improve error handling with proper rollback at each stage of startup
- Clean up stale backups when Live configs are not in takeover state
  to avoid long-term storage of sensitive tokens
2025-12-17 11:03:49 +08:00
Jason 0ef8a4153f fix(proxy): sync UI when active provider differs from current setting
Previously, UI sync was triggered only when failover happened (retry count > 1).
This missed cases where the first provider in the failover queue succeeded but
was different from the user's selected provider in settings.

Now we capture the current provider ID at request start and compare it with
the actually used provider. This ensures UI/tray always reflects the real
provider handling requests.
2025-12-17 10:22:02 +08:00
Jason 1b73b26c0e fix(proxy): resolve circuit breaker race condition and error classification
This commit addresses two critical issues in the proxy failover logic:

1. Circuit Breaker HalfOpen Concurrency Bug:
   - Introduced `AllowResult` struct to track half-open permit usage
   - Added state guard in `transition_to_half_open()` to prevent duplicate resets
   - Replaced `fetch_sub` with CAS loop in `release_half_open_permit()` to prevent underflow
   - Separated `is_available()` (routing) from `allow_request()` (permit acquisition)

2. Error Classification Conflation:
   - Split retry logic into `should_retry_same_provider()` and `categorize_proxy_error()`
   - Same-provider retry: only for transient errors (timeout, 429, 5xx)
   - Cross-provider failover: now includes ConfigError, TransformError, AuthError
   - 4xx errors (401/403) no longer waste retries on the same provider
2025-12-17 09:36:17 +08:00
Jason 3d514c8250 fix(proxy): stabilize live takeover and provider editing
- Skip live writes when takeover is active and proxy is running
- Refresh live backups from provider edits during takeover
- Sync live tokens to DB without clobbering real keys with placeholders
- Avoid injecting extra placeholder keys into Claude live env
- Reapply takeover after proxy listen address/port changes
- In takeover mode, edit dialog uses DB config and keeps API key state in sync
2025-12-17 09:36:17 +08:00
TinsFox 18e973b920 fix: azure website link (#407) 2025-12-17 08:43:55 +08:00
YoVinchen ec20ff4d8c Feature/error request logging (#401)
* feat(proxy): add error mapper for HTTP status code mapping

- Add error_mapper.rs module to map ProxyError to HTTP status codes
- Implement map_proxy_error_to_status() for error classification
- Implement get_error_message() for user-friendly error messages
- Support all error types: upstream, timeout, connection, provider failures
- Include comprehensive unit tests for all mappings

* feat(proxy): enhance error logging with context support

- Add log_error_with_context() method for detailed error recording
- Support streaming flag, session_id, and provider_type fields
- Remove dead_code warning from log_error() method
- Enable comprehensive error request tracking in database

* feat(proxy): implement error capture and logging in all handlers

- Capture and log all failed requests in handle_messages (Claude)
- Capture and log all failed requests in handle_gemini (Gemini)
- Capture and log all failed requests in handle_responses (Codex)
- Capture and log all failed requests in handle_chat_completions (Codex)
- Record error status codes, messages, and latency for all failures
- Generate unique session_id for each request
- Support both streaming and non-streaming error scenarios

* style: fix clippy warnings and typescript errors

- Add allow(dead_code) for CircuitBreaker::get_state (reserved for future)
- Fix all uninlined format string warnings (27 instances)
- Use inline format syntax for better readability
- Fix unused import and parameter warnings in ProviderActions.tsx
- Achieve zero warnings in both Rust and TypeScript

* style: apply code formatting

- Remove trailing whitespace in misc.rs
- Add trailing comma in App.tsx
- Format multi-line className in ProviderCard.tsx

* feat(proxy): add settings button to proxy panel

Add configuration buttons in both running and stopped states to
provide easy access to proxy settings dialog.

* fix(speedtest): skip client build for invalid inputs

* chore(clippy): fix uninlined format args

* Merge branch 'main' into feature/error-request-logging
2025-12-16 21:02:08 +08:00
264 changed files with 21800 additions and 4423 deletions
+28 -3
View File
@@ -53,7 +53,10 @@ jobs:
wget \
file \
patchelf \
libssl-dev
libssl-dev \
rpm \
flatpak \
flatpak-builder
# GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev \
@@ -153,7 +156,7 @@ jobs:
- name: Build Tauri App (Linux)
if: runner.os == 'Linux'
run: pnpm tauri build
run: pnpm tauri build -- --bundles appimage,deb,rpm
- name: Prepare macOS Assets
if: runner.os == 'macOS'
@@ -271,6 +274,28 @@ jobs:
else
echo "No .deb found (optional)"
fi
# 额外上传 .rpm(用于 Fedora/RHEL/openSUSE 等,不参与 Updater
RPM=$(find src-tauri/target/release/bundle -name "*.rpm" | head -1 || true)
if [ -n "$RPM" ]; then
NEW_RPM="CC-Switch-${VERSION}-Linux.rpm"
cp "$RPM" "release-assets/$NEW_RPM"
echo "RPM package copied: $NEW_RPM"
else
echo "No .rpm found (optional)"
fi
# 额外上传 .flatpak(跨发行版;不参与 Updater
if [ -n "$DEB" ]; then
echo "Building Flatpak bundle from .deb..."
cp "$DEB" flatpak/cc-switch.deb
flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak install -y --user flathub org.gnome.Platform//46 org.gnome.Sdk//46
flatpak-builder --force-clean --user --disable-cache --repo flatpak-repo flatpak-build flatpak/com.ccswitch.desktop.yml
NEW_FLATPAK="CC-Switch-${VERSION}-Linux.flatpak"
flatpak build-bundle --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo flatpak-repo "release-assets/$NEW_FLATPAK" com.ccswitch.desktop
echo "Flatpak bundle created: $NEW_FLATPAK"
else
echo "Skip Flatpak build: no .deb found"
fi
- name: List prepared assets
shell: bash
@@ -299,7 +324,7 @@ jobs:
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`Homebrew
- **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
- **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`AppImage)或 `CC-Switch-${{ github.ref_name }}-Linux.deb`Debian/Ubuntu
- **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`AppImage)或 `CC-Switch-${{ github.ref_name }}-Linux.deb`Debian/Ubuntu或 `CC-Switch-${{ github.ref_name }}-Linux.rpm`Fedora/RHEL/openSUSE)或 `CC-Switch-${{ github.ref_name }}-Linux.flatpak`Flatpak
---
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
+5
View File
@@ -18,3 +18,8 @@ GEMINI.md
/.vscode
vitest-report.json
nul
# Flatpak build artifacts
flatpak/cc-switch.deb
flatpak-build/
flatpak-repo/
+1 -1
View File
@@ -1 +1 @@
v22.4.1
22.12.0
+152
View File
@@ -5,6 +5,158 @@ 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.9.0-3] - 2025-12-29
### Beta Release
Third beta release with important bug fixes for Windows compatibility, UI improvements, and new features.
### Added
- **Universal Provider** - Support for universal provider configurations (#348)
- **Provider Search Filter** - Quick filter to find providers by name (#435)
- **Keyboard Shortcut** - Open settings with Command+comma / Ctrl+comma (#436)
- **Xiaomi MiMo Icon** - Added MiMo icon and Claude provider configuration (#470)
- **Usage Model Extraction** - Extract model info from usage statistics (#455)
- **Skip First-Run Confirmation** - Option to skip Claude Code first-run confirmation dialog
- **Exit Animations** - Added exit animation to FullScreenPanel dialogs
- **Fade Transitions** - Smooth fade transitions for app/view/panel switching
### Fixed
#### Windows
- Wrap npx/npm commands with `cmd /c` for MCP export
- Prevent terminal windows from appearing during version check
#### macOS
- Use .app bundle path for autostart to prevent terminal window popup
#### UI
- Resolve Dialog/Modal not opening on first click (#492)
- Improve dark mode text contrast for form labels
- Reduce header spacing and fix layout shift on view switch
- Prevent header layout shift when switching views
#### Database & Schema
- Add missing base columns migration for proxy_config
- Add backward compatibility check for proxy_config seed insert
#### Other
- Use local timezone and robust DST handling in usage stats (#500)
- Remove deprecated `sync_enabled_to_codex` call
- Gracefully handle invalid Codex config.toml during MCP sync
- Add missing translations for reasoning model and OpenRouter compat mode
### Improved
- **macOS Tray** - Use macOS tray template icon
- **Header Alignment** - Remove macOS titlebar tint, align custom header
- **Shadow Removal** - Cleaner UI by removing shadow styles
- **Code Inspector** - Added code-inspector-plugin for development
- **i18n** - Complete internationalization for usage panel and settings
- **Sponsor Logos** - Made sponsor logos clickable
### Stats
- 35 commits since v3.9.0-2
- 5 files changed in test/lint fixes
---
## [3.9.0-1] - 2025-12-18
### Beta Release
This beta release introduces the **Local API Proxy** feature, along with Skills multi-app support, UI improvements, and numerous bug fixes.
### Major Features
#### Local Proxy Server
- **Local HTTP Proxy** - High-performance proxy server built on Axum framework
- **Multi-app Support** - Unified proxy for Claude Code, Codex, and Gemini CLI API requests
- **Per-app Takeover** - Independent control over which apps route through the proxy
- **Live Config Takeover** - Automatically backs up and redirects CLI configurations to local proxy
#### Auto Failover
- **Circuit Breaker** - Automatically detects provider failures and triggers protection
- **Smart Failover** - Automatically switches to backup provider when current one is unavailable
- **Health Tracking** - Real-time monitoring of provider availability
- **Independent Failover Queues** - Each app maintains its own failover queue
#### Monitoring
- **Request Logging** - Detailed logging of all proxy requests
- **Usage Statistics** - Token consumption, latency, success rate metrics
- **Real-time Status** - Frontend displays proxy status and statistics
#### Skills Multi-App Support
- **Multi-app Support** - Skills now support both Claude and Codex (#365)
- **Multi-app Migration** - Existing Skills auto-migrate to multi-app structure (#378)
- **Installation Path Fix** - Use directory basename for skill installation path (#358)
### Added
- **Provider Icon Colors** - Customize provider icon colors (#385)
- **Deeplink Usage Config** - Import usage query config via deeplink (#400)
- **Error Request Logging** - Detailed logging for proxy requests (#401)
- **Closable Toast** - Added close button to switch notification toast (#350)
- **Icon Color Component** - ProviderIcon component supports color prop (#384)
### Fixed
#### Proxy Related
- Takeover Codex base_url via model_provider
- Harden crash recovery with fallback detection
- Sync UI when active provider differs from current setting
- Resolve circuit breaker race condition and error classification
- Stabilize live takeover and provider editing
- Reset health badges when proxy stops
- Retry failover for all HTTP errors including 4xx
- Fix HalfOpen counter underflow and config field inconsistencies
- Resolve circuit breaker state persistence and HalfOpen deadlock
- Auto-recover live config after abnormal exit
- Update live backup when hot-switching provider in proxy mode
- Wait for server shutdown before exiting app
- Disable auto-start on app launch by resetting enabled flag on stop
- Sync live config tokens to database before takeover
- Resolve 404 error and auto-setup proxy targets
#### MCP Related
- Skip sync when target CLI app is not installed
- Improve upsert and import robustness
- Use browser-compatible platform detection for MCP presets
#### UI Related
- Restore fade transition for Skills button
- Add close button to all success toasts
- Prevent card jitter when health badge appears
- Update SettingsPage tab styles (#342)
#### Other
- Fix Azure website link (#407)
- Add fallback to provider config for usage credentials (#360)
- Fix Windows black screen on startup (use system titlebar)
- Add fallback for crypto.randomUUID() on older WebViews
- Use correct npm package for Codex CLI version check
- Security fixes for JavaScript executor and usage script (#151)
### Improved
- **Proxy Active Theme** - Apply emerald theme when proxy takeover is active
- **Card Animation** - Improved provider card hover animation
- **Remove Restart Prompt** - No longer prompts restart when switching providers
### Technical
- Implement per-app takeover mode
- Proxy module contains 20+ Rust files with complete layered architecture
- Add 5 new database tables for proxy functionality
- Modularize handlers.rs to reduce code duplication
- Remove is_proxy_target in favor of failover_queue
### Stats
- 55 commits since v3.8.2
- 164 files changed
- +22,164 / -570 lines
---
## [3.8.0] - 2025-11-28
### Major Updates
+21 -10
View File
@@ -2,7 +2,7 @@
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
[![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.8.3-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)
@@ -15,7 +15,7 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANG
## ❤️Sponsor
![Zhipu GLM](assets/partners/banners/glm-en.jpg)
[![Zhipu GLM](assets/partners/banners/glm-en.jpg)](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)!
@@ -23,19 +23,18 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
<table>
<tr>
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during recharge to get 10% off.</td>
</tr>
<tr>
<td width="180"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
<td>Thanks to ShanDianShuo for sponsoring this project! ShanDianShuo is a local-first AI voice input: Millisecond latency, data stays on device, 4x faster than typing, AI-powered correction, Privacy-first, completely free. Doubles your coding efficiency with Claude Code! <a href="https://www.shandianshuo.cn">Free download</a> for Mac/Win</td>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
</tr>
<tr>
<td width="180"><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>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
<td>Thanks to DMXAPI for sponsoring this project! DMXAPI provides global large model API services to 200+ enterprise users. One API key for all global models. Features include: instant invoicing, unlimited concurrency, starting from $0.15, 24/7 technical support. GPT/Claude/Gemini all at 32% off, domestic models 20-50% off, Claude Code exclusive models at 66% off! <a href="https://www.dmxapi.cn/register?aff=bUHu">Register here</a></td>
</tr>
</table>
@@ -48,7 +47,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
## Features
### Current Version: v3.8.2 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
### Current Version: v3.8.3 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
**v3.8.0 Major Update (2025-11-28)**
@@ -187,7 +186,19 @@ paru -S cc-switch-bin
### Linux Users
Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{version}-Linux.AppImage` from the [Releases](../../releases) page.
Download the latest Linux build from the [Releases](../../releases) page:
- `CC-Switch-v{version}-Linux.deb` (Debian/Ubuntu)
- `CC-Switch-v{version}-Linux.rpm` (Fedora/RHEL/openSUSE)
- `CC-Switch-v{version}-Linux.AppImage` (Universal)
- `CC-Switch-v{version}-Linux.flatpak` (Flatpak)
Flatpak install & run:
```bash
flatpak install --user ./CC-Switch-v{version}-Linux.flatpak
flatpak run com.ccswitch.desktop
```
## Quick Start
+21 -11
View File
@@ -2,7 +2,7 @@
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
[![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.8.3-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)
@@ -15,7 +15,7 @@
## ❤️スポンサー
![Zhipu GLM](assets/partners/banners/glm-en.jpg)
[![Zhipu GLM](assets/partners/banners/glm-en.jpg)](https://z.ai/subscribe?ic=8JVLJQFSKB)
本プロジェクトは Z.ai の GLM CODING PLAN による支援を受けています。GLM CODING PLAN は AI コーディング向けのサブスクリプションで、月額わずか 3 ドルから。Claude Code、Cline、Roo Code など 10 以上の人気 AI コーディングツールでフラッグシップモデル GLM-4.6 を利用でき、速く安定した開発体験を提供します。[このリンク](https://z.ai/subscribe?ic=8JVLJQFSKB) から申し込むと 10% オフになります!
@@ -23,20 +23,18 @@
<table>
<tr>
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
<td>PackyCode のご支援に感謝します!PackyCode は 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>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td>
</tr>
<tr>
<td width="180"><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>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
<td>DMXAPI のご支援に感謝します!DMXAPI は 200 社以上の企業ユーザーにグローバル大規模モデル API サービスを提供しています。1 つの API キーで全世界のモデルにアクセス可能。即時請求書発行、同時接続数無制限、最低 $0.15 から、24 時間年中無休のテクニカルサポート。GPT/Claude/Gemini が全て 32% オフ、国内モデルは 20〜50% オフ、Claude Code 専用モデルは 66% オフ実施中!<a href="https://www.dmxapi.cn/register?aff=bUHu">登録はこちら</a></td>
</tr>
</table>
@@ -49,7 +47,7 @@
## 特長
### 現在のバージョン:v3.8.2 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md)
### 現在のバージョン:v3.8.3 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md)
**v3.8.0 メジャーアップデート (2025-11-28)**
@@ -188,7 +186,19 @@ paru -S cc-switch-bin
### Linux ユーザー
[Releases](../../releases) から最新版の `CC-Switch-v{version}-Linux.deb` または `CC-Switch-v{version}-Linux.AppImage` をダウンロード
[Releases](../../releases) から最新版の Linux ビルドをダウンロード
- `CC-Switch-v{version}-Linux.deb`Debian/Ubuntu
- `CC-Switch-v{version}-Linux.rpm`Fedora/RHEL/openSUSE
- `CC-Switch-v{version}-Linux.AppImage`(汎用)
- `CC-Switch-v{version}-Linux.flatpak`Flatpak
Flatpak のインストールと起動:
```bash
flatpak install --user ./CC-Switch-v{version}-Linux.flatpak
flatpak run com.ccswitch.desktop
```
## クイックスタート
+23 -11
View File
@@ -2,7 +2,7 @@
# Claude Code / Codex / Gemini CLI 全方位辅助工具
[![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.8.3-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)
@@ -15,7 +15,7 @@
## ❤️赞助商
![智谱 GLM](assets/partners/banners/glm-zh.jpg)
[![智谱 GLM](assets/partners/banners/glm-zh.jpg)](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)购买可以享受九折优惠。
@@ -23,20 +23,20 @@
<table>
<tr>
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,可以享受9折优惠。</td>
</tr>
<tr>
<td width="180"><img src="assets/partners/logos/sds-zh.png" alt="ShanDianShuo" width="150"></td>
<td>感谢闪电说赞助了本项目!闪电说是本地优先的 AI 语音输入法:毫秒级响应,数据不离设备;打字速度提升 4 倍,AI 智能纠错;绝对隐私安全,完全免费,配合 Claude Code 写代码效率翻倍!支持 Mac/Win 双平台,<a href="https://www.shandianshuo.cn">免费下载</a></td>
</tr>
<tr>
<td width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
</tr>
<tr>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-zh.jpeg" alt="DMXAPI" width="150"></a></td>
<td>感谢 DMXAPI(大模型API)赞助了本项目! DMXAPI,一个Key用全球大模型。
为200多家企业用户提供全球大模型API服务。· 充值即开票 ·当天开票 ·并发不限制 ·1元起充 · 7x24 在线技术辅导,GPT/Claude/Gemini全部6.8折,国内模型5~8折,Claude Code 专属模型3.4折进行中!<a href="https://www.dmxapi.cn/register?aff=bUHu">点击这里注册</a></td>
</tr>
</table>
## 界面预览
@@ -47,7 +47,7 @@
## 功能特性
### 当前版本:v3.8.2 | [完整更新日志](CHANGELOG.md)
### 当前版本:v3.8.3 | [完整更新日志](CHANGELOG.md)
**v3.8.0 重大更新(2025-11-28**
@@ -186,7 +186,19 @@ paru -S cc-switch-bin
### Linux 用户
从 [Releases](../../releases) 页面下载最新版本的 `CC-Switch-v{版本号}-Linux.deb` 包或者 `CC-Switch-v{版本号}-Linux.AppImage` 安装包
从 [Releases](../../releases) 页面下载最新版本的 Linux 安装包
- `CC-Switch-v{版本号}-Linux.deb`Debian/Ubuntu
- `CC-Switch-v{版本号}-Linux.rpm`Fedora/RHEL/openSUSE
- `CC-Switch-v{版本号}-Linux.AppImage`(通用)
- `CC-Switch-v{版本号}-Linux.flatpak`Flatpak
Flatpak 安装与运行:
```bash
flatpak install --user ./CC-Switch-v{版本号}-Linux.flatpak
flatpak run com.ccswitch.desktop
```
## 快速开始
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: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

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

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

+63
View File
@@ -0,0 +1,63 @@
# Flatpak Build Guide
This directory contains the Flatpak manifest (`com.ccswitch.desktop`) for CC Switch, used to convert the generated `.deb` artifact into an installable `.flatpak` package via CI or local builds.
## Dependencies
- `flatpak`
- `flatpak-builder`
- Flathub remote (for installing `org.gnome.Platform//46` runtime)
For Ubuntu/Debian:
```bash
sudo apt install flatpak flatpak-builder
flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak install -y --user flathub org.gnome.Platform//46 org.gnome.Sdk//46
```
## Local Build (Generate .flatpak from .deb)
1) Build the deb on Linux first:
```bash
pnpm tauri build -- --bundles deb
```
2) Copy the generated deb to this directory:
```bash
cp "$(find src-tauri/target/release/bundle -name '*.deb' | head -n 1)" flatpak/cc-switch.deb
```
3) Build the local Flatpak repository and export the `.flatpak`:
```bash
flatpak-builder --force-clean --user --disable-cache --repo flatpak-repo flatpak-build flatpak/com.ccswitch.desktop.yml
flatpak build-bundle --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo flatpak-repo CC-Switch-Linux.flatpak com.ccswitch.desktop
```
4) Install and run:
```bash
flatpak install --user ./CC-Switch-Linux.flatpak
flatpak run com.ccswitch.desktop
```
## Permissions Note
The current manifest uses `--filesystem=home` by default for "download and run" convenience, allowing the app to directly read/write CLI configuration files and app data on the host (and supporting the "directory override" feature).
If you prefer minimal permissions (e.g., for Flathub submission or security concerns), you can replace `--filesystem=home` in `flatpak/com.ccswitch.desktop.yml` with more precise grants:
```yaml
- --filesystem=~/.cc-switch:create
- --filesystem=~/.claude:create
- --filesystem=~/.claude.json
- --filesystem=~/.codex:create
- --filesystem=~/.gemini:create
```
Note: Flatpak's `:create` modifier only works with directories, not files. Therefore, `~/.claude.json` cannot use `:create`. If this file doesn't exist on the user's machine, the app may not be able to create it with restricted permissions. Users should either run Claude Code once to generate it, or manually create an empty JSON file (content: `{}`).
If you plan to publish on Flathub or want stricter permission control, adjust the `finish-args` in `flatpak/com.ccswitch.desktop.yml` accordingly.
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=CC Switch
Comment=All-in-One Assistant for Claude Code, Codex & Gemini CLI
Exec=cc-switch
Icon=com.ccswitch.desktop
Terminal=false
Categories=Utility;Development;
StartupNotify=true
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.ccswitch.desktop</id>
<name>CC Switch</name>
<summary>All-in-One Assistant for Claude Code, Codex &amp; Gemini CLI</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<description>
<p>CC Switch is a cross-platform desktop app for managing and switching provider configurations for Claude Code, Codex, and Gemini CLI.</p>
<ul>
<li>Manage multiple provider configurations and endpoints</li>
<li>One-click switch and sync to client live configurations</li>
<li>MCP servers and Prompt/Skills management</li>
</ul>
</description>
<launchable type="desktop-id">com.ccswitch.desktop.desktop</launchable>
<provides>
<binary>cc-switch</binary>
</provides>
<url type="homepage">https://github.com/farion1231/cc-switch</url>
<url type="bugtracker">https://github.com/farion1231/cc-switch/issues</url>
</component>
+45
View File
@@ -0,0 +1,45 @@
id: com.ccswitch.desktop
runtime: org.gnome.Platform
runtime-version: '46'
sdk: org.gnome.Sdk
command: cc-switch
finish-args:
- --share=ipc
- --share=network
- --socket=wayland
- --socket=fallback-x11
- --device=dri
# Tray icon permissions (required by Tauri tray-icon)
- --talk-name=org.kde.StatusNotifierWatcher
- --filesystem=xdg-run/tray-icon:create
# GitHub Releases scenario: Users download and install manually.
# For "download and run" convenience (needs read/write access to ~/.cc-switch, ~/.claude, ~/.claude.json, ~/.codex, ~/.gemini,
# and supports custom directory overrides), we grant full Home access by default.
# If you plan to publish on Flathub or prefer minimal permissions, replace this with more precise directory grants (see flatpak/README.md).
- --filesystem=home
modules:
- name: cc-switch
buildsystem: simple
sources:
# Placed in flatpak/ directory by CI or local build script
- type: file
path: cc-switch.deb
- type: file
path: com.ccswitch.desktop.desktop
- type: file
path: com.ccswitch.desktop.metainfo.xml
- type: file
path: ../src-tauri/icons/128x128.png
build-commands:
- ar -x *.deb
- tar -xf data.tar.*
- cp -a usr/* /app/
# Use our own desktop/metainfo/icon to align with Flatpak app id
- rm -f /app/share/applications/*.desktop
- install -Dm644 com.ccswitch.desktop.desktop /app/share/applications/com.ccswitch.desktop.desktop
- install -Dm644 com.ccswitch.desktop.metainfo.xml /app/share/metainfo/com.ccswitch.desktop.metainfo.xml
- install -Dm644 128x128.png /app/share/icons/hicolor/128x128/apps/com.ccswitch.desktop.png
+5 -3
View File
@@ -1,7 +1,8 @@
{
"name": "cc-switch",
"version": "3.8.2",
"version": "3.9.0-3",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": {
"dev": "pnpm tauri dev",
"build": "pnpm tauri build",
@@ -27,6 +28,7 @@
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.20",
"code-inspector-plugin": "^1.3.3",
"cross-fetch": "^4.1.0",
"jsdom": "^25.0.0",
"msw": "^2.11.6",
@@ -34,7 +36,7 @@
"prettier": "^3.6.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite": "^7.3.0",
"vitest": "^2.0.5"
},
"dependencies": {
@@ -85,4 +87,4 @@
"zod": "^4.1.12"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}
}
+517 -25
View File
File diff suppressed because it is too large Load Diff
+57 -5
View File
@@ -586,6 +586,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"
@@ -695,7 +701,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.8.2"
version = "3.9.0-3"
dependencies = [
"anyhow",
"async-stream",
@@ -2214,7 +2220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
dependencies = [
"byteorder",
"png",
"png 0.17.16",
]
[[package]]
@@ -2330,6 +2336,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"
@@ -2759,6 +2778,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"
@@ -2774,7 +2803,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",
@@ -3563,6 +3592,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"
@@ -3694,6 +3736,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"
@@ -4987,6 +5038,7 @@ dependencies = [
"heck 0.5.0",
"http",
"http-range",
"image",
"jni",
"libc",
"log",
@@ -5055,7 +5107,7 @@ dependencies = [
"ico",
"json-patch",
"plist",
"png",
"png 0.17.16",
"proc-macro2",
"quote",
"semver",
@@ -5810,7 +5862,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.8.2"
version = "3.9.0-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

+104
View File
@@ -55,6 +55,110 @@ impl McpApps {
}
}
/// Skill 应用启用状态(标记 Skill 应用到哪些客户端)
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct SkillApps {
#[serde(default)]
pub claude: bool,
#[serde(default)]
pub codex: bool,
#[serde(default)]
pub gemini: bool,
}
impl SkillApps {
/// 检查指定应用是否启用
pub fn is_enabled_for(&self, app: &AppType) -> bool {
match app {
AppType::Claude => self.claude,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
}
}
/// 设置指定应用的启用状态
pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) {
match app {
AppType::Claude => self.claude = enabled,
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
}
}
/// 获取所有启用的应用列表
pub fn enabled_apps(&self) -> Vec<AppType> {
let mut apps = Vec::new();
if self.claude {
apps.push(AppType::Claude);
}
if self.codex {
apps.push(AppType::Codex);
}
if self.gemini {
apps.push(AppType::Gemini);
}
apps
}
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini
}
/// 仅启用指定应用(其他应用设为禁用)
pub fn only(app: &AppType) -> Self {
let mut apps = Self::default();
apps.set_enabled_for(app, true);
apps
}
}
/// 已安装的 Skillv3.10.0+ 统一结构)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstalledSkill {
/// 唯一标识符(格式:"owner/repo:directory" 或 "local:directory"
pub id: String,
/// 显示名称
pub name: String,
/// 描述
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// 安装目录名(在 SSOT 目录中的子目录名)
pub directory: String,
/// 仓库所有者(GitHub 用户/组织)
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_owner: Option<String>,
/// 仓库名称
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_name: Option<String>,
/// 仓库分支
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_branch: Option<String>,
/// README URL
#[serde(skip_serializing_if = "Option::is_none")]
pub readme_url: Option<String>,
/// 应用启用状态
pub apps: SkillApps,
/// 安装时间(Unix 时间戳)
pub installed_at: i64,
}
/// 未管理的 Skill(在应用目录中发现但未被 CC Switch 管理)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UnmanagedSkill {
/// 目录名
pub directory: String,
/// 显示名称(从 SKILL.md 解析)
pub name: String,
/// 描述
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// 在哪些应用目录中发现(如 ["claude", "codex"]
pub found_in: Vec<String>,
}
/// MCP 服务器定义(v3.7.0 统一结构)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServer {
+71 -4
View File
@@ -1,16 +1,36 @@
use crate::error::AppError;
use auto_launch::{AutoLaunch, AutoLaunchBuilder};
/// 获取 macOS 上的 .app bundle 路径
/// 将 `/path/to/CC Switch.app/Contents/MacOS/CC Switch` 转换为 `/path/to/CC Switch.app`
#[cfg(target_os = "macos")]
fn get_macos_app_bundle_path(exe_path: &std::path::Path) -> Option<std::path::PathBuf> {
let path_str = exe_path.to_string_lossy();
// 查找 .app/Contents/MacOS/ 模式
if let Some(app_pos) = path_str.find(".app/Contents/MacOS/") {
let app_bundle_end = app_pos + 4; // ".app" 的结束位置
Some(std::path::PathBuf::from(&path_str[..app_bundle_end]))
} else {
None
}
}
/// 初始化 AutoLaunch 实例
fn get_auto_launch() -> Result<AutoLaunch, AppError> {
let app_name = "CC Switch";
let app_path =
let exe_path =
std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?;
// macOS 需要使用 .app bundle 路径,否则 AppleScript login item 会打开终端
#[cfg(target_os = "macos")]
let app_path = get_macos_app_bundle_path(&exe_path).unwrap_or(exe_path);
#[cfg(not(target_os = "macos"))]
let app_path = exe_path;
// 使用 AutoLaunchBuilder 消除平台差异
// Windows/Linux: new() 接受 3 参数
// macOS: new() 接受 4 参数(含 hidden 参数)
// Builder 模式自动处理这些差异
// macOS: 使用 AppleScript 方式(默认),需要 .app bundle 路径
// Windows/Linux: 使用注册表/XDG autostart
let auto_launch = AutoLaunchBuilder::new()
.set_app_name(app_name)
.set_app_path(&app_path.to_string_lossy())
@@ -47,3 +67,50 @@ pub fn is_auto_launch_enabled() -> Result<bool, AppError> {
.is_enabled()
.map_err(|e| AppError::Message(format!("检查开机自启状态失败: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "macos")]
#[test]
fn test_get_macos_app_bundle_path_valid() {
let exe_path = std::path::Path::new("/Applications/CC Switch.app/Contents/MacOS/CC Switch");
let result = get_macos_app_bundle_path(exe_path);
assert_eq!(
result,
Some(std::path::PathBuf::from("/Applications/CC Switch.app"))
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_get_macos_app_bundle_path_with_spaces() {
let exe_path =
std::path::Path::new("/Users/test/My Apps/CC Switch.app/Contents/MacOS/CC Switch");
let result = get_macos_app_bundle_path(exe_path);
assert_eq!(
result,
Some(std::path::PathBuf::from(
"/Users/test/My Apps/CC Switch.app"
))
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_get_macos_app_bundle_path_not_in_bundle() {
let exe_path = std::path::Path::new("/usr/local/bin/cc-switch");
let result = get_macos_app_bundle_path(exe_path);
assert_eq!(result, None);
}
#[cfg(target_os = "macos")]
#[test]
fn test_get_macos_app_bundle_path_dev_build() {
// 开发环境下的路径通常不在 .app bundle 内
let exe_path = std::path::Path::new("/Users/dev/project/target/debug/cc-switch");
let result = get_macos_app_bundle_path(exe_path);
assert_eq!(result, None);
}
}
+243
View File
@@ -7,6 +7,64 @@ use std::path::{Path, PathBuf};
use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path};
use crate::error::AppError;
/// 需要在 Windows 上用 cmd /c 包装的命令
/// 这些命令在 Windows 上实际是 .cmd 批处理文件,需要通过 cmd /c 来执行
#[cfg(windows)]
const WINDOWS_WRAP_COMMANDS: &[&str] = &["npx", "npm", "yarn", "pnpm", "node", "bun", "deno"];
/// Windows 平台:将 `npx args...` 转换为 `cmd /c npx args...`
/// 解决 Claude Code /doctor 报告的 "Windows requires 'cmd /c' wrapper to execute npx" 警告
#[cfg(windows)]
fn wrap_command_for_windows(obj: &mut Map<String, Value>) {
// 只处理 stdio 类型(默认或显式)
let server_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
if server_type != "stdio" {
return;
}
let Some(cmd) = obj.get("command").and_then(|v| v.as_str()) else {
return;
};
// 已经是 cmd 的不重复包装
if cmd.eq_ignore_ascii_case("cmd") || cmd.eq_ignore_ascii_case("cmd.exe") {
return;
}
// 提取命令名(去掉 .cmd 后缀和路径)
let cmd_name = Path::new(cmd)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(cmd);
let needs_wrap = WINDOWS_WRAP_COMMANDS
.iter()
.any(|&c| cmd_name.eq_ignore_ascii_case(c));
if !needs_wrap {
return;
}
// 构建新的 args: ["/c", "原命令", ...原args]
let original_args = obj
.get("args")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut new_args = vec![Value::String("/c".into()), Value::String(cmd.into())];
new_args.extend(original_args);
obj.insert("command".into(), Value::String("cmd".into()));
obj.insert("args".into(), Value::Array(new_args));
}
/// 非 Windows 平台无需处理
#[cfg(not(windows))]
fn wrap_command_for_windows(_obj: &mut Map<String, Value>) {
// 非 Windows 平台不做任何处理
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpStatus {
@@ -105,6 +163,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()));
@@ -290,6 +397,9 @@ pub fn set_mcp_servers_map(
obj.remove("homepage");
obj.remove("docs");
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式
wrap_command_for_windows(&mut obj);
out.insert(id.clone(), Value::Object(obj));
}
@@ -303,3 +413,136 @@ pub fn set_mcp_servers_map(
write_json_value(&path, &root)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// 测试 Windows 命令包装功能
/// 由于使用条件编译,在非 Windows 平台上测试的是空函数
#[test]
fn test_wrap_command_for_windows_npx() {
let mut obj = json!({"command": "npx", "args": ["-y", "@upstash/context7-mcp"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
#[cfg(windows)]
{
assert_eq!(obj["command"], "cmd");
assert_eq!(
obj["args"],
json!(["/c", "npx", "-y", "@upstash/context7-mcp"])
);
}
#[cfg(not(windows))]
{
// 非 Windows 平台不做任何处理
assert_eq!(obj["command"], "npx");
}
}
#[test]
fn test_wrap_command_for_windows_npm() {
let mut obj = json!({"command": "npm", "args": ["run", "start"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
#[cfg(windows)]
{
assert_eq!(obj["command"], "cmd");
assert_eq!(obj["args"], json!(["/c", "npm", "run", "start"]));
}
}
#[test]
fn test_wrap_command_for_windows_already_cmd() {
// 已经是 cmd 的不应该重复包装
let mut obj = json!({"command": "cmd", "args": ["/c", "npx", "-y", "foo"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
assert_eq!(obj["command"], "cmd");
// args 应该保持不变,不会变成 ["/c", "cmd", "/c", "npx", ...]
assert_eq!(obj["args"], json!(["/c", "npx", "-y", "foo"]));
}
#[test]
fn test_wrap_command_for_windows_http_type_skipped() {
// http 类型不应该被处理
let mut obj = json!({"type": "http", "url": "https://example.com/mcp"})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
assert!(!obj.contains_key("command"));
assert_eq!(obj["url"], "https://example.com/mcp");
}
#[test]
fn test_wrap_command_for_windows_other_command_skipped() {
// 非目标命令(如 python)不应该被包装
let mut obj = json!({"command": "python", "args": ["server.py"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
// python 不在 WINDOWS_WRAP_COMMANDS 列表中,不应该被包装
assert_eq!(obj["command"], "python");
assert_eq!(obj["args"], json!(["server.py"]));
}
#[test]
fn test_wrap_command_for_windows_no_args() {
// 没有 args 的情况
let mut obj = json!({"command": "npx"}).as_object().unwrap().clone();
wrap_command_for_windows(&mut obj);
#[cfg(windows)]
{
assert_eq!(obj["command"], "cmd");
assert_eq!(obj["args"], json!(["/c", "npx"]));
}
}
#[test]
fn test_wrap_command_for_windows_with_cmd_suffix() {
// 处理 npx.cmd 格式
let mut obj = json!({"command": "npx.cmd", "args": ["-y", "foo"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
#[cfg(windows)]
{
assert_eq!(obj["command"], "cmd");
assert_eq!(obj["args"], json!(["/c", "npx.cmd", "-y", "foo"]));
}
}
#[test]
fn test_wrap_command_for_windows_case_insensitive() {
// 大小写不敏感
let mut obj = json!({"command": "NPX", "args": ["-y", "foo"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
#[cfg(windows)]
{
assert_eq!(obj["command"], "cmd");
assert_eq!(obj["args"], json!(["/c", "NPX", "-y", "foo"]));
}
}
}
+43 -3
View File
@@ -7,6 +7,7 @@ use tauri_plugin_opener::OpenerExt;
use crate::app_config::AppType;
use crate::codex_config;
use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::settings;
/// 获取 Claude Code 配置状态
#[tauri::command]
@@ -16,6 +17,18 @@ pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
use std::str::FromStr;
fn invalid_json_format_error(error: serde_json::Error) -> String {
let lang = settings::get_settings()
.language
.unwrap_or_else(|| "zh".to_string());
match lang.as_str() {
"en" => format!("Invalid JSON format: {error}"),
"ja" => format!("JSON形式が無効です: {error}"),
_ => format!("无效的 JSON 格式: {error}"),
}
}
#[tauri::command]
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
match AppType::from_str(&app).map_err(|e| e.to_string())? {
@@ -155,8 +168,7 @@ pub async fn set_claude_common_config_snippet(
) -> Result<(), String> {
// 验证是否为有效的 JSON(如果不为空)
if !snippet.trim().is_empty() {
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
}
let value = if snippet.trim().is_empty() {
@@ -197,7 +209,7 @@ pub async fn set_common_config_snippet(
"claude" | "gemini" => {
// 验证 JSON 格式
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
.map_err(invalid_json_format_error)?;
}
"codex" => {
// TOML 格式暂不验证(或可使用 toml crate)
@@ -219,3 +231,31 @@ pub async fn set_common_config_snippet(
.map_err(|e| e.to_string())?;
Ok(())
}
/// 提取通用配置片段
///
/// 优先从 `settingsConfig`(编辑器当前内容)提取;若未提供,则从当前激活供应商提取。
///
/// 提取时会自动排除差异化字段(API Key、模型配置、端点等),返回可复用的通用配置片段。
#[tauri::command]
pub async fn extract_common_config_snippet(
appType: String,
settingsConfig: Option<String>,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<String, String> {
let app = AppType::from_str(&appType).map_err(|e| e.to_string())?;
if let Some(settings_config) = settingsConfig.filter(|s| !s.trim().is_empty()) {
let settings: serde_json::Value =
serde_json::from_str(&settings_config).map_err(invalid_json_format_error)?;
return crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(
app,
&settings,
)
.map_err(|e| e.to_string());
}
crate::services::provider::ProviderService::extract_common_config_snippet(&state, app)
.map_err(|e| e.to_string())
}
+28 -10
View File
@@ -1,6 +1,6 @@
//! 故障转移队列命令
//!
//! 管理代理模式下的故障转移队列
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
use crate::database::FailoverQueueItem;
use crate::provider::Provider;
@@ -56,29 +56,47 @@ pub async fn remove_from_failover_queue(
.map_err(|e| e.to_string())
}
/// 重新排序故障转移队列
/// 获取指定应用的自动故障转移开关状态(从 proxy_config 表读取)
#[tauri::command]
pub async fn reorder_failover_queue(
pub async fn get_auto_failover_enabled(
state: tauri::State<'_, AppState>,
app_type: String,
provider_ids: Vec<String>,
) -> Result<(), String> {
) -> Result<bool, String> {
state
.db
.reorder_failover_queue(&app_type, &provider_ids)
.get_proxy_config_for_app(&app_type)
.await
.map(|config| config.auto_failover_enabled)
.map_err(|e| e.to_string())
}
/// 设置故障转移队列项的启用状态
/// 设置指定应用的自动故障转移开关状态(写入 proxy_config 表)
///
/// 注意:关闭故障转移时不会清除队列,队列内容会保留供下次开启时使用
#[tauri::command]
pub async fn set_failover_item_enabled(
pub async fn set_auto_failover_enabled(
state: tauri::State<'_, AppState>,
app_type: String,
provider_id: String,
enabled: bool,
) -> Result<(), String> {
log::info!(
"[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}"
);
// 读取当前配置
let mut config = state
.db
.get_proxy_config_for_app(&app_type)
.await
.map_err(|e| e.to_string())?;
// 更新 auto_failover_enabled 字段
config.auto_failover_enabled = enabled;
// 写回数据库
state
.db
.set_failover_item_enabled(&app_type, &provider_id, enabled)
.update_proxy_config_for_app(config)
.await
.map_err(|e| e.to_string())
}
+10
View File
@@ -192,3 +192,13 @@ pub async fn toggle_mcp_app(
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?;
McpService::toggle_app(&state, &server_id, app_ty, enabled).map_err(|e| e.to_string())
}
/// 从所有应用导入 MCP 服务器(复用已有的导入逻辑)
#[tauri::command]
pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result<usize, String> {
let mut total = 0;
total += McpService::import_from_claude(&state).unwrap_or(0);
total += McpService::import_from_codex(&state).unwrap_or(0);
total += McpService::import_from_gemini(&state).unwrap_or(0);
Ok(total)
}
+58 -12
View File
@@ -1,9 +1,15 @@
#![allow(non_snake_case)]
use crate::init_status::InitErrorPayload;
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
use tauri::AppHandle;
use tauri_plugin_opener::OpenerExt;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
/// 打开外部链接
#[tauri::command]
pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {
@@ -59,6 +65,13 @@ pub async fn get_migration_result() -> Result<bool, String> {
Ok(crate::init_status::take_migration_success())
}
/// 获取 Skills 自动导入(SSOT)迁移结果(若有)。
/// 只返回一次 Some({count}),之后返回 None,用于前端显示一次性 Toast 通知。
#[tauri::command]
pub async fn get_skills_migration_result() -> Result<Option<SkillsMigrationPayload>, String> {
Ok(crate::init_status::take_skills_migration_result())
}
#[derive(serde::Serialize)]
pub struct ToolVersion {
name: String,
@@ -142,11 +155,16 @@ fn extract_version(raw: &str) -> String {
fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
use std::process::Command;
let output = if cfg!(target_os = "windows") {
#[cfg(target_os = "windows")]
let output = {
Command::new("cmd")
.args(["/C", &format!("{tool} --version")])
.creation_flags(CREATE_NO_WINDOW)
.output()
} else {
};
#[cfg(not(target_os = "windows"))]
let output = {
Command::new("sh")
.arg("-c")
.arg(format!("{tool} --version"))
@@ -155,11 +173,17 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if out.status.success() {
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
(Some(extract_version(&raw)), None)
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if raw.is_empty() {
(None, Some("未安装或无法执行".to_string()))
} else {
(Some(extract_version(raw)), None)
}
} else {
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
let err = if stderr.is_empty() { stdout } else { stderr };
(
None,
Some(if err.is_empty() {
@@ -231,17 +255,39 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
if tool_path.exists() {
// 构建 PATH 环境变量,确保 node 可被找到
let current_path = std::env::var("PATH").unwrap_or_default();
#[cfg(target_os = "windows")]
let new_path = format!("{};{}", path.display(), current_path);
#[cfg(not(target_os = "windows"))]
let new_path = format!("{}:{}", path.display(), current_path);
let output = Command::new(&tool_path)
.arg("--version")
.env("PATH", &new_path)
.output();
#[cfg(target_os = "windows")]
let output = {
// 使用 cmd /C 包装执行,确保子进程也在隐藏的控制台中运行
Command::new("cmd")
.args(["/C", &format!("\"{}\" --version", tool_path.display())])
.env("PATH", &new_path)
.creation_flags(CREATE_NO_WINDOW)
.output()
};
#[cfg(not(target_os = "windows"))]
let output = {
Command::new(&tool_path)
.arg("--version")
.env("PATH", &new_path)
.output()
};
if let Ok(out) = output {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if out.status.success() {
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
return (Some(extract_version(&raw)), None);
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if !raw.is_empty() {
return (Some(extract_version(raw)), None);
}
}
}
}
+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())
}
+94
View File
@@ -229,3 +229,97 @@ pub fn update_providers_sort_order(
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
}
// ============================================================================
// 统一供应商(Universal Provider)命令
// ============================================================================
use crate::provider::UniversalProvider;
use std::collections::HashMap;
use tauri::{AppHandle, Emitter};
/// 统一供应商同步完成事件的 payload
#[derive(Clone, serde::Serialize)]
pub struct UniversalProviderSyncedEvent {
/// 操作类型: "upsert" | "delete" | "sync"
pub action: String,
/// 统一供应商 ID
pub id: String,
}
/// 发送统一供应商同步事件,通知前端刷新供应商列表
fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
let _ = app.emit(
"universal-provider-synced",
UniversalProviderSyncedEvent {
action: action.to_string(),
id: id.to_string(),
},
);
}
/// 获取所有统一供应商
#[tauri::command]
pub fn get_universal_providers(
state: State<'_, AppState>,
) -> Result<HashMap<String, UniversalProvider>, String> {
ProviderService::list_universal(state.inner()).map_err(|e| e.to_string())
}
/// 获取单个统一供应商
#[tauri::command]
pub fn get_universal_provider(
state: State<'_, AppState>,
id: String,
) -> Result<Option<UniversalProvider>, String> {
ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string())
}
/// 添加或更新统一供应商
#[tauri::command]
pub fn upsert_universal_provider(
app: AppHandle,
state: State<'_, AppState>,
provider: UniversalProvider,
) -> Result<bool, String> {
let id = provider.id.clone();
let result =
ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?;
// 发送事件通知前端刷新
emit_universal_provider_synced(&app, "upsert", &id);
Ok(result)
}
/// 删除统一供应商
#[tauri::command]
pub fn delete_universal_provider(
app: AppHandle,
state: State<'_, AppState>,
id: String,
) -> Result<bool, String> {
let result =
ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?;
// 发送事件通知前端刷新
emit_universal_provider_synced(&app, "delete", &id);
Ok(result)
}
/// 同步统一供应商到各应用(手动触发)
#[tauri::command]
pub fn sync_universal_provider(
app: AppHandle,
state: State<'_, AppState>,
id: String,
) -> Result<bool, String> {
let result =
ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?;
// 发送事件通知前端刷新
emit_universal_provider_synced(&app, "sync", &id);
Ok(result)
}
+147 -3
View File
@@ -6,12 +6,12 @@ use crate::proxy::types::*;
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
use crate::store::AppState;
/// 启动代理服务器( Live 配置接管
/// 启动代理服务器(仅启动服务,不接管 Live 配置)
#[tauri::command]
pub async fn start_proxy_with_takeover(
pub async fn start_proxy_server(
state: tauri::State<'_, AppState>,
) -> Result<ProxyServerInfo, String> {
state.proxy_service.start_with_takeover().await
state.proxy_service.start().await
}
/// 停止代理服务器(恢复 Live 配置)
@@ -20,6 +20,27 @@ pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Resul
state.proxy_service.stop_with_restore().await
}
/// 获取各应用接管状态
#[tauri::command]
pub async fn get_proxy_takeover_status(
state: tauri::State<'_, AppState>,
) -> Result<ProxyTakeoverStatus, String> {
state.proxy_service.get_takeover_status().await
}
/// 为指定应用开启/关闭接管
#[tauri::command]
pub async fn set_proxy_takeover_for_app(
state: tauri::State<'_, AppState>,
app_type: String,
enabled: bool,
) -> Result<(), String> {
state
.proxy_service
.set_takeover_for_app(&app_type, enabled)
.await
}
/// 获取代理服务器状态
#[tauri::command]
pub async fn get_proxy_status(state: tauri::State<'_, AppState>) -> Result<ProxyStatus, String> {
@@ -41,6 +62,63 @@ pub async fn update_proxy_config(
state.proxy_service.update_config(&config).await
}
// ==================== Global & Per-App Config ====================
/// 获取全局代理配置
///
/// 返回统一的全局配置字段(代理开关、监听地址、端口、日志开关)
#[tauri::command]
pub async fn get_global_proxy_config(
state: tauri::State<'_, AppState>,
) -> Result<GlobalProxyConfig, String> {
let db = &state.db;
db.get_global_proxy_config()
.await
.map_err(|e| e.to_string())
}
/// 更新全局代理配置
///
/// 更新统一的全局配置字段,会同时更新三行(claude/codex/gemini
#[tauri::command]
pub async fn update_global_proxy_config(
state: tauri::State<'_, AppState>,
config: GlobalProxyConfig,
) -> Result<(), String> {
let db = &state.db;
db.update_global_proxy_config(config)
.await
.map_err(|e| e.to_string())
}
/// 获取指定应用的代理配置
///
/// 返回应用级配置(enabled、auto_failover、超时、熔断器等)
#[tauri::command]
pub async fn get_proxy_config_for_app(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<AppProxyConfig, String> {
let db = &state.db;
db.get_proxy_config_for_app(&app_type)
.await
.map_err(|e| e.to_string())
}
/// 更新指定应用的代理配置
///
/// 更新应用级配置(enabled、auto_failover、超时、熔断器等)
#[tauri::command]
pub async fn update_proxy_config_for_app(
state: tauri::State<'_, AppState>,
config: AppProxyConfig,
) -> Result<(), String> {
let db = &state.db;
db.update_proxy_config_for_app(config)
.await
.map_err(|e| e.to_string())
}
/// 检查代理服务器是否正在运行
#[tauri::command]
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
@@ -82,8 +160,13 @@ pub async fn get_provider_health(
}
/// 重置熔断器
///
/// 重置后会检查是否应该切回队列中优先级更高的供应商:
/// 1. 检查自动故障转移是否开启
/// 2. 如果恢复的供应商在队列中优先级更高(queue_order 更小),则自动切换
#[tauri::command]
pub async fn reset_circuit_breaker(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
provider_id: String,
app_type: String,
@@ -100,6 +183,67 @@ pub async fn reset_circuit_breaker(
.reset_provider_circuit_breaker(&provider_id, &app_type)
.await?;
// 3. 检查是否应该切回优先级更高的供应商(从 proxy_config 表读取)
// 只有当该应用已被代理接管(enabled=true)且开启了自动故障转移时才执行
let (app_enabled, auto_failover_enabled) = match db.get_proxy_config_for_app(&app_type).await {
Ok(config) => (config.enabled, config.auto_failover_enabled),
Err(e) => {
log::error!("[{app_type}] Failed to read proxy_config: {e}, defaulting to disabled");
(false, false)
}
};
if app_enabled && auto_failover_enabled && state.proxy_service.is_running().await {
// 获取当前供应商 ID
let current_id = db
.get_current_provider(&app_type)
.map_err(|e| e.to_string())?;
if let Some(current_id) = current_id {
// 获取故障转移队列
let queue = db
.get_failover_queue(&app_type)
.map_err(|e| e.to_string())?;
// 找到恢复的供应商和当前供应商在队列中的位置(使用 sort_index
let restored_order = queue
.iter()
.find(|item| item.provider_id == provider_id)
.and_then(|item| item.sort_index);
let current_order = queue
.iter()
.find(|item| item.provider_id == current_id)
.and_then(|item| item.sort_index);
// 如果恢复的供应商优先级更高(sort_index 更小),则切换
if let (Some(restored), Some(current)) = (restored_order, current_order) {
if restored < current {
log::info!(
"[Recovery] 供应商 {provider_id} 已恢复且优先级更高 (P{restored} vs P{current}),自动切换"
);
// 获取供应商名称用于日志和事件
let provider_name = db
.get_all_providers(&app_type)
.ok()
.and_then(|providers| providers.get(&provider_id).map(|p| p.name.clone()))
.unwrap_or_else(|| provider_id.clone());
// 创建故障转移切换管理器并执行切换
let switch_manager =
crate::proxy::failover_switch::FailoverSwitchManager::new(db.clone());
if let Err(e) = switch_manager
.try_switch(Some(&app_handle), &app_type, &provider_id, &provider_name)
.await
{
log::error!("[Recovery] 自动切换失败: {e}");
}
}
}
}
}
Ok(())
}
+142 -127
View File
@@ -1,12 +1,17 @@
use crate::app_config::AppType;
//! Skills 命令层
//!
//! v3.10.0+ 统一管理架构:
//! - 支持三应用开关(Claude/Codex/Gemini
//! - SSOT 存储在 ~/.cc-switch/skills/
use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill};
use crate::error::format_skill_error;
use crate::services::skill::SkillState;
use crate::services::{Skill, SkillRepo, SkillService};
use crate::services::skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
use crate::store::AppState;
use chrono::Utc;
use std::sync::Arc;
use tauri::State;
/// SkillService 状态包装
pub struct SkillServiceState(pub Arc<SkillService>);
/// 解析 app 参数为 AppType
@@ -19,65 +24,117 @@ fn parse_app_type(app: &str) -> Result<AppType, String> {
}
}
/// 根据 app_type 生成带前缀的 skill key
fn get_skill_key(app_type: &AppType, directory: &str) -> String {
let prefix = match app_type {
AppType::Claude => "claude",
AppType::Codex => "codex",
AppType::Gemini => "gemini",
};
format!("{prefix}:{directory}")
// ========== 统一管理命令 ==========
/// 获取所有已安装的 Skills
#[tauri::command]
pub fn get_installed_skills(app_state: State<'_, AppState>) -> Result<Vec<InstalledSkill>, String> {
SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())
}
/// 安装 Skill(新版统一安装)
///
/// 参数:
/// - skill: 从发现列表获取的技能信息
/// - current_app: 当前选中的应用,安装后默认启用该应用
#[tauri::command]
pub async fn install_skill_unified(
skill: DiscoverableSkill,
current_app: String,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<InstalledSkill, String> {
let app_type = parse_app_type(&current_app)?;
service
.0
.install(&app_state.db, &skill, &app_type)
.await
.map_err(|e| e.to_string())
}
/// 卸载 Skill(新版统一卸载)
#[tauri::command]
pub fn uninstall_skill_unified(id: String, app_state: State<'_, AppState>) -> Result<bool, String> {
SkillService::uninstall(&app_state.db, &id).map_err(|e| e.to_string())?;
Ok(true)
}
/// 切换 Skill 的应用启用状态
#[tauri::command]
pub fn toggle_skill_app(
id: String,
app: String,
enabled: bool,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
let app_type = parse_app_type(&app)?;
SkillService::toggle_app(&app_state.db, &id, &app_type, enabled).map_err(|e| e.to_string())?;
Ok(true)
}
/// 扫描未管理的 Skills
#[tauri::command]
pub fn scan_unmanaged_skills(
app_state: State<'_, AppState>,
) -> Result<Vec<UnmanagedSkill>, String> {
SkillService::scan_unmanaged(&app_state.db).map_err(|e| e.to_string())
}
/// 从应用目录导入 Skills
#[tauri::command]
pub fn import_skills_from_apps(
directories: Vec<String>,
app_state: State<'_, AppState>,
) -> Result<Vec<InstalledSkill>, String> {
SkillService::import_from_apps(&app_state.db, directories).map_err(|e| e.to_string())
}
// ========== 发现功能命令 ==========
/// 发现可安装的 Skills(从仓库获取)
#[tauri::command]
pub async fn discover_available_skills(
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<DiscoverableSkill>, String> {
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
service
.0
.discover_available(repos)
.await
.map_err(|e| e.to_string())
}
// ========== 兼容旧 API 的命令 ==========
/// 获取技能列表(兼容旧 API)
#[tauri::command]
pub async fn get_skills(
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<Skill>, String> {
get_skills_for_app("claude".to_string(), service, app_state).await
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
service
.0
.list_skills(repos, &app_state.db)
.await
.map_err(|e| e.to_string())
}
/// 获取指定应用的技能列表(兼容旧 API)
#[tauri::command]
pub async fn get_skills_for_app(
app: String,
_service: State<'_, SkillServiceState>,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<Skill>, String> {
let app_type = parse_app_type(&app)?;
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
let skills = service
.list_skills(repos)
.await
.map_err(|e| e.to_string())?;
// 自动同步本地已安装的 skills 到数据库
// 这样用户在首次运行时,已有的 skills 会被自动记录
let existing_states = app_state.db.get_skills().unwrap_or_default();
for skill in &skills {
if skill.installed {
let key = get_skill_key(&app_type, &skill.directory);
if !existing_states.contains_key(&key) {
// 本地有该 skill,但数据库中没有记录,自动添加
if let Err(e) = app_state.db.update_skill_state(
&key,
&SkillState {
installed: true,
installed_at: Utc::now(),
},
) {
log::warn!("同步本地 skill {key} 状态到数据库失败: {e}");
}
}
}
}
Ok(skills)
// 新版本不再区分应用,统一返回所有技能
let _ = parse_app_type(&app)?; // 验证 app 参数有效
get_skills(service, app_state).await
}
/// 安装技能(兼容旧 API
#[tauri::command]
pub async fn install_skill(
directory: String,
@@ -87,27 +144,34 @@ pub async fn install_skill(
install_skill_for_app("claude".to_string(), directory, service, app_state).await
}
/// 安装指定应用的技能(兼容旧 API)
#[tauri::command]
pub async fn install_skill_for_app(
app: String,
directory: String,
_service: State<'_, SkillServiceState>,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
let app_type = parse_app_type(&app)?;
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
// 先在不持有写锁的情况下收集仓库与技能信息
// 先获取技能信息
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
let skills = service
.list_skills(repos)
.0
.discover_available(repos)
.await
.map_err(|e| e.to_string())?;
let skill = skills
.iter()
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
.into_iter()
.find(|s| {
let install_name = std::path::Path::new(&s.directory)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| s.directory.clone());
install_name.eq_ignore_ascii_case(&directory)
|| s.directory.eq_ignore_ascii_case(&directory)
})
.ok_or_else(|| {
format_skill_error(
"SKILL_NOT_FOUND",
@@ -116,103 +180,54 @@ pub async fn install_skill_for_app(
)
})?;
if !skill.installed {
let repo = SkillRepo {
owner: skill.repo_owner.clone().ok_or_else(|| {
format_skill_error(
"MISSING_REPO_INFO",
&[("directory", &directory), ("field", "owner")],
None,
)
})?,
name: skill.repo_name.clone().ok_or_else(|| {
format_skill_error(
"MISSING_REPO_INFO",
&[("directory", &directory), ("field", "name")],
None,
)
})?,
branch: skill
.repo_branch
.clone()
.unwrap_or_else(|| "main".to_string()),
enabled: true,
};
service
.install_skill(directory.clone(), repo)
.await
.map_err(|e| e.to_string())?;
}
let key = get_skill_key(&app_type, &directory);
app_state
.db
.update_skill_state(
&key,
&SkillState {
installed: true,
installed_at: Utc::now(),
},
)
service
.0
.install(&app_state.db, &skill, &app_type)
.await
.map_err(|e| e.to_string())?;
Ok(true)
}
/// 卸载技能(兼容旧 API
#[tauri::command]
pub fn uninstall_skill(
directory: String,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
uninstall_skill_for_app("claude".to_string(), directory, service, app_state)
pub fn uninstall_skill(directory: String, app_state: State<'_, AppState>) -> Result<bool, String> {
uninstall_skill_for_app("claude".to_string(), directory, app_state)
}
/// 卸载指定应用的技能(兼容旧 API)
#[tauri::command]
pub fn uninstall_skill_for_app(
app: String,
directory: String,
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
let app_type = parse_app_type(&app)?;
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
let _ = parse_app_type(&app)?; // 验证参数
service
.uninstall_skill(directory.clone())
.map_err(|e| e.to_string())?;
// 通过 directory 找到对应的 skill id
let skills = SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())?;
// Remove from database by setting installed = false
let key = get_skill_key(&app_type, &directory);
app_state
.db
.update_skill_state(
&key,
&SkillState {
installed: false,
installed_at: Utc::now(),
},
)
.map_err(|e| e.to_string())?;
let skill = skills
.into_iter()
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
.ok_or_else(|| format!("未找到已安装的 Skill: {directory}"))?;
SkillService::uninstall(&app_state.db, &skill.id).map_err(|e| e.to_string())?;
Ok(true)
}
// ========== 仓库管理命令 ==========
/// 获取技能仓库列表
#[tauri::command]
pub fn get_skill_repos(
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<SkillRepo>, String> {
pub fn get_skill_repos(app_state: State<'_, AppState>) -> Result<Vec<SkillRepo>, String> {
app_state.db.get_skill_repos().map_err(|e| e.to_string())
}
/// 添加技能仓库
#[tauri::command]
pub fn add_skill_repo(
repo: SkillRepo,
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
pub fn add_skill_repo(repo: SkillRepo, app_state: State<'_, AppState>) -> Result<bool, String> {
app_state
.db
.save_skill_repo(&repo)
@@ -220,11 +235,11 @@ pub fn add_skill_repo(
Ok(true)
}
/// 删除技能仓库
#[tauri::command]
pub fn remove_skill_repo(
owner: String,
name: String,
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
app_state
+1 -3
View File
@@ -52,9 +52,7 @@ pub async fn stream_check_all_providers(
}
if let Ok(queue) = state.db.get_failover_queue(app_type.as_str()) {
for item in queue {
if item.enabled {
ids.insert(item.provider_id);
}
ids.insert(item.provider_id);
}
}
Some(ids)
+3 -2
View File
@@ -19,9 +19,10 @@ pub fn get_usage_summary(
#[tauri::command]
pub fn get_usage_trends(
state: State<'_, AppState>,
days: u32,
start_date: Option<i64>,
end_date: Option<i64>,
) -> Result<Vec<DailyStats>, AppError> {
state.db.get_daily_trends(days)
state.db.get_daily_trends(start_date, end_date)
}
/// 获取 Provider 统计
+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);
+35 -130
View File
@@ -1,36 +1,32 @@
//! 故障转移队列 DAO
//!
//! 管理代理模式下的故障转移队列
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::provider::Provider;
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
/// 故障转移队列条目
/// 故障转移队列条目(简化版,用于前端展示)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FailoverQueueItem {
pub provider_id: String,
pub provider_name: String,
pub queue_order: i32,
pub enabled: bool,
pub created_at: i64,
pub sort_index: Option<usize>,
}
impl Database {
/// 获取故障转移队列(按 queue_order 排序)
/// 获取故障转移队列(按 sort_index 排序)
pub fn get_failover_queue(&self, app_type: &str) -> Result<Vec<FailoverQueueItem>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT fq.provider_id, p.name, fq.queue_order, fq.enabled, fq.created_at
FROM failover_queue fq
JOIN providers p ON fq.provider_id = p.id AND fq.app_type = p.app_type
WHERE fq.app_type = ?1
ORDER BY fq.queue_order ASC",
"SELECT id, name, sort_index
FROM providers
WHERE app_type = ?1 AND in_failover_queue = 1
ORDER BY COALESCE(sort_index, 999999), id ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -39,9 +35,7 @@ impl Database {
Ok(FailoverQueueItem {
provider_id: row.get(0)?,
provider_name: row.get(1)?,
queue_order: row.get(2)?,
enabled: row.get(3)?,
created_at: row.get(4)?,
sort_index: row.get(2)?,
})
})
.map_err(|e| AppError::Database(e.to_string()))?
@@ -53,43 +47,23 @@ impl Database {
/// 获取故障转移队列中的供应商(完整 Provider 信息,按顺序)
pub fn get_failover_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
let queue = self.get_failover_queue(app_type)?;
let all_providers = self.get_all_providers(app_type)?;
let mut result = Vec::new();
for item in queue {
if item.enabled {
if let Some(provider) = all_providers.get(&item.provider_id) {
result.push(provider.clone());
}
}
}
let result: Vec<Provider> = all_providers
.into_values()
.filter(|p| p.in_failover_queue)
.collect();
Ok(result)
}
/// 添加供应商到故障转移队列末尾
/// 添加供应商到故障转移队列
pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 获取当前最大 queue_order
let max_order: i32 = conn
.query_row(
"SELECT COALESCE(MAX(queue_order), 0) FROM failover_queue WHERE app_type = ?1",
[app_type],
|row| row.get(0),
)
.map_err(|e| AppError::Database(e.to_string()))?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
conn.execute(
"INSERT OR IGNORE INTO failover_queue (app_type, provider_id, queue_order, enabled, created_at)
VALUES (?1, ?2, ?3, 1, ?4)",
rusqlite::params![app_type, provider_id, max_order + 1, now],
"UPDATE providers SET in_failover_queue = 1 WHERE id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -104,90 +78,22 @@ impl Database {
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 获取被删除项的 queue_order
let removed_order: Option<i32> = conn
.query_row(
"SELECT queue_order FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
[app_type, provider_id],
|row| row.get(0),
)
.ok();
// 删除该项
// 1. 从队列中移除
conn.execute(
"DELETE FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
[app_type, provider_id],
"UPDATE providers SET in_failover_queue = 0 WHERE id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 重新排序后面的项(填补空隙
if let Some(order) = removed_order {
conn.execute(
"UPDATE failover_queue
SET queue_order = queue_order - 1
WHERE app_type = ?1 AND queue_order > ?2",
rusqlite::params![app_type, order],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
Ok(())
}
/// 重新排序故障转移队列
/// provider_ids: 按新顺序排列的 provider_id 列表
pub fn reorder_failover_queue(
&self,
app_type: &str,
provider_ids: &[String],
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 使用事务确保原子性
conn.execute("BEGIN TRANSACTION", [])
.map_err(|e| AppError::Database(e.to_string()))?;
let result = (|| {
for (index, provider_id) in provider_ids.iter().enumerate() {
conn.execute(
"UPDATE failover_queue
SET queue_order = ?3
WHERE app_type = ?1 AND provider_id = ?2",
rusqlite::params![app_type, provider_id, (index + 1) as i32],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
Ok(())
})();
match result {
Ok(_) => {
conn.execute("COMMIT", [])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
Err(e) => {
conn.execute("ROLLBACK", []).ok();
Err(e)
}
}
}
/// 设置故障转移队列中供应商的启用状态
pub fn set_failover_item_enabled(
&self,
app_type: &str,
provider_id: &str,
enabled: bool,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 2. 清除该供应商的健康状态(退出队列后不再需要健康监控
conn.execute(
"UPDATE failover_queue SET enabled = ?3 WHERE app_type = ?1 AND provider_id = ?2",
rusqlite::params![app_type, provider_id, enabled],
"DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
log::info!("已从故障转移队列移除供应商 {provider_id} ({app_type}), 并清除其健康状态");
Ok(())
}
@@ -195,8 +101,11 @@ impl Database {
pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute("DELETE FROM failover_queue WHERE app_type = ?1", [app_type])
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"UPDATE providers SET in_failover_queue = 0 WHERE app_type = ?1",
[app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
@@ -209,15 +118,15 @@ impl Database {
) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let count: i32 = conn
let in_queue: bool = conn
.query_row(
"SELECT COUNT(*) FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
[app_type, provider_id],
"SELECT in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
|row| row.get(0),
)
.map_err(|e| AppError::Database(e.to_string()))?;
.unwrap_or(false);
Ok(count > 0)
Ok(in_queue)
}
/// 获取可添加到故障转移队列的供应商(不在队列中的)
@@ -226,14 +135,10 @@ impl Database {
app_type: &str,
) -> Result<Vec<Provider>, AppError> {
let all_providers = self.get_all_providers(app_type)?;
let queue = self.get_failover_queue(app_type)?;
let queue_ids: std::collections::HashSet<_> =
queue.iter().map(|item| &item.provider_id).collect();
let available: Vec<Provider> = all_providers
.into_values()
.filter(|p| !queue_ids.contains(&p.id))
.filter(|p| !p.in_failover_queue)
.collect();
Ok(available)
+1
View File
@@ -10,6 +10,7 @@ pub mod proxy;
pub mod settings;
pub mod skills;
pub mod stream_check;
pub mod universal_providers;
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
// 导出 FailoverQueueItem 供外部使用
+19 -11
View File
@@ -17,7 +17,7 @@ impl Database {
) -> Result<IndexMap<String, Provider>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn.prepare(
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
FROM providers WHERE app_type = ?1
ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC"
).map_err(|e| AppError::Database(e.to_string()))?;
@@ -35,6 +35,7 @@ impl Database {
let icon: Option<String> = row.get(8)?;
let icon_color: Option<String> = row.get(9)?;
let meta_str: String = row.get(10)?;
let in_failover_queue: bool = row.get(11)?;
let settings_config =
serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
@@ -54,6 +55,7 @@ impl Database {
meta: Some(meta),
icon,
icon_color,
in_failover_queue,
},
))
})
@@ -129,7 +131,7 @@ impl Database {
) -> Result<Option<Provider>, AppError> {
let conn = lock_conn!(self.conn);
let result = conn.query_row(
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
FROM providers WHERE id = ?1 AND app_type = ?2",
params![id, app_type],
|row| {
@@ -143,6 +145,7 @@ impl Database {
let icon: Option<String> = row.get(7)?;
let icon_color: Option<String> = row.get(8)?;
let meta_str: String = row.get(9)?;
let in_failover_queue: bool = row.get(10)?;
let settings_config = serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();
@@ -159,6 +162,7 @@ impl Database {
meta: Some(meta),
icon,
icon_color,
in_failover_queue,
})
},
);
@@ -184,17 +188,18 @@ impl Database {
let mut meta_clone = provider.meta.clone().unwrap_or_default();
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
// 检查是否存在(用于判断新增/更新,以及保留 is_current
let existing: Option<bool> = tx
// 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue
let existing: Option<(bool, bool)> = tx
.query_row(
"SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2",
"SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
params![provider.id, app_type],
|row| row.get(0),
|row| Ok((row.get(0)?, row.get(1)?)),
)
.ok();
let is_update = existing.is_some();
let is_current = existing.unwrap_or(false);
let (is_current, in_failover_queue) =
existing.unwrap_or((false, provider.in_failover_queue));
if is_update {
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
@@ -210,8 +215,9 @@ impl Database {
icon = ?8,
icon_color = ?9,
meta = ?10,
is_current = ?11
WHERE id = ?12 AND app_type = ?13",
is_current = ?11,
in_failover_queue = ?12
WHERE id = ?13 AND app_type = ?14",
params![
provider.name,
serde_json::to_string(&provider.settings_config).unwrap(),
@@ -224,6 +230,7 @@ impl Database {
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(),
is_current,
in_failover_queue,
provider.id,
app_type,
],
@@ -234,8 +241,8 @@ impl Database {
tx.execute(
"INSERT INTO providers (
id, app_type, name, settings_config, website_url, category,
created_at, sort_index, notes, icon, icon_color, meta, is_current
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
created_at, sort_index, notes, icon, icon_color, meta, is_current, in_failover_queue
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![
provider.id,
app_type,
@@ -250,6 +257,7 @@ impl Database {
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(),
is_current,
in_failover_queue,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
+325 -79
View File
@@ -8,63 +8,66 @@ use crate::proxy::types::*;
use super::super::{lock_conn, Database};
impl Database {
// ==================== Proxy Config ====================
// ==================== Global Proxy Config ====================
/// 获取代理配置
pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> {
// 在一个作用域内获取锁并查询,确保锁在await之前释放
/// 获取全局代理配置(统一字段)
///
/// 从 claude 行读取(三行镜像一致)
pub async fn get_global_proxy_config(&self) -> Result<GlobalProxyConfig, AppError> {
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT enabled, listen_address, listen_port, max_retries,
request_timeout, enable_logging, live_takeover_active
FROM proxy_config WHERE id = 1",
"SELECT proxy_enabled, listen_address, listen_port, enable_logging
FROM proxy_config WHERE app_type = 'claude'",
[],
|row| {
Ok(ProxyConfig {
enabled: row.get::<_, i32>(0)? != 0,
Ok(GlobalProxyConfig {
proxy_enabled: row.get::<_, i32>(0)? != 0,
listen_address: row.get(1)?,
listen_port: row.get::<_, i32>(2)? as u16,
max_retries: row.get::<_, i32>(3)? as u8,
request_timeout: row.get::<_, i32>(4)? as u64,
enable_logging: row.get::<_, i32>(5)? != 0,
live_takeover_active: row.get::<_, i32>(6).unwrap_or(0) != 0,
enable_logging: row.get::<_, i32>(3)? != 0,
})
},
)
}; // conn锁在这里释放
};
// conn 已在 block 结束时释放
match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,插入默认配置
let default_config = ProxyConfig::default();
self.update_proxy_config(default_config.clone()).await?;
Ok(default_config)
// 如果不存在,创建默认配置
self.init_proxy_config_rows().await?;
Ok(GlobalProxyConfig {
proxy_enabled: false,
listen_address: "127.0.0.1".to_string(),
listen_port: 5000,
enable_logging: true,
})
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 更新代理配置
pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> {
/// 更新全局代理配置(镜像写三行)
pub async fn update_global_proxy_config(
&self,
config: GlobalProxyConfig,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO proxy_config
(id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, live_takeover_active, target_app, created_at, updated_at)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')),
datetime('now'))",
"UPDATE proxy_config SET
proxy_enabled = ?1,
listen_address = ?2,
listen_port = ?3,
enable_logging = ?4,
updated_at = datetime('now')",
rusqlite::params![
if config.enabled { 1 } else { 0 },
if config.proxy_enabled { 1 } else { 0 },
config.listen_address,
config.listen_port as i32,
config.max_retries as i32,
config.request_timeout as i32,
if config.enable_logging { 1 } else { 0 },
if config.live_takeover_active { 1 } else { 0 },
"claude", // 兼容旧字段,写入默认值
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -72,28 +75,214 @@ impl Database {
Ok(())
}
/// 设置 Live 接管状态
pub async fn set_live_takeover_active(&self, active: bool) -> Result<(), AppError> {
/// 获取应用级代理配置
pub async fn get_proxy_config_for_app(
&self,
app_type: &str,
) -> Result<AppProxyConfig, AppError> {
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
let app_type_owned = app_type.to_string();
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT app_type, enabled, auto_failover_enabled,
max_retries, streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests
FROM proxy_config WHERE app_type = ?1",
[app_type],
|row| {
Ok(AppProxyConfig {
app_type: row.get(0)?,
enabled: row.get::<_, i32>(1)? != 0,
auto_failover_enabled: row.get::<_, i32>(2)? != 0,
max_retries: row.get::<_, i32>(3)? as u32,
streaming_first_byte_timeout: row.get::<_, i32>(4)? as u32,
streaming_idle_timeout: row.get::<_, i32>(5)? as u32,
non_streaming_timeout: row.get::<_, i32>(6)? as u32,
circuit_failure_threshold: row.get::<_, i32>(7)? as u32,
circuit_success_threshold: row.get::<_, i32>(8)? as u32,
circuit_timeout_seconds: row.get::<_, i32>(9)? as u32,
circuit_error_rate_threshold: row.get(10)?,
circuit_min_requests: row.get::<_, i32>(11)? as u32,
})
},
)
};
// conn 已在 block 结束时释放
match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,创建默认配置
self.init_proxy_config_rows().await?;
Ok(AppProxyConfig {
app_type: app_type_owned,
enabled: false,
auto_failover_enabled: false,
max_retries: 3,
streaming_first_byte_timeout: 60,
streaming_idle_timeout: 120,
non_streaming_timeout: 600,
circuit_failure_threshold: 4,
circuit_success_threshold: 2,
circuit_timeout_seconds: 60,
circuit_error_rate_threshold: 0.6,
circuit_min_requests: 10,
})
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 更新应用级代理配置
pub async fn update_proxy_config_for_app(
&self,
config: AppProxyConfig,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE proxy_config SET live_takeover_active = ?1, updated_at = datetime('now') WHERE id = 1",
rusqlite::params![if active { 1 } else { 0 }],
"UPDATE proxy_config SET
enabled = ?2,
auto_failover_enabled = ?3,
max_retries = ?4,
streaming_first_byte_timeout = ?5,
streaming_idle_timeout = ?6,
non_streaming_timeout = ?7,
circuit_failure_threshold = ?8,
circuit_success_threshold = ?9,
circuit_timeout_seconds = ?10,
circuit_error_rate_threshold = ?11,
circuit_min_requests = ?12,
updated_at = datetime('now')
WHERE app_type = ?1",
rusqlite::params![
config.app_type,
if config.enabled { 1 } else { 0 },
if config.auto_failover_enabled { 1 } else { 0 },
config.max_retries as i32,
config.streaming_first_byte_timeout as i32,
config.streaming_idle_timeout as i32,
config.non_streaming_timeout as i32,
config.circuit_failure_threshold as i32,
config.circuit_success_threshold as i32,
config.circuit_timeout_seconds as i32,
config.circuit_error_rate_threshold,
config.circuit_min_requests as i32,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 初始化 proxy_config 表的三行数据
async fn init_proxy_config_rows(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
for app_type in &["claude", "codex", "gemini"] {
conn.execute(
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES (?1)",
[app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
Ok(())
}
// ==================== Legacy Proxy Config (兼容旧代码) ====================
/// 获取代理配置(兼容旧接口,返回 claude 行的配置)
pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> {
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT listen_address, listen_port, max_retries,
enable_logging,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout
FROM proxy_config WHERE app_type = 'claude'",
[],
|row| {
Ok(ProxyConfig {
listen_address: row.get(0)?,
listen_port: row.get::<_, i32>(1)? as u16,
max_retries: row.get::<_, i32>(2)? as u8,
request_timeout: 600, // 废弃字段,返回默认值
enable_logging: row.get::<_, i32>(3)? != 0,
live_takeover_active: false, // 废弃字段
streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(60) as u64,
streaming_idle_timeout: row.get::<_, i32>(5).unwrap_or(120) as u64,
non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(600) as u64,
})
},
)
};
// conn 已在 block 结束时释放
match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,初始化默认配置
self.init_proxy_config_rows().await?;
Ok(ProxyConfig::default())
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 更新代理配置(兼容旧接口,更新所有三行的公共字段)
pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 更新所有三行的公共字段
conn.execute(
"UPDATE proxy_config SET
listen_address = ?1,
listen_port = ?2,
max_retries = ?3,
enable_logging = ?4,
streaming_first_byte_timeout = ?5,
streaming_idle_timeout = ?6,
non_streaming_timeout = ?7,
updated_at = datetime('now')",
rusqlite::params![
config.listen_address,
config.listen_port as i32,
config.max_retries as i32,
if config.enable_logging { 1 } else { 0 },
config.streaming_first_byte_timeout as i32,
config.streaming_idle_timeout as i32,
config.non_streaming_timeout as i32,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 设置 Live 接管状态(兼容旧版本,更新 enabled 字段)
pub async fn set_live_takeover_active(&self, _active: bool) -> Result<(), AppError> {
// 不再使用此字段,由 enabled 字段替代
// 保留空实现以兼容旧代码
Ok(())
}
/// 检查是否处于 Live 接管模式
///
/// 检查是否有任一 app 的 enabled = true
pub async fn is_live_takeover_active(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let active: i32 = conn
let count: i64 = conn
.query_row(
"SELECT COALESCE(live_takeover_active, 0) FROM proxy_config WHERE id = 1",
"SELECT COUNT(*) FROM proxy_config WHERE enabled = 1",
[],
|row| row.get(0),
)
.unwrap_or(0);
Ok(active != 0)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(count > 0)
}
// ==================== Provider Health ====================
@@ -104,28 +293,45 @@ impl Database {
provider_id: &str,
app_type: &str,
) -> Result<ProviderHealth, AppError> {
let conn = lock_conn!(self.conn);
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT provider_id, app_type, is_healthy, consecutive_failures,
last_success_at, last_failure_at, last_error, updated_at
FROM provider_health
WHERE provider_id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
|row| {
Ok(ProviderHealth {
provider_id: row.get(0)?,
app_type: row.get(1)?,
is_healthy: row.get::<_, i64>(2)? != 0,
consecutive_failures: row.get::<_, i64>(3)? as u32,
last_success_at: row.get(4)?,
last_failure_at: row.get(5)?,
last_error: row.get(6)?,
updated_at: row.get(7)?,
})
},
)
.map_err(|e| AppError::Database(e.to_string()))
conn.query_row(
"SELECT provider_id, app_type, is_healthy, consecutive_failures,
last_success_at, last_failure_at, last_error, updated_at
FROM provider_health
WHERE provider_id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
|row| {
Ok(ProviderHealth {
provider_id: row.get(0)?,
app_type: row.get(1)?,
is_healthy: row.get::<_, i64>(2)? != 0,
consecutive_failures: row.get::<_, i64>(3)? as u32,
last_success_at: row.get(4)?,
last_failure_at: row.get(5)?,
last_error: row.get(6)?,
updated_at: row.get(7)?,
})
},
)
};
match result {
Ok(health) => Ok(health),
// 缺少记录时视为健康(关闭后清空状态,再次打开时默认正常)
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(ProviderHealth {
provider_id: provider_id.to_string(),
app_type: app_type.to_string(),
is_healthy: true,
consecutive_failures: 0,
last_success_at: None,
last_failure_at: None,
last_error: None,
updated_at: chrono::Utc::now().to_rfc3339(),
}),
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 更新Provider健康状态
@@ -230,6 +436,20 @@ impl Database {
Ok(())
}
/// 清空指定应用的健康状态(关闭单个代理时使用)
pub async fn clear_provider_health_for_app(&self, app_type: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM provider_health WHERE app_type = ?1",
[app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
log::debug!("Cleared provider health records for app {app_type}");
Ok(())
}
/// 清空所有Provider健康状态(代理停止时调用)
pub async fn clear_all_provider_health(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
@@ -241,19 +461,22 @@ impl Database {
Ok(())
}
// ==================== Circuit Breaker Config ====================
// ==================== Circuit Breaker Config (Legacy Compatibility) ====================
/// 获取熔断器配置
/// 获取熔断器配置(兼容旧接口,从 claude 行读取)
///
/// 熔断器配置已合并到 proxy_config 表,每 app 独立
/// 此方法保留用于兼容旧代码,建议使用 get_proxy_config_for_app
pub async fn get_circuit_breaker_config(
&self,
) -> Result<crate::proxy::circuit_breaker::CircuitBreakerConfig, AppError> {
let conn = lock_conn!(self.conn);
let config = conn
.query_row(
"SELECT failure_threshold, success_threshold, timeout_seconds,
error_rate_threshold, min_requests
FROM circuit_breaker_config WHERE id = 1",
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests
FROM proxy_config WHERE app_type = 'claude'",
[],
|row| {
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig {
@@ -265,27 +488,39 @@ impl Database {
})
},
)
.map_err(|e| AppError::Database(e.to_string()))?;
};
// conn 已在 block 结束时释放
Ok(config)
match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,初始化默认配置
self.init_proxy_config_rows().await?;
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig::default())
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 更新熔断器配置
/// 更新熔断器配置(兼容旧接口,更新所有三行)
///
/// 熔断器配置已合并到 proxy_config 表
/// 此方法保留用于兼容旧代码,建议使用 update_proxy_config_for_app
pub async fn update_circuit_breaker_config(
&self,
config: &crate::proxy::circuit_breaker::CircuitBreakerConfig,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 更新所有三行的熔断器配置
conn.execute(
"UPDATE circuit_breaker_config
SET failure_threshold = ?1,
success_threshold = ?2,
timeout_seconds = ?3,
error_rate_threshold = ?4,
min_requests = ?5,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1",
"UPDATE proxy_config SET
circuit_failure_threshold = ?1,
circuit_success_threshold = ?2,
circuit_timeout_seconds = ?3,
circuit_error_rate_threshold = ?4,
circuit_min_requests = ?5,
updated_at = datetime('now')",
rusqlite::params![
config.failure_threshold as i32,
config.success_threshold as i32,
@@ -321,6 +556,17 @@ impl Database {
Ok(())
}
/// 检查是否存在任意 Live 配置备份
pub async fn has_any_live_backup(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM proxy_live_backup", [], |row| {
row.get(0)
})
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(count > 0)
}
/// 获取 Live 配置备份
pub async fn get_live_backup(&self, app_type: &str) -> Result<Option<LiveBackup>, AppError> {
let conn = lock_conn!(self.conn);
+66
View File
@@ -62,4 +62,70 @@ impl Database {
Ok(())
}
}
// --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)---
/// 获取指定应用的代理接管状态
///
/// **已废弃**: 请使用 `proxy_config.enabled` 字段替代
/// 此方法仅用于数据库迁移时读取旧数据
#[deprecated(since = "3.9.0", note = "使用 get_proxy_config_for_app().enabled 替代")]
pub fn get_proxy_takeover_enabled(&self, app_type: &str) -> Result<bool, AppError> {
let key = format!("proxy_takeover_{app_type}");
match self.get_setting(&key)? {
Some(value) => Ok(value == "true"),
None => Ok(false),
}
}
/// 设置指定应用的代理接管状态
///
/// **已废弃**: 请使用 `proxy_config.enabled` 字段替代
#[deprecated(
since = "3.9.0",
note = "使用 update_proxy_config_for_app() 修改 enabled 字段"
)]
pub fn set_proxy_takeover_enabled(
&self,
app_type: &str,
enabled: bool,
) -> Result<(), AppError> {
let key = format!("proxy_takeover_{app_type}");
let value = if enabled { "true" } else { "false" };
self.set_setting(&key, value)
}
/// 检查是否有任一应用开启了代理接管
///
/// **已废弃**: 请使用 `is_live_takeover_active()` 替代
#[deprecated(since = "3.9.0", note = "使用 is_live_takeover_active() 替代")]
pub fn has_any_proxy_takeover(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM settings WHERE key LIKE 'proxy_takeover_%' AND value = 'true'",
[],
|row| row.get(0),
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(count > 0)
}
/// 清除所有代理接管状态(将所有 proxy_takeover_* 设置为 false
///
/// **已废弃**: settings 表不再用于存储代理状态
#[deprecated(
since = "3.9.0",
note = "使用 update_proxy_config_for_app() 清除各应用的 enabled 字段"
)]
pub fn clear_all_proxy_takeover(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE settings SET value = 'false' WHERE key LIKE 'proxy_takeover_%'",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
log::info!("已清除所有代理接管状态");
Ok(())
}
}
+121 -37
View File
@@ -1,73 +1,156 @@
//! Skills 数据访问对象
//!
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
//!
//! v3.10.0+ 统一管理架构:
//! - Skills 使用统一的 id 主键,支持三应用启用标志
//! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录
use crate::app_config::{InstalledSkill, SkillApps};
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::services::skill::{SkillRepo, SkillState};
use crate::services::skill::SkillRepo;
use indexmap::IndexMap;
use rusqlite::params;
impl Database {
/// 获取所有 Skills 状态
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
// ========== InstalledSkill CRUD ==========
/// 获取所有已安装的 Skills
pub fn get_all_installed_skills(&self) -> Result<IndexMap<String, InstalledSkill>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT directory, app_type, installed, installed_at FROM skills ORDER BY directory ASC, app_type ASC")
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
FROM skills ORDER BY name ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let skill_iter = stmt
.query_map([], |row| {
let directory: String = row.get(0)?;
let app_type: String = row.get(1)?;
let installed: bool = row.get(2)?;
let installed_at_ts: i64 = row.get(3)?;
let installed_at =
chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default();
// 构建复合 key"app_type:directory"
let key = format!("{app_type}:{directory}");
Ok((
key,
SkillState {
installed,
installed_at,
Ok(InstalledSkill {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
directory: row.get(3)?,
repo_owner: row.get(4)?,
repo_name: row.get(5)?,
repo_branch: row.get(6)?,
readme_url: row.get(7)?,
apps: SkillApps {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
},
))
installed_at: row.get(11)?,
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
let mut skills = IndexMap::new();
for skill_res in skill_iter {
let (key, skill) = skill_res.map_err(|e| AppError::Database(e.to_string()))?;
skills.insert(key, skill);
let skill = skill_res.map_err(|e| AppError::Database(e.to_string()))?;
skills.insert(skill.id.clone(), skill);
}
Ok(skills)
}
/// 更新 Skill 状态
/// key 格式为 "app_type:directory"
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> {
// 解析 key
let (app_type, directory) = if let Some(idx) = key.find(':') {
let (app, dir) = key.split_at(idx);
(app, &dir[1..]) // 跳过冒号
} else {
// 向后兼容:如果没有前缀,默认为 claude
("claude", key)
};
/// 获取单个已安装的 Skill
pub fn get_installed_skill(&self, id: &str) -> Result<Option<InstalledSkill>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
FROM skills WHERE id = ?1",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let result = stmt.query_row([id], |row| {
Ok(InstalledSkill {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
directory: row.get(3)?,
repo_owner: row.get(4)?,
repo_name: row.get(5)?,
repo_branch: row.get(6)?,
readme_url: row.get(7)?,
apps: SkillApps {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
},
installed_at: row.get(11)?,
})
});
match result {
Ok(skill) => Ok(Some(skill)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 保存 Skill(添加或更新)
pub fn save_skill(&self, skill: &InstalledSkill) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)",
params![directory, app_type, state.installed, state.installed_at.timestamp()],
"INSERT OR REPLACE INTO skills
(id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
skill.id,
skill.name,
skill.description,
skill.directory,
skill.repo_owner,
skill.repo_name,
skill.repo_branch,
skill.readme_url,
skill.apps.claude,
skill.apps.codex,
skill.apps.gemini,
skill.installed_at,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 删除 Skill
pub fn delete_skill(&self, id: &str) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let affected = conn
.execute("DELETE FROM skills WHERE id = ?1", params![id])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
}
/// 清空所有 Skills(用于迁移)
pub fn clear_skills(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute("DELETE FROM skills", [])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 更新 Skill 的应用启用状态
pub fn update_skill_apps(&self, id: &str, apps: &SkillApps) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let affected = conn
.execute(
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3 WHERE id = ?4",
params![apps.claude, apps.codex, apps.gemini, id],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
}
// ========== SkillRepo CRUD(保持原有) ==========
/// 获取所有 Skill 仓库
pub fn get_skill_repos(&self) -> Result<Vec<SkillRepo>, AppError> {
let conn = lock_conn!(self.conn);
@@ -101,7 +184,8 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled) VALUES (?1, ?2, ?3, ?4)",
params![repo.owner, repo.name, repo.branch, repo.enabled],
).map_err(|e| AppError::Database(e.to_string()))?;
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
@@ -0,0 +1,74 @@
//! 统一供应商 (Universal Provider) DAO
//!
//! 提供统一供应商的 CRUD 操作。
use crate::database::{lock_conn, to_json_string, Database};
use crate::error::AppError;
use crate::provider::UniversalProvider;
use std::collections::HashMap;
/// 统一供应商的 Settings Key
const UNIVERSAL_PROVIDERS_KEY: &str = "universal_providers";
impl Database {
/// 获取所有统一供应商
pub fn get_all_universal_providers(
&self,
) -> Result<HashMap<String, UniversalProvider>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT value FROM settings WHERE key = ?")
.map_err(|e| AppError::Database(e.to_string()))?;
let result: Option<String> = stmt
.query_row([UNIVERSAL_PROVIDERS_KEY], |row| row.get(0))
.ok();
match result {
Some(json) => serde_json::from_str(&json)
.map_err(|e| AppError::Database(format!("解析统一供应商数据失败: {e}"))),
None => Ok(HashMap::new()),
}
}
/// 获取单个统一供应商
pub fn get_universal_provider(&self, id: &str) -> Result<Option<UniversalProvider>, AppError> {
let providers = self.get_all_universal_providers()?;
Ok(providers.get(id).cloned())
}
/// 保存统一供应商(添加或更新)
pub fn save_universal_provider(&self, provider: &UniversalProvider) -> Result<(), AppError> {
let mut providers = self.get_all_universal_providers()?;
providers.insert(provider.id.clone(), provider.clone());
self.save_all_universal_providers(&providers)
}
/// 删除统一供应商
pub fn delete_universal_provider(&self, id: &str) -> Result<bool, AppError> {
let mut providers = self.get_all_universal_providers()?;
let existed = providers.remove(id).is_some();
if existed {
self.save_all_universal_providers(&providers)?;
}
Ok(existed)
}
/// 保存所有统一供应商(内部方法)
fn save_all_universal_providers(
&self,
providers: &HashMap<String, UniversalProvider>,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
let json = to_json_string(providers)?;
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
[UNIVERSAL_PROVIDERS_KEY, &json],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
}
+10 -7
View File
@@ -192,13 +192,16 @@ impl Database {
tx: &rusqlite::Transaction<'_>,
config: &MultiAppConfig,
) -> Result<(), AppError> {
for (key, state) in &config.skills.skills {
tx.execute(
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
params![key, state.installed, state.installed_at.timestamp()],
)
.map_err(|e| AppError::Database(format!("Migrate skill failed: {e}")))?;
}
// v3.10.0+Skills 的 SSOT 已迁移到文件系统(~/.cc-switch/skills/+ 数据库统一结构。
//
// 旧版 config.json 里的 `skills.skills` 仅记录“安装状态”,但不包含完整元数据,
// 且无法保证 SSOT 目录中一定存在对应的 skill 文件。
//
// 因此这里不再直接把旧的安装状态写入新 skills 表,避免产生“数据库显示已安装但文件缺失”的不一致。
// 迁移后可通过:
// - 前端「导入已有」(扫描各应用的 skills 目录并复制到 SSOT)
// - 或后续启动时的自动扫描逻辑
// 来重建已安装技能记录。
for repo in &config.skills.repos {
tx.execute(
+1 -1
View File
@@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 2;
pub(crate) const SCHEMA_VERSION: i32 = 3;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
File diff suppressed because it is too large Load Diff
+241 -8
View File
@@ -6,7 +6,7 @@ use super::*;
use crate::app_config::MultiAppConfig;
use crate::provider::{Provider, ProviderManager};
use indexmap::IndexMap;
use rusqlite::Connection;
use rusqlite::{params, Connection};
use serde_json::json;
use std::collections::HashMap;
@@ -51,9 +51,76 @@ const LEGACY_SCHEMA_SQL: &str = r#"
);
"#;
// v3.8.xschema v1)的真实表结构快照:用于验证从 v3.8.* 升级到当前版本的迁移链路
// 参考:tag v3.8.3 的 src-tauri/src/database/schema.rs
const V3_8_SCHEMA_V1_SQL: &str = r#"
CREATE TABLE providers (
id TEXT NOT NULL,
app_type TEXT NOT NULL,
name TEXT NOT NULL,
settings_config TEXT NOT NULL,
website_url TEXT,
category TEXT,
created_at INTEGER,
sort_index INTEGER,
notes TEXT,
icon TEXT,
icon_color TEXT,
meta TEXT NOT NULL DEFAULT '{}',
is_current BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (id, app_type)
);
CREATE TABLE provider_endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
url TEXT NOT NULL,
added_at INTEGER,
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
);
CREATE TABLE mcp_servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
server_config TEXT NOT NULL,
description TEXT,
homepage TEXT,
docs TEXT,
tags TEXT NOT NULL DEFAULT '[]',
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
);
CREATE TABLE prompts (
id TEXT NOT NULL,
app_type TEXT NOT NULL,
name TEXT NOT NULL,
content TEXT NOT NULL,
description TEXT,
enabled BOOLEAN NOT NULL DEFAULT 1,
created_at INTEGER,
updated_at INTEGER,
PRIMARY KEY (id, app_type)
);
CREATE TABLE skills (
key TEXT PRIMARY KEY,
installed BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE skill_repos (
owner TEXT NOT NULL,
name TEXT NOT NULL,
branch TEXT NOT NULL DEFAULT 'main',
enabled BOOLEAN NOT NULL DEFAULT 1,
PRIMARY KEY (owner, name)
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT
);
"#;
#[derive(Debug)]
struct ColumnInfo {
name: String,
r#type: String,
notnull: i64,
default: Option<String>,
@@ -65,10 +132,9 @@ fn get_column_info(conn: &Connection, table: &str, column: &str) -> ColumnInfo {
.expect("prepare pragma");
let mut rows = stmt.query([]).expect("query pragma");
while let Some(row) = rows.next().expect("read row") {
let name: String = row.get(1).expect("name");
if name.eq_ignore_ascii_case(column) {
let column_name: String = row.get(1).expect("name");
if column_name.eq_ignore_ascii_case(column) {
return ColumnInfo {
name,
r#type: row.get::<_, String>(2).expect("type"),
notnull: row.get::<_, i64>(3).expect("notnull"),
default: row.get::<_, Option<String>>(4).ok().flatten(),
@@ -201,6 +267,171 @@ fn migration_aligns_column_defaults_and_types() {
);
}
#[test]
fn create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
let conn = Connection::open_in_memory().expect("open memory db");
// 模拟测试版 v2user_version=2,但 proxy_config 仍是单例结构(无 app_type
Database::set_user_version(&conn, 2).expect("set user_version");
conn.execute_batch(
r#"
CREATE TABLE proxy_config (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 5000,
max_retries INTEGER NOT NULL DEFAULT 3,
request_timeout INTEGER NOT NULL DEFAULT 300,
enable_logging INTEGER NOT NULL DEFAULT 1,
target_app TEXT NOT NULL DEFAULT 'claude',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO proxy_config (id, enabled) VALUES (1, 1);
"#,
)
.expect("seed legacy proxy_config");
Database::create_tables_on_conn(&conn).expect("create tables should repair proxy_config");
assert!(
Database::has_column(&conn, "proxy_config", "app_type").expect("check app_type"),
"proxy_config should be migrated to per-app structure"
);
let count: i32 = conn
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
.expect("count rows");
assert_eq!(count, 3, "per-app proxy_config should have 3 rows");
// 新结构下应能按 app_type 查询
let _: i32 = conn
.query_row(
"SELECT COUNT(*) FROM proxy_config WHERE app_type = 'claude'",
[],
|r| r.get(0),
)
.expect("query by app_type");
}
#[test]
fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
let conn = Connection::open_in_memory().expect("open memory db");
conn.execute("PRAGMA foreign_keys = ON;", [])
.expect("enable foreign keys");
// 模拟 v3.8.* 用户的数据库(schema v1
conn.execute_batch(V3_8_SCHEMA_V1_SQL)
.expect("seed v3.8 schema v1");
Database::set_user_version(&conn, 1).expect("set user_version=1");
// 插入一条旧版 Provider + Skill(用于验证迁移不会破坏既有数据)
conn.execute(
"INSERT INTO providers (
id, app_type, name, settings_config, website_url, category,
created_at, sort_index, notes, icon, icon_color, meta, is_current
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![
"p1",
"claude",
"Test Provider",
serde_json::to_string(&json!({ "anthropicApiKey": "sk-test" })).unwrap(),
Option::<String>::None,
Option::<String>::None,
Option::<i64>::None,
Option::<usize>::None,
Option::<String>::None,
Option::<String>::None,
Option::<String>::None,
"{}",
1,
],
)
.expect("seed provider");
conn.execute(
"INSERT INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
params!["claude:demo-skill", 1, 1700000000i64],
)
.expect("seed legacy skill");
// 按应用启动流程:先 create_tables(补齐新增表),再 apply_schema_migrations(按 user_version 迁移)
Database::create_tables_on_conn(&conn).expect("create tables");
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
assert_eq!(
Database::get_user_version(&conn).expect("user_version after migration"),
SCHEMA_VERSION
);
// v1 -> v2providers 新增字段必须补齐
for column in [
"cost_multiplier",
"limit_daily_usd",
"limit_monthly_usd",
"provider_type",
"in_failover_queue",
] {
assert!(
Database::has_column(&conn, "providers", column).expect("check column"),
"providers.{column} should exist after migration"
);
}
// 旧 provider 不应丢失,且新增字段应有默认值
let provider_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM providers WHERE id = 'p1' AND app_type = 'claude'",
[],
|r| r.get(0),
)
.expect("count providers");
assert_eq!(provider_count, 1);
let cost_multiplier: String = conn
.query_row(
"SELECT cost_multiplier FROM providers WHERE id = 'p1' AND app_type = 'claude'",
[],
|r| r.get(0),
)
.expect("read cost_multiplier");
assert_eq!(cost_multiplier, "1.0");
// v2 -> v3skills 表重建为统一结构,并设置 pending 标记(后续由启动时扫描文件系统重建数据)
assert!(
Database::has_column(&conn, "skills", "enabled_claude").expect("check skills v3 column"),
"skills table should be migrated to v3 structure"
);
let skills_count: i64 = conn
.query_row("SELECT COUNT(*) FROM skills", [], |r| r.get(0))
.expect("count skills");
assert_eq!(skills_count, 0, "skills table should be rebuilt empty");
let pending: Option<String> = conn
.query_row(
"SELECT value FROM settings WHERE key = 'skills_ssot_migration_pending'",
[],
|r| r.get(0),
)
.ok();
assert!(
matches!(pending.as_deref(), Some("true") | Some("1")),
"skills_ssot_migration_pending should be set after v2->v3 migration"
);
// v3.9+ 新增:proxy_config 三行 seed 必须存在(否则 UI 会查不到默认值)
let proxy_rows: i64 = conn
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
.expect("count proxy_config rows");
assert_eq!(proxy_rows, 3);
// model_pricing 应具备默认数据(迁移时会 seed)
let pricing_rows: i64 = conn
.query_row("SELECT COUNT(*) FROM model_pricing", [], |r| r.get(0))
.expect("count model_pricing rows");
assert!(pricing_rows > 0, "model_pricing should be seeded");
}
#[test]
fn dry_run_does_not_write_to_disk() {
// Create minimal valid config for migration
@@ -245,12 +476,14 @@ fn dry_run_validates_schema_compatibility() {
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
},
);
let mut manager = ProviderManager::default();
manager.providers = providers;
manager.current = "test-provider".to_string();
let manager = ProviderManager {
providers,
current: "test-provider".to_string(),
};
let mut apps = HashMap::new();
apps.insert("claude".to_string(), manager);
+1
View File
@@ -132,6 +132,7 @@ pub(crate) fn build_provider_from_request(
meta,
icon: request.icon.clone(),
icon_color: None,
in_failover_queue: false,
};
Ok(provider)
+3 -3
View File
@@ -375,7 +375,7 @@ fn test_parse_prompt_deeplink() {
assert_eq!(request.name.unwrap(), "test");
assert_eq!(request.content.unwrap(), content_b64);
assert_eq!(request.description.unwrap(), "desc");
assert_eq!(request.enabled.unwrap(), true);
assert!(request.enabled.unwrap());
}
#[test]
@@ -391,13 +391,13 @@ fn test_parse_mcp_deeplink() {
assert_eq!(request.resource, "mcp");
assert_eq!(request.apps.unwrap(), "claude,codex");
assert_eq!(request.config.unwrap(), config_b64);
assert_eq!(request.enabled.unwrap(), true);
assert!(request.enabled.unwrap());
}
#[test]
fn test_parse_skill_deeplink() {
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev";
let request = parse_deeplink_url(&url).unwrap();
let request = parse_deeplink_url(url).unwrap();
assert_eq!(request.resource, "skill");
assert_eq!(request.repo.unwrap(), "owner/repo");
+4
View File
@@ -52,6 +52,10 @@ pub enum AppError {
},
#[error("数据库错误: {0}")]
Database(String),
#[error("所有供应商已熔断,无可用渠道")]
AllProvidersCircuitOpen,
#[error("未配置供应商")]
NoProvidersConfigured,
}
impl AppError {
+11 -4
View File
@@ -42,8 +42,8 @@ fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {
///
/// 执行反向格式转换以保持与统一 MCP 结构的兼容性:
/// - httpUrl → url + type: "http"
/// - 仅有 url 字段 → 保持不变(SSE 类型)
/// - 仅有 command 字段 → 保持不变(stdio 类型)
/// - 仅有 url 字段 → 补齐 type: "sse"Gemini 以字段名推断传输类型)
/// - 仅有 command 字段 → 补齐 type: "stdio"
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
let path = user_config_path();
if !path.exists() {
@@ -65,8 +65,15 @@ pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>
obj.insert("url".to_string(), http_url);
obj.insert("type".to_string(), Value::String("http".to_string()));
}
// 如果有 url 但没有 type,不添加 type(默认为 SSE
// 如果有 command 但没有 type,不添加 type(默认为 stdio
// Gemini CLI 不使用 type 字段:这里补齐成统一结构,便于校验与导入
if obj.get("type").is_none() {
if obj.contains_key("command") {
obj.insert("type".to_string(), Value::String("stdio".to_string()));
} else if obj.contains_key("url") {
obj.insert("type".to_string(), Value::String("sse".to_string()));
}
}
}
}
+41
View File
@@ -52,6 +52,47 @@ pub fn take_migration_success() -> bool {
}
}
// ============================================================
// Skills SSOT 迁移结果状态
// ============================================================
#[derive(Debug, Clone, Serialize)]
pub struct SkillsMigrationPayload {
pub count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
static SKILLS_MIGRATION_RESULT: OnceLock<RwLock<Option<SkillsMigrationPayload>>> = OnceLock::new();
fn skills_migration_cell() -> &'static RwLock<Option<SkillsMigrationPayload>> {
SKILLS_MIGRATION_RESULT.get_or_init(|| RwLock::new(None))
}
pub fn set_skills_migration_result(count: usize) {
if let Ok(mut guard) = skills_migration_cell().write() {
*guard = Some(SkillsMigrationPayload { count, error: None });
}
}
pub fn set_skills_migration_error(error: String) {
if let Ok(mut guard) = skills_migration_cell().write() {
*guard = Some(SkillsMigrationPayload {
count: 0,
error: Some(error),
});
}
}
/// 获取并消费 Skills 迁移结果(只返回一次 Some,之后返回 None)
pub fn take_skills_migration_result() -> Option<SkillsMigrationPayload> {
if let Ok(mut guard) = skills_migration_cell().write() {
guard.take()
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
+311 -117
View File
@@ -48,8 +48,9 @@ use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use std::sync::Arc;
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
#[cfg(target_os = "macos")]
use tauri::image::Image;
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
use tauri::RunEvent;
use tauri::{Emitter, Manager};
@@ -134,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();
@@ -209,44 +223,6 @@ pub fn run() {
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
}
}
#[cfg(target_os = "macos")]
{
// 设置 macOS 标题栏背景色为主界面蓝色
if let Some(window) = app.get_webview_window("main") {
use objc2::rc::Retained;
use objc2::runtime::AnyObject;
use objc2_app_kit::NSColor;
match window.ns_window() {
Ok(ns_window_ptr) => {
if let Some(ns_window) =
unsafe { Retained::retain(ns_window_ptr as *mut AnyObject) }
{
// 使用与主界面 banner 相同的蓝色 #3498db
// #3498db = RGB(52, 152, 219)
let bg_color = unsafe {
NSColor::colorWithRed_green_blue_alpha(
52.0 / 255.0, // R: 52
152.0 / 255.0, // G: 152
219.0 / 255.0, // B: 219
1.0, // Alpha: 1.0
)
};
unsafe {
use objc2::msg_send;
let _: () =
msg_send![&*ns_window, setBackgroundColor: &*bg_color];
}
} else {
log::warn!("Failed to retain NSWindow reference");
}
}
Err(e) => log::warn!("Failed to get NSWindow pointer: {e}"),
}
}
}
// 初始化日志
if cfg!(debug_assertions) {
app.handle().plugin(
@@ -297,12 +273,25 @@ pub fn run() {
None
};
// 现在创建数据库
let db = match crate::database::Database::init() {
Ok(db) => Arc::new(db),
Err(e) => {
log::error!("Failed to init database: {e}");
return Err(Box::new(e));
// 现在创建数据库(包含 Schema 迁移)
//
// 说明:从 v3.8.* 升级的用户通常会走到这里的 SQLite schema 迁移,
// 若迁移失败(数据库损坏/权限不足/user_version 过新等),需要给用户明确提示,
// 否则表现可能只是“应用打不开/闪退”。
let db = loop {
match crate::database::Database::init() {
Ok(db) => break Arc::new(db),
Err(e) => {
log::error!("Failed to init database: {e}");
if !show_database_init_error_dialog(app.handle(), &db_path, &e.to_string())
{
log::info!("用户选择退出程序");
std::process::exit(1);
}
log::info!("用户选择重试初始化数据库");
}
}
};
@@ -348,6 +337,47 @@ pub fn run() {
Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"),
}
// 1.1. Skills 统一管理迁移:当数据库迁移到 v3 结构后,自动从各应用目录导入到 SSOT
// 触发条件由 schema 迁移设置 settings.skills_ssot_migration_pending = true 控制。
match app_state.db.get_setting("skills_ssot_migration_pending") {
Ok(Some(flag)) if flag == "true" || flag == "1" => {
// 安全保护:如果用户已经有 v3 结构的 Skills 数据,就不要自动清空重建。
let has_existing = app_state
.db
.get_all_installed_skills()
.map(|skills| !skills.is_empty())
.unwrap_or(false);
if has_existing {
log::info!(
"Detected skills_ssot_migration_pending but skills table not empty; skipping auto import."
);
let _ = app_state
.db
.set_setting("skills_ssot_migration_pending", "false");
} else {
match crate::services::skill::migrate_skills_to_ssot(&app_state.db) {
Ok(count) => {
log::info!("✓ Auto imported {count} skill(s) into SSOT");
if count > 0 {
crate::init_status::set_skills_migration_result(count);
}
let _ = app_state
.db
.set_setting("skills_ssot_migration_pending", "false");
}
Err(e) => {
log::warn!("✗ Failed to auto import legacy skills to SSOT: {e}");
crate::init_status::set_skills_migration_error(e.to_string());
// 保留 pending 标志,方便下次启动重试
}
}
}
}
Ok(_) => {} // 未开启迁移标志,静默跳过
Err(e) => log::warn!("✗ Failed to read skills migration flag: {e}"),
}
// 2. 导入供应商配置(已有内置检查:该应用已有供应商则跳过)
for app in [
crate::app_config::AppType::Claude,
@@ -360,6 +390,27 @@ pub fn run() {
) {
Ok(true) => {
log::info!("✓ Imported default provider for {}", app.as_str());
// 首次运行:自动提取通用配置片段(仅当通用配置为空时)
if app_state
.db
.get_config_snippet(app.as_str())
.ok()
.flatten()
.is_none()
{
match crate::services::provider::ProviderService::extract_common_config_snippet(&app_state, app.clone()) {
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
if let Err(e) = app_state.db.set_config_snippet(app.as_str(), Some(snippet)) {
log::warn!("✗ Failed to save common config snippet for {}: {e}", app.as_str());
} else {
log::info!("✓ Extracted common config snippet for {}", app.as_str());
}
}
Ok(_) => log::debug!("○ No common config to extract for {}", app.as_str()),
Err(e) => log::debug!("○ Failed to extract common config for {}: {e}", app.as_str()),
}
}
}
Ok(false) => {} // 已有供应商,静默跳过
Err(e) => {
@@ -504,11 +555,26 @@ pub fn run() {
})
.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)?;
@@ -516,58 +582,36 @@ pub fn run() {
app.manage(app_state);
// 初始化 SkillService
match SkillService::new() {
Ok(skill_service) => {
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
}
Err(e) => {
log::warn!("初始化 SkillService 失败: {e}");
}
}
let skill_service = SkillService::new();
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
// 异常退出恢复 + 自动启动代理服务器
// 异常退出恢复 + 代理状态自动恢复
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let state = app_handle.state::<AppState>();
// 1. 检测异常退出并恢复 Live 配置
match state.db.is_live_takeover_active().await {
Ok(true) => {
// 接管标志为 true 但代理未运行 → 上次异常退出
if !state.proxy_service.is_running().await {
log::warn!("检测到上次异常退出,正在恢复 Live 配置...");
if let Err(e) = state.proxy_service.recover_from_crash().await {
log::error!("恢复 Live 配置失败: {e}");
} else {
log::info!("Live 配置已从异常退出中恢复");
}
}
}
Ok(false) => {
// 正常状态,无需恢复
}
// 检查是否有 Live 备份(表示上次异常退出时可能处于接管状态)
let has_backups = match state.db.has_any_live_backup().await {
Ok(v) => v,
Err(e) => {
log::error!("检查接管状态失败: {e}");
log::error!("检查 Live 备份失败: {e}");
false
}
};
// 检查 Live 配置是否仍处于被接管状态(包含占位符)
let live_taken_over = state.proxy_service.detect_takeover_in_live_configs();
if has_backups || live_taken_over {
log::warn!("检测到上次异常退出(存在接管残留),正在恢复 Live 配置...");
if let Err(e) = state.proxy_service.recover_from_crash().await {
log::error!("恢复 Live 配置失败: {e}");
} else {
log::info!("Live 配置已恢复");
}
}
// 2. 自动启动代理服务器(如果配置为启用)
match state.db.get_proxy_config().await {
Ok(config) => {
if config.enabled {
log::info!("代理服务配置为启用,正在启动...");
match state.proxy_service.start_with_takeover().await {
Ok(info) => log::info!(
"代理服务器自动启动成功: {}:{}",
info.address,
info.port
),
Err(e) => log::error!("代理服务器自动启动失败: {e}"),
}
}
}
Err(e) => log::error!("启动时获取代理配置失败: {e}"),
}
// 检查 settings 表中的代理状态,自动恢复代理服务
restore_proxy_state_on_startup(&state).await;
});
Ok(())
@@ -589,12 +633,14 @@ pub fn run() {
commands::open_external,
commands::get_init_error,
commands::get_migration_result,
commands::get_skills_migration_result,
commands::get_app_config_path,
commands::open_app_config_folder,
commands::get_claude_common_config_snippet,
commands::set_claude_common_config_snippet,
commands::get_common_config_snippet,
commands::set_common_config_snippet,
commands::extract_common_config_snippet,
commands::read_live_provider_settings,
commands::get_settings,
commands::save_settings,
@@ -605,6 +651,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,
@@ -619,11 +667,12 @@ pub fn run() {
commands::upsert_mcp_server_in_config,
commands::delete_mcp_server_in_config,
commands::set_mcp_enabled,
// v3.7.0: Unified MCP management
// Unified MCP management
commands::get_mcp_servers,
commands::upsert_mcp_server,
commands::delete_mcp_server,
commands::toggle_mcp_app,
commands::import_mcp_from_apps,
// Prompt management
commands::get_prompts,
commands::upsert_prompt,
@@ -658,7 +707,15 @@ pub fn run() {
commands::check_env_conflicts,
commands::delete_env_vars,
commands::restore_env_backup,
// Skill management
// Skill management (v3.10.0+ unified)
commands::get_installed_skills,
commands::install_skill_unified,
commands::uninstall_skill_unified,
commands::toggle_skill_app,
commands::scan_unmanaged_skills,
commands::import_skills_from_apps,
commands::discover_available_skills,
// Skill management (legacy API compatibility)
commands::get_skills,
commands::get_skills_for_app,
commands::install_skill,
@@ -672,11 +729,18 @@ pub fn run() {
commands::set_auto_launch,
commands::get_auto_launch_status,
// Proxy server management
commands::start_proxy_with_takeover,
commands::start_proxy_server,
commands::stop_proxy_with_restore,
commands::get_proxy_takeover_status,
commands::set_proxy_takeover_for_app,
commands::get_proxy_status,
commands::get_proxy_config,
commands::update_proxy_config,
// Global & Per-App Config
commands::get_global_proxy_config,
commands::update_global_proxy_config,
commands::get_proxy_config_for_app,
commands::update_proxy_config_for_app,
commands::is_proxy_running,
commands::is_live_takeover_active,
commands::switch_proxy_provider,
@@ -691,8 +755,8 @@ pub fn run() {
commands::get_available_providers_for_failover,
commands::add_to_failover_queue,
commands::remove_from_failover_queue,
commands::reorder_failover_queue,
commands::set_failover_item_enabled,
commands::get_auto_failover_enabled,
commands::set_auto_failover_enabled,
// Usage statistics
commands::get_usage_summary,
commands::get_usage_trends,
@@ -710,6 +774,12 @@ pub fn run() {
commands::get_stream_check_config,
commands::save_stream_check_config,
commands::get_tool_versions,
// Universal Provider management
commands::get_universal_providers,
commands::get_universal_provider,
commands::upsert_universal_provider,
commands::delete_universal_provider,
commands::sync_universal_provider,
]);
let app = builder
@@ -824,32 +894,91 @@ pub fn run() {
///
/// 在应用退出前检查代理服务器状态,如果正在运行则停止代理并恢复 Live 配置。
/// 确保 Claude Code/Codex/Gemini 的配置不会处于损坏状态。
/// 使用 stop_with_restore_keep_state 保留 settings 表中的代理状态,下次启动时自动恢复。
pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
if let Some(state) = app_handle.try_state::<store::AppState>() {
let proxy_service = &state.proxy_service;
// 检查代理是否在运行
if proxy_service.is_running().await {
log::info!("检测到代理服务器正在运行,开始清理...");
// 退出时也需要兜底:代理可能已崩溃/未运行,但 Live 接管残留仍在(占位符/备份)。
let has_backups = match state.db.has_any_live_backup().await {
Ok(v) => v,
Err(e) => {
log::error!("退出时检查 Live 备份失败: {e}");
false
}
};
let live_taken_over = proxy_service.detect_takeover_in_live_configs();
let needs_restore = has_backups || live_taken_over;
// 检查是否处于 Live 接管模式
if let Ok(is_takeover) = state.db.is_live_takeover_active().await {
if is_takeover {
// 接管模式:停止并恢复配置
if let Err(e) = proxy_service.stop_with_restore().await {
log::error!("退出时恢复 Live 配置失败: {e}");
} else {
log::info!("已恢复 Live 配置");
}
} else {
// 非接管模式:仅停止代理
if let Err(e) = proxy_service.stop().await {
log::error!("退出时停止代理失败: {e}");
}
if needs_restore {
log::info!("检测到接管残留,开始恢复 Live 配置(保留代理状态)...");
// 使用 keep_state 版本,保留 settings 表中的代理状态
if let Err(e) = proxy_service.stop_with_restore_keep_state().await {
log::error!("退出时恢复 Live 配置失败: {e}");
} else {
log::info!("已恢复 Live 配置(代理状态已保留,下次启动将自动恢复)");
}
return;
}
// 非接管模式:代理在运行则仅停止代理
if proxy_service.is_running().await {
log::info!("检测到代理服务器正在运行,开始停止...");
if let Err(e) = proxy_service.stop().await {
log::error!("退出时停止代理失败: {e}");
}
log::info!("代理服务器清理完成");
}
}
}
// ============================================================
// 启动时恢复代理状态
// ============================================================
/// 启动时根据 proxy_config 表中的代理状态自动恢复代理服务
///
/// 检查 `proxy_config.enabled` 字段,如果有任一应用的状态为 `true`,
/// 则自动启动代理服务并接管对应应用的 Live 配置。
async fn restore_proxy_state_on_startup(state: &store::AppState) {
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
let mut apps_to_restore = Vec::new();
for app_type in ["claude", "codex", "gemini"] {
if let Ok(config) = state.db.get_proxy_config_for_app(app_type).await {
if config.enabled {
apps_to_restore.push(app_type);
}
}
}
if apps_to_restore.is_empty() {
log::debug!("启动时无需恢复代理状态");
return;
}
log::info!("检测到上次代理状态需要恢复,应用列表: {apps_to_restore:?}");
// 逐个恢复接管状态
for app_type in apps_to_restore {
match state
.proxy_service
.set_takeover_for_app(app_type, true)
.await
{
Ok(()) => {
log::info!("✓ 已恢复 {app_type} 的代理接管状态");
}
Err(e) => {
log::error!("✗ 恢复 {app_type} 的代理接管状态失败: {e}");
// 失败时清除该应用的状态,避免下次启动再次尝试
if let Err(clear_err) = state
.proxy_service
.set_takeover_for_app(app_type, false)
.await
{
log::error!("清除 {app_type} 代理状态失败: {clear_err}");
}
}
log::info!("代理服务器清理完成");
}
}
}
@@ -917,3 +1046,68 @@ fn show_migration_error_dialog(app: &tauri::AppHandle, error: &str) -> bool {
))
.blocking_show()
}
/// 显示数据库初始化/Schema 迁移失败对话框
/// 返回 true 表示用户选择重试,false 表示用户选择退出
fn show_database_init_error_dialog(
app: &tauri::AppHandle,
db_path: &std::path::Path,
error: &str,
) -> bool {
let title = if is_chinese_locale() {
"数据库初始化失败"
} else {
"Database Initialization Failed"
};
let message = if is_chinese_locale() {
format!(
"初始化数据库或迁移数据库结构时发生错误:\n\n{error}\n\n\
数据库文件路径:\n{db}\n\n\
您的数据尚未丢失,应用不会自动删除数据库文件。\n\
常见原因包括:数据库版本过新、文件损坏、权限不足、磁盘空间不足等。\n\n\
建议:\n\
1) 先备份整个配置目录(包含 cc-switch.db\n\
2) 如果提示“数据库版本过新”,请升级到更新版本\n\
3) 如果刚升级出现异常,可回退旧版本导出/备份后再升级\n\n\
点击「重试」重新尝试初始化\n\
点击「退出」关闭程序",
db = db_path.display()
)
} else {
format!(
"An error occurred while initializing or migrating the database:\n\n{error}\n\n\
Database file path:\n{db}\n\n\
Your data is NOT lost - the app will not delete the database automatically.\n\
Common causes include: newer database version, corrupted file, permission issues, or low disk space.\n\n\
Suggestions:\n\
1) Back up the entire config directory (including cc-switch.db)\n\
2) If you see “database version is newer”, please upgrade CC Switch\n\
3) If this happened right after upgrading, consider rolling back to export/backup then upgrade again\n\n\
Click 'Retry' to attempt initialization again\n\
Click 'Exit' to close the program",
db = db_path.display()
)
};
let retry_text = if is_chinese_locale() {
"重试"
} else {
"Retry"
};
let exit_text = if is_chinese_locale() {
"退出"
} else {
"Exit"
};
app.dialog()
.message(&message)
.title(title)
.kind(MessageDialogKind::Error)
.buttons(MessageDialogButtons::OkCancelCustom(
retry_text.to_string(),
exit_text.to_string(),
))
.blocking_show()
}
+15
View File
@@ -8,6 +8,12 @@ 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();
@@ -33,6 +39,9 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
/// 将 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)
}
@@ -107,6 +116,9 @@ pub fn sync_single_server_to_claude(
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
if !should_sync_claude_mcp() {
return Ok(());
}
// 读取现有的 MCP 配置
let current = crate::claude_mcp::read_mcp_servers_map()?;
@@ -120,6 +132,9 @@ pub fn sync_single_server_to_claude(
/// 从 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()?;

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