Compare commits

...

178 Commits

Author SHA1 Message Date
Jason db4a1ea907 refactor(preset): remove AiHubMix hardcoded API_KEY in favor of generic auth selector
AiHubMix was the only preset that hardcoded ANTHROPIC_API_KEY before the
generic auth field selector was introduced. Now that users can freely
choose between AUTH_TOKEN and API_KEY via the UI, remove the special-case
and default AiHubMix to the standard ANTHROPIC_AUTH_TOKEN.
2026-02-23 21:52:37 +08:00
Jason 58b7dc32b3 feat(provider): add auth field selector for Claude providers (AUTH_TOKEN / API_KEY)
Allow users to choose between ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY
when creating or editing custom Claude providers, persisted in meta.apiKeyField.
2026-02-23 21:52:37 +08:00
Jason 65ccabd5ad fix(provider): expand partial-merge key fields for Bedrock, Vertex, Foundry and behavior settings
Add missing env/top-level fields to CLAUDE_KEY_ENV_FIELDS and
CLAUDE_KEY_TOP_LEVEL so that provider switching correctly replaces
(and clears) credentials and flags for AWS Bedrock, Google Vertex AI,
Microsoft Foundry, and provider behavior overrides like max output
tokens and prompt caching.
2026-02-23 21:52:37 +08:00
Jason a3a81806f8 fix(provider): add missing key fields to partial-merge constants
Add provider-specific fields verified against official docs to prevent
key residue or loss during provider switching:

- Claude: CLAUDE_CODE_SUBAGENT_MODEL (env), model (top-level)
- Codex: review_model, plan_mode_reasoning_effort
- Gemini: GOOGLE_API_KEY (official alternative to GEMINI_API_KEY)
2026-02-23 21:52:37 +08:00
Jason 5104045ffb feat(claude): add Quick Toggles for common Claude Code preferences
Add checkbox toggles for hideAttribution, alwaysThinking, and
enableTeammates that write directly to the live settings file via
RFC 7396 JSON Merge Patch. Mirror changes to the form editor using
form.watch for reactive updates.
2026-02-23 21:52:37 +08:00
Jason 7898096de3 refactor(cleanup): remove dead code and redundant MCP sync after partial-merge refactor
- Remove ConfigService legacy full-overwrite sync methods (~150 lines)
- Remove redundant McpService::sync_all_enabled from switch_normal
- Switch proxy fallback recovery from write_live_snapshot to write_live_partial
- Remove dead ProviderService::write_gemini_live wrapper
- Update tests to reflect partial-merge behavior (MCP preserved, not re-synced)
2026-02-23 21:52:37 +08:00
Jason cc235c6c63 refactor(provider): switch from full config overwrite to partial key-field merging
Replace the provider switching mechanism for Claude/Codex/Gemini from
full settings_config overwrite to partial key-field replacement, preserving
user's non-provider settings (plugins, MCP, permissions, etc.) across switches.

- Add write_live_partial() with per-app implementations for Claude (JSON env
  merge), Codex (auth replace + TOML partial merge), and Gemini (env merge)
- Add backfill_key_fields() to extract only provider-specific fields when
  saving live config back to provider entries
- Update switch_normal, sync_current_to_live, add, update to use partial merge
- Remove common config snippet feature for Claude/Codex/Gemini (no longer
  needed with partial merging); preserve OMO common config
- Delete 6 frontend files (3 components + 3 hooks), clean up 11 modified files
- Remove backend extract_common_config_* methods, 3 Tauri commands,
  CommonConfigSnippets struct, and related migration code
- Update integration tests to validate key-field-only backfill behavior
2026-02-23 21:52:37 +08:00
Jason 1b20b7ff88 feat(backup): add hourly periodic backup timer during runtime
Previously periodic backup only checked on startup. Now spawns a
tokio interval task that checks every hour while the app is running.
2026-02-23 11:27:28 +08:00
Jason f8820aa22c feat(backup): add independent backup panel, configurable policy, and rename support
Extract backup & restore into a standalone AccordionItem in Advanced settings.
Add configurable auto-backup interval (disabled/6h/12h/24h/48h/7d) and retention
count (3-50) via settings. Add per-backup rename with inline editing UI.
2026-02-23 11:27:28 +08:00
Jason 3afec8a10f feat(backup): add pre-migration backup, periodic backup, backfill warning, and backup management UI
Four improvements to the database backup mechanism:

1. Auto backup before schema migration - creates a snapshot when
   upgrading from an older database version, providing a safety net
   beyond the existing SAVEPOINT rollback mechanism.

2. Periodic startup backup - checks on app launch whether the latest
   backup is older than 24 hours and creates a new one if needed,
   ensuring all users have recent backups regardless of usage patterns.

3. Backfill failure notification - switch now returns SwitchResult with
   warnings instead of silently ignoring backfill errors, so users are
   informed when their manual config changes may not have been saved.

4. Backup management UI - new BackupListSection in Settings > Data
   Management showing all backup snapshots with restore capability,
   including a confirmation dialog and automatic safety backup before
   restore.
2026-02-23 11:27:28 +08:00
Jason 5ebc879f09 feat(settings): add first-run confirmation dialogs for proxy and usage features
Prevent accidental activation of advanced features by showing a one-time
info dialog. Once confirmed, the flag is persisted in settings.json and
the dialog never appears again.

- Proxy: confirmation when toggling proxy server ON for the first time
- Usage: confirmation when enabling usage query inside UsageScriptModal
- Enhanced ConfirmDialog with "info" variant (blue icon + default button)
- Added i18n translations for zh, en, ja
2026-02-23 11:27:28 +08:00
Jason 6b4ba64bbd feat(toolbar): add smooth transition animation for AppSwitcher compact toggle
Replace conditional rendering with always-rendered span driven by CSS
max-width + opacity animation. Add time-lock in useAutoCompact to prevent
ResizeObserver flicker during expand animation.
2026-02-23 11:27:28 +08:00
Jason 5763b9094b fix(toolbar): remove height constraint that clipped AppSwitcher
The toolbar wrapper's fixed h-[32px] was smaller than AppSwitcher's
natural 40px height (32px buttons + 8px padding), causing visual
clipping. Removed the constraint and let flex layout handle sizing
within the 64px header.
2026-02-23 11:27:28 +08:00
Jason ce92f37ef0 feat(toolbar): auto-compact AppSwitcher based on available width
Replace hardcoded app count threshold with ResizeObserver-based
detection. Uses a two-layer layout (overflow-hidden outer + shrink-0
inner) to avoid the compact/normal oscillation problem.
2026-02-23 11:27:28 +08:00
Jason 2e676e5f53 feat(sessions): add session browsing for Gemini CLI 2026-02-23 11:27:28 +08:00
Jason c3f29a62d1 feat(sessions): default filter to current app when entering session page
Navigate directly to the active app's sessions instead of showing all.
2026-02-23 11:27:28 +08:00
Jason 165af5eec4 feat(sessions): add session browsing for OpenCode and OpenClaw
Add two new session providers following the existing convention-based
pattern. OpenCode reads three-layer JSON storage, OpenClaw parses JSONL
event streams. Wire up backend dispatch, frontend filter dropdown, icon
mappings, toolbar button for OpenClaw, and i18n subtitle updates.
2026-02-23 11:27:28 +08:00
Jason 8b9c09d994 feat(toolbar): add fade transition when switching between OpenClaw and other apps 2026-02-23 11:27:28 +08:00
Jason a8d391bd74 refactor(settings): split Data accordion into Import/Export and Cloud Sync 2026-02-23 11:27:28 +08:00
Jason 7d9b20721e refactor(settings): split Advanced tab into Proxy tab and move Pricing to Usage
Extract proxy-related accordion items (Local Proxy, Failover, Rectifier,
Global Outbound Proxy) into a dedicated Proxy tab via ProxyTabContent
component. Move Pricing config panel to UsageDashboard as a collapsible
accordion. This reduces SettingsPage from ~716 to ~426 lines and improves
settings discoverability with a 5-tab layout: General | Proxy | Advanced |
Usage | About.
2026-02-23 11:27:28 +08:00
Kelvin Chiu d11df17b5d feat: more granular local environment checks (#870)
* feat: more granular local environment checks

* refactor: improve PR #870 with i18n, shadcn Select, and testable helpers

- Extract is_valid_shell, is_valid_shell_flag, default_flag_for_shell
  to module-level #[cfg(windows)] functions for testability
- Add unit tests for extracted helper functions
- Replace native <select> with shadcn/ui Select components
- Extract env badge ternary to ENV_BADGE_CONFIG Record lookup
- Add i18n keys for env badges and WSL selectors (zh/en/ja)
- Unify initial useEffect load path with loadAllToolVersions()

* fix: prevent useEffect re-firing on wslShellByTool changes

The useEffect that loads initial tool versions depended on
loadAllToolVersions, which in turn depended on wslShellByTool.
This caused a full re-fetch of all 4 tools every time the user
changed a WSL shell or flag, racing with the single-tool refresh.

Fix: use empty deps [] since this is a mount-only effect. The
refresh button and shell/flag handlers cover subsequent updates.

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-02-23 11:26:23 +08:00
Jason 4c88174cb0 refactor(provider): replace startup auto-import with manual import button
Remove the startup loop in lib.rs that auto-imported default providers
for Claude/Codex/Gemini. Move config snippet extraction logic into the
import_default_config command so it works when triggered manually.

Add an "Import Current Config" button to ProviderEmptyState, wired via
useMutation in ProviderList (shown only for standard apps). Update i18n
keys (zh/en/ja) with new button labels and revised empty state text.
2026-02-20 22:01:18 +08:00
Jason 5a72888852 fix(query): remove auto-import side effect from useProvidersQuery queryFn
Users now trigger provider import manually via the empty state button,
keeping queryFn pure and preventing unintended imports on mount/refocus.
2026-02-20 21:57:45 +08:00
Jason 54f1bfb5d0 chore(tray): hide Auto Failover shortcut from tray menu
The Auto (Failover) toggle in the system tray is no longer shown.
The feature remains fully functional via the Settings page.
All related backend code (handle_auto_click, AUTO_SUFFIX, etc.)
is preserved for easy re-enablement.
2026-02-20 20:08:54 +08:00
Jason 4efab15174 refactor(openclaw): make primary model read-only in Agents panel
The primary model field in the Agents defaults panel now displays as
read-only, eliminating the duplicate edit entry with the "Set as Default
Model" button on provider cards. Fallback models and runtime parameters
remain editable.
2026-02-20 16:46:02 +08:00
Jason 357d32c17e fix(i18n): clarify OpenClaw default model button labels
Rename "Enable/Default" to "Set Default/Current Default" across
zh/en/ja locales and widen button to fit longer text.
2026-02-20 11:11:10 +08:00
Jason 7532308519 fix(workspace): sort daily memory by date and inline into file grid
- Sort daily memory list by filename (YYYY-MM-DD.md) instead of mtime
  so editing an older file no longer bumps it to the top
- Move daily memory card inline with workspace MD file buttons
- Shorten card description across all locales
- Defer file creation until user actually saves (no empty file on create)
2026-02-20 10:40:58 +08:00
Jason 4440a14082 chore(ui): exclude OpenClaw from MCP & Skills app toggle lists 2026-02-20 10:40:48 +08:00
Jason d1bb4480db feat(workspace): add daily memory file management for OpenClaw
Add browse, edit, create and delete support for daily memory files
(~/.openclaw/workspace/memory/YYYY-MM-DD.md) in the Workspace panel.
2026-02-20 08:54:53 +08:00
Jason d04c279890 chore(ui): hide proxy/pricing config for OpenCode/OpenClaw providers 2026-02-19 23:34:20 +08:00
Jason 3125b5419c chore(ui): hide stream check / model test config panels
The stream check feature is unreliable due to diverse provider request
formats. Comment out the model test config UI in settings page, provider
advanced config, and the test button in provider actions. Backend code
and i18n keys are preserved for future restoration.
2026-02-19 23:19:44 +08:00
Jason 0fa6b33b5e feat(settings): add enableLocalProxy toggle to gate main page proxy UI
New users often accidentally trigger ProxyToggle/FailoverToggle on the
main page. Add a settings toggle (default off) so the proxy controls
only appear when explicitly enabled. The proxy service start/stop in
settings remains independent of this visibility flag.
2026-02-19 23:06:22 +08:00
Jason 1b71dc721c refactor(omo): deduplicate OMO/OMO Slim via OmoVariant parameterization
Introduce OmoVariant struct with STANDARD/SLIM constants to eliminate
~250 lines of copy-pasted code across DAO, service, commands, and
frontend layers. Adding a new OMO variant now requires only a single
const declaration instead of duplicating ~400 lines.
2026-02-19 21:11:58 +08:00
Jason 8e219b5eb1 feat(omo): add OMO Slim (oh-my-opencode-slim) support
Implement full OMO Slim profile management to align with ai-toolbox:
- Backend: Slim service methods, DAO, Tauri commands, plugin conflict handling
- Frontend: types, API, query hooks, form integration with isSlim parameterization
- Slim variant: 6 agents (no categories), separate config file and plugin name
- Mutual exclusion: standard OMO and Slim cannot coexist as plugins
- i18n: zh/en/ja translations for all Slim agent descriptions
2026-02-19 20:47:55 +08:00
Jason 51476953ae style: apply prettier formatting to App, SettingsPage, useSettings 2026-02-18 23:40:31 +08:00
Jason 0f4ce74916 refactor(forms): extract OpenCode/OMO/OpenClaw state from ProviderForm into dedicated hooks
Split ProviderForm.tsx (2227 → 1526 lines) by extracting:
- opencodeFormUtils.ts: pure functions and default config constants
- useOmoModelSource: OMO model source collection from OpenCode providers
- useOpencodeFormState: OpenCode provider form state and handlers
- useOmoDraftState: OMO profile draft editing state
- useOpenclawFormState: OpenClaw provider form state and handlers
2026-02-18 23:36:36 +08:00
SaladDay 9514d08ef6 fix(settings): normalize SQL import/export dark-mode card (#1067) 2026-02-17 22:52:00 +08:00
Jason adaef3522d fix(ui): add vertical spacing between directory settings sections
Replace React Fragment with a div using space-y-6 to add proper
vertical spacing between the app config directory and directory
override sections in Settings > Advanced > Directory Settings.
2026-02-17 21:55:10 +08:00
Jim Northrup 4c8334c6fd fix: Don't add ?beta=true to OpenAI Chat Completions endpoints (#1052)
Fixes Nvidia provider and other providers using apiFormat="openai_chat".

The ClaudeAdapter::build_url() method was incorrectly adding ?beta=true
to both /v1/messages and /v1/chat/completions endpoints. This caused
the Nvidia provider to fail because:

1. Nvidia uses apiFormat="openai_chat"
2. Requests are transformed to OpenAI format and sent to /v1/chat/completions
3. The URL gets ?beta=true appended (Anthropic-specific parameter)
4. Nvidia's API rejects requests with this parameter

Fix:
- Only add ?beta=true to /v1/messages endpoint
- Exclude /v1/chat/completions from getting this parameter

Tested:
- Anthropic /v1/messages still gets ?beta=true ✓
- OpenAI Chat Completions /v1/chat/completions does NOT get ?beta=true ✓
- All 13 Claude adapter tests pass ✓

Co-authored-by: jnorthrup <jnorthrup@example.com>
2026-02-16 22:53:08 +08:00
Jason 6caf843843 docs: sync SSSAiCode sponsor update across all README languages 2026-02-16 21:34:43 +08:00
Jason 6c38a8fd24 docs: add SSSAiCode sponsor across all README languages 2026-02-16 00:10:38 +08:00
Jason 977813f725 docs: sync Crazyrouter sponsor update across all README languages 2026-02-15 23:47:40 +08:00
JIA-ss 11f1ef33e4 feat(ui): add quick config toggles to Claude config editor (#1012)
Add 3 quick-toggle checkboxes above the JSON editor in Claude provider settings:
- Hide AI Attribution (sets attribution.commit/pr to empty string)
- Extended Thinking (sets alwaysThinkingEnabled to true)
- Teammates Mode (sets env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS to "1")

Uses local state to keep toggles and JsonEditor in sync without
triggering ProviderForm re-renders. Includes i18n for zh/en/ja.

Co-authored-by: Jason <farion1231@gmail.com>
2026-02-15 22:10:40 +08:00
SaladDay 20f62bf4f8 feat(webdav): follow-up 补齐自动同步与大文件防护 (#1043)
* feat(webdav): add robust auto sync with failure feedback

(cherry picked from commit bb6760124a62a964b36902c004e173534910728f)

* fix(webdav): enforce bounded download and extraction size

(cherry picked from commit 7777d6ec2b9bba07c8bbba9b04fe3ea6b15e0e79)

* fix(webdav): only show auto-sync callout for auto-source errors

* refactor(webdav): remove services->commands auto-sync dependency
2026-02-15 20:58:17 +08:00
Dex Miller 508aa6070c Fix/skill zip symlink resolution (#1040)
* fix(skill): resolve symlinks in ZIP extraction for GitHub repos (#1001)

- detect symlink entries via is_symlink() during ZIP extraction and collect target paths

- add resolve_symlinks_in_dir() to copy symlink target content into link location

- canonicalize base_dir to fix macOS /tmp → /private/tmp path comparison issue

- add path traversal safety check to block symlinks pointing outside repo boundary

- apply symlink resolution to both download_and_extract and extract_local_zip paths

Closes https://github.com/farion1231/cc-switch/issues/1001

* fix(skill): change search to match name and repo instead of description

* feat(skill): support importing skills from ~/.agents/skills/ directory

- Scan ~/.agents/skills/ in scan_unmanaged() for skill discovery
- Parse ~/.agents/.skill-lock.json to extract repo owner/name metadata
- Auto-add discovered repos to skill_repos management on import
- Add path field to UnmanagedSkill to show discovered location in UI

Closes #980

* fix(skill): use metadata name or ZIP filename for root-level SKILL.md imports (#1000)

When a ZIP contains SKILL.md at the root without a wrapper directory,
the install name was derived from the temp directory name (e.g. .tmpDZKGpF).
Now falls back to SKILL.md frontmatter name, then ZIP filename stem.

* feat(skill): scan ~/.cc-switch/skills/ for unmanaged skill discovery and import

* refactor(skill): unify scan/import logic with lock file skillPath and repo saving

- Deduplicate scan_unmanaged and import_from_apps using shared source list
- Replace hand-written AppType match with as_str() and AppType::all()
- Extract read_skill_name_desc, build_repo_info_from_lock, save_repos_from_lock helpers
- Add SkillApps::from_labels for building enable state from source labels
- Parse skillPath from .skill-lock.json for correct readme URLs
- Save skill repos to skill_repos table in both import and migration paths

* fix(skill): resolve symlink and path traversal issues in ZIP skill import

* fix(skill): separate source path validation and add canonicalization for symlink safety
2026-02-15 20:57:14 +08:00
makoMako 7b20c17ea4 fix(opencode): 补齐 install.sh 安装路径检测 (#988)
补齐 OpenCode 路径扫描来源(按官方 install.sh 优先级):
  OPENCODE_INSTALL_DIR > XDG_BIN_DIR > ~/bin > ~/.opencode/bin
保留并增强 Go 安装路径扫描(~/go/bin、GOPATH/*/bin)。

区分单值环境变量(push_env_single_dir)与 path-list 环境变量
(extend_from_path_list),避免对 OPENCODE_INSTALL_DIR 等单值
变量误用 split_paths。

增加路径去重逻辑(push_unique_path),避免重复扫描。

增加跨平台可执行候选逻辑(tool_executable_candidates):
  Windows: .cmd / .exe / 裸命令
  Unix:    裸命令

将 PATH 拼接提至外层循环,减少重复 syscall。

增加单元测试覆盖路径拼装、去重及 Windows 候选顺序。

Closes #958

Co-authored-by: Warp <agent@warp.dev>
2026-02-14 22:35:20 +08:00
Jason 5a17a67b8b fix: deduplicate OpenCodeModel import after rebase 2026-02-14 15:32:17 +08:00
Jason dca12b9f7d feat(openclaw): add 13 new provider presets
Migrate missing providers from Claude/Codex/Gemini/OpenCode presets:

Tier 1 Partners:
- RightCode (anthropic-messages)
- AICodeMirror (anthropic-messages)
- Zhipu GLM en (openai-completions, international)
- MiniMax en (openai-completions, international)

Tier 2 Chinese Officials:
- Kimi For Coding, KAT-Coder, Longcat, DouBaoSeed,
  BaiLing, Xiaomi MiMo

Tier 3 Aggregators:
- SiliconFlow, SiliconFlow en, Nvidia
2026-02-14 15:32:17 +08:00
Jason f974d203a8 fix(openclaw): remove /v1 from anthropic-messages provider baseUrls
Anthropic SDK auto-appends /v1 to baseUrl, so including it in the
preset causes double-path (/v1/v1/messages). Affects AiHubMix, DMXAPI,
PackyCode, Cubence, and AIGoCode.
2026-02-14 15:32:17 +08:00
Jason 44ba9468b5 refactor(openclaw): move model detail fields into advanced options
Keep only model ID and display name visible by default,
collapse context window, max tokens, reasoning, and cost
fields into the advanced options section.
2026-02-14 15:32:17 +08:00
Jason 5081206176 fix(openclaw): address code review findings across P0-P3 issues
- Add 25 missing i18n keys for OpenClawFormFields in all 3 locales (P0)
- Replace key={index} with stable crypto.randomUUID() keys in EnvPanel,
  ToolsPanel, and OpenClawFormFields to prevent list state bugs (P1)
- Exclude openclaw from ProxyToggle/FailoverToggle in App.tsx (P1)
- Add merge_additive_config() for openclaw/opencode deep link imports (P1)
- Normalize serde(flatten) field naming to `extra` + HashMap (P2)
- Add directory existence check in remove_openclaw_provider_from_live (P2)
- Remove dead code in import_default_config and openclaw API methods (P2)
- Add duplicate key validation in EnvPanel before save (P2)
- Add openclawConfigDir to Settings type (P2)
- Add staleTime to OpenClaw query hooks (P3)
- Fix type-unsafe delete via destructuring in mutations.ts (P3)
2026-02-14 15:32:17 +08:00
Jason 00b424628f refactor(openclaw): migrate config panels to TanStack Query hooks
Centralize query keys and extract reusable hooks (useOpenClaw.ts),
replacing manual useState/useEffect load/save patterns in Env, Tools,
and AgentsDefaults panels for consistency with MCP/Skills modules.
2026-02-14 15:32:17 +08:00
Jason bc87f9d9eb fix(openclaw): address code review findings for robustness
- Fix EnvPanel visibleKeys using entry key name instead of array index
  to prevent visibility state corruption after deletion
- Add NaN guard in AgentsDefaultsPanel numeric field parsing
- Validate provider id and models before importing from live config
- Upgrade import failure log level from debug to warn for OpenCode/OpenClaw
2026-02-14 15:32:17 +08:00
Jason eccb95b83d fix(openclaw): enable scrolling on config panels
Add overflow-y-auto to main content area so OpenClaw panels
(Env, Tools, Agents) can scroll when content exceeds viewport.
2026-02-14 15:32:17 +08:00
Jason 2b1f0f0f7e feat(openclaw): add Env/Tools/Agents config panels
- Migrate OpenClaw commands from provider.rs to dedicated commands/openclaw.rs
- Add backend types and read/write for env, tools, agents.defaults sections
- Create EnvPanel (API key + custom vars KV editor)
- Create ToolsPanel (profile selector + allow/deny lists)
- Create AgentsDefaultsPanel (default model + runtime parameters)
- Extend App.tsx menu bar with Env/Tools/Agents buttons
- Remove Prompts button for OpenClaw (overlaps with Workspace AGENTS.md)
2026-02-14 15:32:17 +08:00
Jason 9035784aa4 feat(openclaw): extend workspace files with HEARTBEAT/BOOTSTRAP/BOOT
Add 3 new markdown files to the workspace whitelist and frontend grid:
- HEARTBEAT.md: activity runlist
- BOOTSTRAP.md: first-run ritual
- BOOT.md: gateway restart checklist

Update i18n for all 3 locales (zh, en, ja).
2026-02-14 15:32:17 +08:00
Jason a61892e46a style: format appConfig array to multi-line 2026-02-14 15:32:17 +08:00
Jason 4f2812f472 feat(openclaw): show only Workspace button in menu bar
Hide Skills, Prompts, Sessions, and MCP buttons when OpenClaw is active,
as it only needs Workspace file management functionality.
2026-02-14 15:32:17 +08:00
Jason 705cc8a5af feat(openclaw): add Workspace Files panel for managing bootstrap md files
Add a dedicated panel to read/write OpenClaw's 6 workspace bootstrap files
(AGENTS.md, SOUL.md, USER.md, IDENTITY.md, TOOLS.md, MEMORY.md) directly
from ~/.openclaw/workspace/, with a whitelist-secured backend and Markdown
editor UI. Also fix prompt auto-import missing OpenCode/OpenClaw and the
PromptFormPanel filenameMap type exclusion.
2026-02-14 15:32:17 +08:00
Jason 182015264c fix(openclaw): add openclaw to all Record<AppId, T> usages
Expand McpApps interface, APP_IDS, APP_ICON_MAP, enabledCounts
initializers, and test mock data to include the openclaw key,
resolving TypeScript errors after AppId union was extended.
2026-02-14 15:31:59 +08:00
Jason a33f8fe973 perf: remove unnecessary query cache for Tauri local IPC
Tauri IPC calls are in-process (microsecond latency), so the 5-minute
staleTime and disabled refetchOnWindowFocus from web app templates are
counterproductive. Set staleTime to 0 and enable refetchOnWindowFocus
so external config changes are reflected immediately.
2026-02-14 15:31:59 +08:00
Jason f1fbe324d1 feat(openclaw): add default model button and fix sessionsApi export
- Add "Enable as Default" button to OpenClaw provider cards
- Place enable button before add/remove button for better UX
- Disable remove button when provider is set as default
- Add sessionsApi re-export from api barrel file to fix white screen
- Add i18n keys for default model feature (zh/en/ja)
2026-02-14 15:31:59 +08:00
Jason 26a2fc4fe0 fix(openclaw,opencode): correct Opus pricing and upgrade to Opus 4.6
- Fix Opus pricing from $15/$75 (Opus 4 price) to $5/$25 in openclaw presets
- Upgrade model ID from claude-opus-4-5-20251101 to claude-opus-4-6
- Update all suggestedDefaults references (fallbacks + modelCatalog)
- Apply same model upgrade in opencode presets (7 providers)
2026-02-14 15:31:59 +08:00
Jason 02f9f2e3b8 feat(openclaw): register suggested models to allowlist on provider add
When adding an OpenClaw provider with suggestedDefaults, automatically
merge its model catalog into the allowlist and set the default model
if not already configured.
2026-02-14 15:31:58 +08:00
Jason c431a86064 fix(openclaw): update button state and toast message after switch
Add cache invalidation for openclawLiveProviderIds in useSwitchProviderMutation
to ensure button state updates correctly after adding/removing providers.

Also fix toast message to show "已添加到配置" for OpenClaw (same as OpenCode).
2026-02-14 15:31:58 +08:00
Jason 46ab15f9f6 style: apply code formatting 2026-02-14 15:31:58 +08:00
Jason 7b2cf66812 feat(openclaw): add providerKey input field with validation
- Add providerKey input field in ProviderForm for OpenClaw
- Implement duplicate key detection by querying existing providers
- Add format validation (lowercase letters, numbers, hyphens only)
- Disable field in edit mode (key cannot be changed after creation)
- Update mutations.ts to support OpenClaw providerKey
- Add i18n translations for zh/en/ja
2026-02-14 15:31:58 +08:00
Jason 227f08e910 fix(openclaw): pass providerKey in AddProviderDialog submit handler
The providerKey was only being passed for opencode, causing openclaw
provider creation to fail with "Provider key is required" error.
2026-02-14 15:31:58 +08:00
Jason 392344e1e9 fix(openclaw): prevent creating default provider on first launch
Add additive mode guard in import_default_config() to skip OpenCode
and OpenClaw apps, which should use their dedicated import functions.
2026-02-14 15:31:58 +08:00
Jason 3e58e65284 fix(openclaw): update API protocol values in provider presets
Update anthropic -> anthropic-messages to match OpenClaw gateway config
2026-02-14 15:31:58 +08:00
Jason 512f22e83a feat(openclaw): add provider form fields and UI components
- Add OpenClawFormFields component for provider configuration
- Add Collapsible UI component (radix-ui dependency)
- Update ProviderForm with OpenClaw-specific handlers and preset logic
- Extend OpenClawModel type with reasoning, input, maxTokens fields
- Exclude OpenClaw from universal provider tab in AddProviderDialog
2026-02-14 15:31:58 +08:00
Jason 715e9e89c4 feat(openclaw): add additive mode frontend support
- Add getOpenClawLiveProviderIds() API for querying live config
- Update ProviderList to query OpenClaw live IDs
- Rename isOpenCodeMode to isAdditiveMode (covers OpenCode + OpenClaw)
- Update ProviderCard shouldAutoQuery and isActiveProvider logic
- Update App.tsx onRemoveFromConfig and invalidateQueries for OpenClaw
2026-02-14 15:31:58 +08:00
Jason 47f2c47a2f feat(openclaw): add agents.defaults config support
- Add types for default model config (primary + fallbacks)
- Add types for model catalog/allowlist with aliases
- Extend OpenClawModelEntry with cost and contextWindow fields
- Add Tauri commands: get/set_openclaw_default_model, get/set_openclaw_model_catalog
- Create frontend API (src/lib/api/openclaw.ts)
- Add suggestedDefaults to provider presets with model metadata
- Add i18n translations (zh/en/ja) for openclawConfig.*
2026-02-14 15:31:58 +08:00
Jason 52db7941ea fix(ui): update OpenClaw icon in icon index
Replace old pixel-art style icon with new gradient lobster icon
to match the SVG added in 2dad4729.
2026-02-14 15:31:58 +08:00
Jason 27bcfcb2b8 fix(ui): add OpenClaw to app switcher
Add "openclaw" to ALL_APPS array so OpenClaw appears in the main
interface app switcher when enabled in visibility settings.
2026-02-14 15:31:58 +08:00
Jason 9bbb6c51ff feat(ui): add OpenClaw brand icon and component 2026-02-14 15:31:58 +08:00
Jason 5c62c47878 chore(openclaw): remove dead code functions from openclaw_config
Remove 6 unused functions marked with #[allow(dead_code)]:
- get_openclaw_skills_dir: Skills not supported
- get_env_vars/set_env_var: Environment vars unused
- get_mcp_servers/set_mcp_server/remove_mcp_server: MCP not supported

OpenClaw integration only requires provider management functionality.
2026-02-14 15:31:58 +08:00
Jason 95391f19ac fix(tests): add mcp_servers table to v4 migration test fixture
Also includes OpenClaw icon that was added in previous commits.
2026-02-14 15:31:58 +08:00
Jason 28b125b34f fix(openclaw): remove MCP/Skills/Prompts support from OpenClaw
OpenClaw only needs provider management functionality, not MCP, Skills,
or Prompts features. This commit removes the incorrectly added support:

- Revert SCHEMA_VERSION from 6 to 5 (remove v5->v6 migration)
- Remove enabled_openclaw field from mcp_servers and skills tables
- Remove openclaw field from McpApps and SkillApps structs
- Update DAO queries to exclude enabled_openclaw column
- Fix frontend components and types to exclude openclaw from MCP/Prompts
- Update all related test files
2026-02-14 15:31:58 +08:00
Jason d56e0b0344 feat(i18n): add OpenClaw translations
- Add "openclaw": "OpenClaw" to apps translations in zh.json
- Add "openclaw": "OpenClaw" to apps translations in en.json
- Add "openclaw": "OpenClaw" to apps translations in ja.json
2026-02-14 15:31:58 +08:00
Jason fedb08e846 feat(ui): add OpenClaw support to frontend components
- Update App.tsx with openclaw visibility and skills fallback
- Add OpenClaw to AppSwitcher icon and display name maps
- Update McpFormModal with openclaw in enabled apps
- Update PromptFormModal/Panel with openclaw filename map
- Update EndpointSpeedTest with openclaw timeout
- Update AppVisibilitySettings with openclaw toggle
- Update test state with openclaw defaults
2026-02-14 15:31:58 +08:00
Jason 545be56698 feat(presets): add OpenClaw provider presets
- Create openclawProviderPresets.ts with provider templates
- Include Chinese officials (DeepSeek, Zhipu, Qwen, Kimi, MiniMax)
- Include aggregators (AiHubMix, DMXAPI, OpenRouter, ModelScope)
- Include third party partners (PackyCode, Cubence, AIGoCode)
- Include OpenAI Compatible custom template
2026-02-14 15:31:58 +08:00
Jason 31ec5d73a6 feat(types): add OpenClaw TypeScript type definitions
- Add OpenClawProviderConfig and OpenClawModel interfaces
- Add openclaw to McpApps, VisibleApps, ProxyTakeoverStatus
- Add "openclaw" to AppId union type
- Add openclaw to SkillApps interface
2026-02-14 15:31:58 +08:00
Jason 6952360f70 feat(backend): add OpenClaw support to commands and deeplinks
- Register openclaw_config module in lib.rs
- Add OpenClaw commands and provider import on startup
- Add OpenClaw branches to config and provider commands
- Add build_openclaw_settings() for deeplink imports
- Update MCP handlers with openclaw field in McpApps
- Add OpenClaw prompt file path
2026-02-14 15:31:58 +08:00
Jason e85878e95a feat(services): add OpenClaw branches to backend services
- Add OpenClaw branches to proxy service (not supported)
- Add OpenClaw to MCP service (skip sync, MCP still in development)
- Add OpenClaw skills directory path
- Update ProxyTakeoverStatus with openclaw field
- Add OpenClaw to stream check (not supported)
2026-02-14 15:31:58 +08:00
Jason 433c86b2d3 feat(provider): add OpenClaw provider service support
- Add import_openclaw_providers_from_live() function
- Add remove_openclaw_provider_from_live() function
- Update write_live_snapshot() for OpenClaw
- Add openclaw fields to VisibleApps and AppSettings
- Add get_openclaw_override_dir() function
2026-02-14 15:31:58 +08:00
Jason 63fafd6608 feat(openclaw): add OpenClaw configuration module
- Create openclaw_config.rs for reading/writing OpenClaw config
- Support JSON5 format (~/.openclaw/openclaw.json)
- Implement provider CRUD operations for additive mode
- Add json5 dependency to Cargo.toml
2026-02-14 15:31:58 +08:00
Jason 7f46a0b910 feat(core): add OpenClaw to AppType enum and database schema
- Add OpenClaw variant to AppType enum
- Update is_additive_mode() to return true for OpenClaw
- Update McpApps, SkillApps, McpRoot, PromptRoot structs
- Add database migration v5 to v6 for enabled_openclaw columns
- Update mcp_servers and skills table definitions
2026-02-14 15:31:38 +08:00
Dex Miller 9c34d04def fix(mcp): add missing OpenCode checkbox in MCP add/edit form (#1026)
Fixes https://github.com/farion1231/cc-switch/issues/1020
2026-02-14 15:29:54 +08:00
clx 6098fa7536 Webdav (#923)
* feat: WebDAV backup/restore

- Add WebDAV test/backup/restore commands and settings\n- Fix ja i18n missing keys; decode PROPFIND href as UTF-8\n- Stabilize Windows prompt auto-import tests via CC_SWITCH_TEST_HOME

* chore: format and minor cleanups

* fix: update build config

* feat(webdav): unify sync UX and hardening fixes

* fix(webdav): harden sync flow and stabilize sync UX/tests

* fix(webdav): add resource limits to skills.zip extraction

Prevent zip bomb / resource exhaustion by enforcing:
- MAX_EXTRACT_ENTRIES (10,000 files)
- MAX_EXTRACT_BYTES (512 MB cumulative)

* refactor(webdav): drop deviceId and display deviceName only

---------

Co-authored-by: small-lovely-cat <77799160+small-lovely-cat@users.noreply.github.com>
Co-authored-by: saladday <1203511142@qq.com>
2026-02-14 15:24:24 +08:00
Jason 721771bfe5 docs: add Crazyrouter as sponsor across all READMEs 2026-02-13 23:13:33 +08:00
Jason 1ecc0fff30 docs: update MiniMax sponsor to M2.5 across all READMEs 2026-02-13 22:47:26 +08:00
Jason b0a177725d fix: remove partner status from Zhipu GLM presets 2026-02-13 22:22:24 +08:00
ThendCN 2edee31638 fix(linux): disable WebKitGTK hardware acceleration to prevent white screen (#986)
On some Linux systems with AMD GPUs (e.g., AMD Cezanne/Radeon Vega),
WebKitGTK's GPU process fails to initialize EGL, causing the app to
show a white screen on startup.

Root cause: `amdgpu_query_info(ACCEL_WORKING)` returns EACCES (-13),
which causes EGL display creation to fail with EGL_BAD_PARAMETER,
crashing the GPU process.

Fix: Use WebKitGTK's `set_hardware_acceleration_policy(Never)` API to
disable GPU acceleration on Linux, falling back to software rendering.
This is applied at startup via Tauri's `with_webview` API.

Co-authored-by: Naozhong AI <oursnoah@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:53:33 +08:00
Dex Miller 62fa5213bf feat(proxy): fix thinking rectifiers and resolve clippy warnings (#1005)
* feat(proxy): align thinking rectifiers and resolve clippy warnings

- add thinking budget rectifier flow with single retry on anthropic budget errors

- align thinking signature rectification behavior with adaptive-safe handling

- expose requestThinkingBudget in settings/ui/i18n and default rectifier config to disabled

- fix clippy warnings in model_mapper format args and RectifierConfig default derive

* fix(proxy): thinking rectifiers
2026-02-12 22:28:05 +08:00
Dex Miller caba5f51ec feat(omo): improve agent model selection UX and fix lowercase keys (#1004)
* fix(omo): use lowercase keys for builtin agent definitions

OMO config schema expects all agent keys to be lowercase.
Updated OMO_BUILTIN_AGENTS keys (Sisyphus → sisyphus, Hephaestus →
hephaestus, etc.) and aligned Rust test fixtures accordingly.

* feat(omo): add i18n support and tooltips for agent/category descriptions

* feat(omo): add preset model variants for thinking level support

Add OPENCODE_PRESET_MODEL_VARIANTS constant with variant definitions
for Google, OpenAI, and Anthropic models. The omoModelVariantsMap
builder now falls back to presets when config-defined variants are
absent, enabling the variant selector for supported models.

* feat(omo): replace model select with searchable combobox and improve fallback handling

* feat(omo): enrich preset model defaults and metadata fallback

* fix(omo): preserve custom fields and align otherFields import/validation

* fix: resolve omo clippy warnings and include app update
2026-02-12 21:30:59 +08:00
Jason e349012abc docs: add Right Code as sponsor across all READMEs 2026-02-09 22:48:32 +08:00
Jason 594e0d52f7 docs: update sponsor from Zhipu GLM to MiniMax across all READMEs 2026-02-09 22:31:56 +08:00
Jason 11404d4d96 fix(ui): only show session manager button for Claude and Codex apps
Session manager backend only supports Claude and Codex providers.
Hide the nav button for Gemini/OpenCode with animated transition,
and auto-fallback to providers view when switching apps.
2026-02-09 22:12:54 +08:00
Dex Miller 0fcb1b01e2 docs: add user manual documentation (#979)
* docs: add user manual documentation

Add comprehensive user manual covering getting started, provider management,
extensions (MCP/prompts/skills), proxy configuration, and FAQ sections.
Includes screenshots and a README index.

* fix(docs): align user manual with v3.10.3 codebase

- Add OpenCode as 4th supported app throughout all docs
- Fix proxy default port 15762 → 15721
- Update Claude presets (9 → 26), Codex (3 → 10), Gemini (3 → 7)
- Add OpenCode presets (25 entries)
- Fix timeout defaults and ranges (stream first byte 60s/90s, etc.)
- Fix circuit breaker defaults with per-app values (Claude vs general)
- Fix Skills support: all 4 apps, not just Claude/Codex
- Remove non-existent Gemini authMode field
- Fix prompt deletion behavior: enabled prompts cannot be deleted
- Remove non-existent Legacy deeplink protocol, use V1 only
- Fix DB table names (usage_logs → proxy_request_logs) and add missing tables
- Fix migration version v3.8.0 → v3.7.0
- Add missing V1 deeplink parameters (config, configFormat, etc.)
- Update doc version v3.9.1 → v3.10.3
- Add claude-opus-4-1 to pricing table
- Fix recovery wait time range 10-300 → 0-300

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-02-09 15:01:15 +08:00
Dex Miller ea20b0aec2 fix(skill): correct skill doc URL branch and path resolution (#977)
Use the actual branch returned by download_repo instead of the
configured branch, fixing 404s when repos default to master but
the URL was hardcoded to main. Also switch URL format from /tree/
to /blob/ and always point to the SKILL.md file.

Closes farion1231/cc-switch#968
2026-02-09 12:30:17 +08:00
Dex Miller ed47480fc7 feat(omo): integrate Oh My OpenCode profile management (#972)
* feat(omo): integrate Oh My OpenCode profile management into Provider system

Adds full-stack OMO support: backend config read/write/import, OMO-specific
provider CRUD with exclusive switching, frontend profile editor with
agent/category/model configuration, global config management, and i18n support.

* feat(omo): add model/variant dropdowns from enabled providers

Replace model text inputs with Select dropdowns sourced from enabled
OpenCode providers, add thinking-level variant selection, and prevent
auto-enabling newly added OMO providers.

* fix(omo): use standard provider action styles for OMO switch button

* fix(omo): replace hardcoded isZh strings with proper i18n t() calls
2026-02-09 12:18:20 +08:00
makoMako 5502c74a79 feat: rename Qwen Coder preset to Bailian (#965)
- replace Qwen Coder preset with Bailian for Claude and OpenCode
- add Bailian icon asset and metadata mapping
- remove Bailian preset default model values
- rename Claude preset Kimi k2 to Kimi
2026-02-08 23:32:40 +08:00
myjustify fa7c9514cc feat(env): add Volta package manager path detection (#969)
Add ~/.volta/bin to CLI version scanning paths to support tools
installed via Volta (claude, codex, gemini, etc.).
2026-02-08 23:30:53 +08:00
Dex Miller d82027f107 feat(pricing): add claude-opus-4-6 and gpt-5.3-codex models, use incremental seeding (#943)
- Add claude-opus-4-6-20260206 pricing (same as opus-4-5)
- Add gpt-5.3-codex series pricing (same as gpt-5.2-codex)
- Change seed_model_pricing to INSERT OR IGNORE for incremental upsert
- Remove count==0 guard in ensure_model_pricing_seeded so new models
  are appended on every startup without overwriting user customizations
2026-02-07 11:06:48 +08:00
Jason ded9980fbf fix(provider): remove /v1 suffix from AIGoCode OpenCode base URL 2026-02-06 22:39:07 +08:00
Jason 3d91c381d9 fix(provider): unify AIGoCode API base URLs to https://api.aigocode.com 2026-02-06 22:39:07 +08:00
funnytime b8538a6996 feat: circular reveal animation for theme switching (#905) 2026-02-06 22:14:29 +08:00
Dex Miller 87b80c66b2 feat(usage): enhance dashboard with auto-refresh control and robust formatting (#942)
* style: format code and apply clippy lint fixes

* feat(usage): enhance dashboard with auto-refresh control and robust formatting

- Add configurable auto-refresh interval toggle (off/5s/10s/30s/60s) to usage dashboard
- Extract shared format utilities (fmtUsd, fmtInt, parseFiniteNumber, getLocaleFromLanguage)
- Refactor request log time filtering to rolling vs fixed mode with validation
- Use stable serializable query keys instead of filter objects
- Handle NaN/Infinity safely in number formatting across all usage components
- Use RFC 3339 date format in backend trend data
2026-02-06 22:00:33 +08:00
Dex Miller 14fa749ca9 fix(opencode): reject save when no models configured (#932)
Add validation to require at least one model before saving an OpenCode
provider. Shows a localized toast error when models are empty.
2026-02-06 16:03:39 +08:00
Dex Miller 95bc0e38df style: format code and apply clippy lint fixes (#941) 2026-02-06 16:02:57 +08:00
Jason 92785a8078 docs: add AICoding.sh sponsor to README files
Add AICoding.sh as a new sponsor with translations for all three
language versions (EN, ZH, JA).
2026-02-04 21:49:00 +08:00
Jason 1d97570a94 refactor(terminal): unify terminal selection using global settings
- Remove terminal selector from Session Manager page
- Backend now reads terminal preference from global settings
- Add terminal name mapping (iterm2 → iterm) for compatibility
- Add WezTerm support to macOS terminal options
- Add WezTerm translations for zh/en/ja
2026-02-04 11:03:44 +08:00
Jason d98183f3da fix(i18n): replace hardcoded Chinese strings in Session Manager
- Add i18n keys for relative time (justNow, minutesAgo, hoursAgo, daysAgo)
- Add i18n keys for role labels (roleUser, roleSystem, roleTool)
- Add i18n keys for UI elements (tocTitle, searchSessions, clickToCopyPath)
- Update formatRelativeTime and getRoleLabel to accept t function
- Add useTranslation hook to SessionToc component
2026-02-04 11:03:43 +08:00
Jason 68a0c304d8 feat(ui): hide provider test button
Provider request formats are complex and varied, making it difficult
to create a unified test mechanism. Users may incorrectly assume
request format issues indicate provider unavailability.

Code is commented out (not deleted) for easy restoration if needed.
2026-02-04 11:03:43 +08:00
Jason 60007ee4e8 fix(windows): restore default home dir resolution to prevent data loss
v3.10.3 introduced HOME env priority on Windows for test isolation,
which caused database path to change when HOME differs from USERPROFILE
(common in Git/MSYS environments), making providers appear to disappear.

Changes:
- Use CC_SWITCH_TEST_HOME for test isolation instead of HOME
- Add legacy fallback to detect v3.10.3 database location on Windows
- Add logging for legacy path detection to aid debugging
2026-02-04 11:03:43 +08:00
funnytime 7bd29d721e fix: prevent window flicker on silent startup (#901)
Closes #892
2026-02-04 10:37:54 +08:00
funnytime c153e7104e fix: titlebar does not follow theme in dark mode (#903) 2026-02-04 10:37:14 +08:00
PeanutSplash e65360e68a refactor(ui): extract shared components and deduplicate MCP/Skills panels (#897)
* refactor(ui): add tooltips and icons to MCP and Skills panels

* refactor: deduplicate UnifiedSkillsPanel and UnifiedMcpPanel shared code
2026-02-04 10:10:45 +08:00
TinsFox f0e8ba1d8f feat: session manger (#867)
* feat: init session manger

* feat: persist selected app to localStorage

- Save app selection when switching providers
- Restore last selected app on page load

* feat: persist current view to localStorage

- Save view selection when switching tabs
- Restore last selected view on page load

* styles: update ui

* feat: Improve macOS Terminal activation and refactor Kitty launch to use  command with user's default shell.

* fix: session view

* feat: toc

* feat: Implement FlexSearch for improved session search functionality.

* feat: Redesign session manager search and filter UI for a more compact and dynamic experience.

* refactor: modularize session manager by extracting components and utility functions into dedicated files.

* feat: Enhance session terminal launching with support for iTerm2, Ghostty, WezTerm, and Alacritty, including UI and custom configuration options.

* feat: Conditionally render terminal selection and resume session buttons only on macOS.
2026-02-02 11:12:30 +08:00
Jassy930 58153333ce fix(stream_check): respect auth_mode for Claude health checks (#824)
Previously, check_claude_stream always added the x-api-key header,
ignoring the provider's auth_mode setting. This caused health check
failures for proxy services that only support Bearer authentication.

Now the function respects the auth.strategy field:
- AuthStrategy::Anthropic: Authorization Bearer + x-api-key
- AuthStrategy::ClaudeAuth: Authorization Bearer only
- AuthStrategy::Bearer: Authorization Bearer only

This aligns with the behavior of ClaudeAdapter::add_auth_headers
and fixes health checks for proxy providers with auth_mode="bearer_only".

Changes:
- Modified check_claude_stream to conditionally add x-api-key header
- Added AuthStrategy import
- Added test_auth_strategy_imports unit test

Tests: All passing (7/7 for stream_check module)

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 09:28:04 +08:00
stmoonar d098ecad64 fix(skills): handle Windows path separator in installed status matching (#868)
* fix(skills): handle Windows path separator in installed status matching

Use regex to split directory path by both / and \ to correctly extract
the install name on Windows, fixing the issue where installed skills
were not showing as installed in the discovery page filter.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(skills): add repository filter to skills discovery page

- Add dropdown to filter skills by repository (owner/name)
- Extract unique repos from discoverable skills list
- Add truncate style for long repo names with hover title
- Add i18n translations for zh/en/ja

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 09:05:05 +08:00
makoMako 809a1fcf84 feat(ui): dynamic endpoint hint based on API format selection (#860)
- Add apiHintOAI i18n key for OpenAI Chat Completions format hint
- Update ClaudeFormFields to show format-specific endpoint hints
- When API format is "openai_chat", show OAI-specific hint
- Maintains consistency between hint and selected API format

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 11:21:43 +08:00
Jason e5fea048a1 fix(ci): add xdg-utils for ARM64 AppImage and suppress dead_code warnings
- Add xdg-utils dependency for xdg-mime binary required by AppImage bundler
- Remove unused McpStatus struct from gemini_mcp.rs (duplicate of claude_mcp.rs)
- Add #![allow(dead_code)] to proxy models reserved for future type-safe API
2026-01-31 21:26:24 +08:00
Jason b8bd1d30d9 docs(changelog): add Linux ARM64 and error display fix to v3.10.3 2026-01-31 20:46:20 +08:00
Jason 4abf259a6d feat(ci): add Linux ARM64 build support
- Add ubuntu-22.04-arm runner to build matrix
- Rename Linux artifacts with architecture suffix (x86_64/arm64)
- Update pnpm cache key with runner.arch to avoid cross-arch pollution
- Add linux-aarch64 platform to latest.json for Tauri updater
2026-01-31 20:38:18 +08:00
Jason faa82a5b86 fix(mutations): use extractErrorMessage for complete error display
Provider add/update/delete mutations were using error.message directly,
which doesn't extract Tauri invoke errors properly. Now using the same
extractErrorMessage pattern as the rest of the codebase.
2026-01-31 20:15:39 +08:00
Jason b5b45c2703 chore: bump version to 3.10.3
- Update version in package.json, Cargo.toml, and tauri.conf.json
- Add missing changelog entries for OpenCode API key link and AICodeMirror preset
- Fix Prettier formatting for new hook files
2026-01-31 17:29:09 +08:00
Jason bd8a323600 feat(opencode): add API key link support for OpenCode provider form
Enable API key link feature for OpenCode app, allowing users to access
provider websites for key registration directly from the form.
2026-01-31 17:16:36 +08:00
Jason 151e43a808 feat(providers): add AICodeMirror partner preset for all apps
Register AICodeMirror icon, add i18n partner promotion texts (zh/en/ja),
and fix trailing whitespace in codexProviderPresets.
2026-01-31 16:05:50 +08:00
Jason 9d2bf08fe0 docs(changelog): release v3.10.3
Add changelog entry for v3.10.3 feature release including:
- API format selector and presets
- Pricing config enhancement
- Skills ZIP install
- Preferred terminal selection
- Silent startup option
- OpenCode environment check and directory sync
- NVIDIA NIM and n1n.ai presets
- Multiple bug fixes for Codex, proxy URL, tray menu, etc.
2026-01-30 23:18:22 +08:00
Jason 05c21e016f feat(settings): add OpenCode support to environment check and one-click install
- Add OpenCode version detection with Go path scanning
- Add GitHub Releases API for fetching latest OpenCode version
- Add OpenCode install command to one-click install section
- Update i18n hints to include OpenCode across all locales
- Fix SettingsPage indentation formatting
2026-01-30 22:23:18 +08:00
Jason 57713dd336 feat(claude): show proxy hint when switching to OpenAI Chat format provider
Display an info toast when switching to a provider that uses OpenAI Chat
format (apiFormat === "openai_chat"), reminding users to enable the proxy
service. Move toast logic from mutation to useProviderActions for better
control over notification content based on provider properties.
2026-01-30 16:40:41 +08:00
Jason 1ed122a8bd refactor(proxy): remove DeepSeek max_tokens clamp from transform layer
The max_tokens restriction was too aggressive and should be handled
upstream or by the provider itself. Simplify anthropic_to_openai by
removing provider parameter since model mapping is already done by
proxy::model_mapper.
2026-01-30 16:40:41 +08:00
Jason 78e341ccb9 feat(providers): add NVIDIA NIM preset for Claude and OpenCode
- Add NVIDIA NIM provider preset with API configuration
- Add nvidia.svg icon and register in icon system
- Add nvidia metadata with keywords and default color (#74B71B)
2026-01-30 16:40:41 +08:00
Jason 162800e18e feat(claude): add apiFormat support for provider presets
Allow preset providers to specify API format (anthropic or openai_chat),
enabling third-party proxy services that use OpenAI Chat Completions format.
2026-01-30 16:40:41 +08:00
Jason 065d5db843 fix(proxy): improve URL building and remove redundant model mapping
- Add model parameter to request logs for better debugging
- Fix duplicate /v1/v1 in URL when both base_url and endpoint have version
- Extend ?beta=true parameter to /v1/chat/completions endpoint
- Remove model mapping from transform layer (now handled by model_mapper)
- Add DeepSeek max_tokens clamping (1-8192 range)
2026-01-30 16:40:41 +08:00
Jason 70a18c1141 refactor(claude): migrate api_format from settings_config to meta
Move api_format storage from settings_config to ProviderMeta to prevent
polluting ~/.claude/settings.json when switching providers.

- Add api_format field to ProviderMeta (Rust + TypeScript)
- Update ProviderForm to read/write apiFormat from meta
- Maintain backward compatibility for legacy settings_config.api_format
  and openrouter_compat_mode fields (read-only fallback)
- Strip api_format from settings_config before writing to live config
2026-01-30 16:40:41 +08:00
Jason 964767ebaf fix(i18n): update apiFormatOpenAIChat label to mention proxy requirement
Change label from "Requires conversion" to "Requires proxy" for clarity:
- zh: "需转换" → "需开启代理"
- en: "Requires conversion" → "Requires proxy"
- ja: "変換が必要" → "プロキシが必要"
2026-01-30 16:40:41 +08:00
Jason 0c25687e09 fix(claude): improve backward compatibility for openrouter_compat_mode
Extend backward compatibility support for legacy openrouter_compat_mode field:
- Support number type (1 = enabled, 0 = disabled)
- Support string type ("true"/"1" = enabled)
- Add corresponding test cases for number and string types
2026-01-30 16:40:41 +08:00
Jason 81b975c47c feat(claude): add API format selector for third-party providers
Replace the OpenRouter-specific compatibility toggle with a generic
API format selector that allows all Claude providers to choose between:

- Anthropic Messages (native): Direct passthrough, no conversion
- OpenAI Chat Completions: Enables Anthropic ↔ OpenAI format conversion

Changes:
- Add ClaudeApiFormat type ("anthropic" | "openai_chat") to types.ts
- Replace openRouterCompatToggle with apiFormat dropdown in ClaudeFormFields
- Update ProviderForm to manage apiFormat state via settingsConfig.api_format
- Refactor claude.rs: add get_api_format() method, update needs_transform()
- Maintain backward compatibility with legacy openrouter_compat_mode field
- Update i18n translations (zh, en, ja)
2026-01-30 16:40:41 +08:00
n1n.ai e44423c307 feat: add n1n.ai provider preset (#667)
Co-authored-by: n1n <team@n1n.ai>
2026-01-30 12:45:01 +08:00
Jason 08d9bb4cab style(dao): format proxy tests with cargo fmt 2026-01-29 10:09:45 +08:00
Jason 987fc46e06 feat(skills): add install from ZIP file feature
- Add open_zip_file_dialog command for selecting ZIP files
- Add install_from_zip service method with recursive skill scanning
- Add install_skills_from_zip Tauri command
- Add frontend API methods and useInstallSkillsFromZip hook
- Add "Install from ZIP" button in Skills management page
- Support local skill ID format: local:{directory}
- Add i18n translations for new feature and error messages
2026-01-29 10:09:45 +08:00
Jason e3d335be2d feat(settings): prioritize native install path for Claude Code detection
- Move ~/.local/bin to first position in version scan search paths
- Update one-click install commands to use official native installation
  (curl script) instead of npm for Claude Code
2026-01-29 10:09:45 +08:00
Andrew Leng 0dd823ae3a fix(codex): fix 404 errors and connection timeout with custom base_url (#760)
* fix(proxy): fix Codex 404 errors with custom base_url prefixes

- handlers.rs:268: Remove hardcoded /v1 prefix in Codex forwarding
- codex.rs:140: Only add /v1 for origin-only base_urls, dedupe /v1/v1
- stream_check.rs:364: Try /responses first, fallback to /v1/responses
- provider.rs:427: Don't force /v1 for custom prefix base_urls

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(codex): always add /v1 for custom prefix base_urls

Changed logic to always add /v1 prefix unless base_url already ends with /v1.
This fixes 504 timeout errors with relay services that expect /v1 in the path.

- Most relay services follow OpenAI standard format: /v1/responses
- Users can opt-out by adding /v1 to their base_url configuration
- Updated test case to reflect new behavior

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(proxy): allow system proxy on localhost with different ports

- Only bypass system proxy if it points to CC Switch's own port (15721)
- Allow other localhost proxies (e.g., Clash on 7890) to be used
- Add INFO level logging for request URLs to aid debugging

This fixes connection timeout issues when using local proxy tools.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(codex): don't add /v1 for custom prefix base_urls

Reverted logic to not add /v1 for base_urls with custom prefixes.
Many relay services use custom paths without /v1.

- Pure origin (e.g., https://api.openai.com) → adds /v1
- With /v1 (e.g., https://api.openai.com/v1) → no change
- Custom prefix (e.g., https://example.com/openai) → no /v1

This fixes 404 errors with relay services that don't use /v1 in their paths.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(proxy): use dynamic port for system proxy detection

Instead of hardcoding port 15721, now uses the actual configured
listen_port from proxy settings.

- Added set_proxy_port() to update the port when proxy server starts
- Added get_proxy_port() to retrieve current port for detection
- Updated server.rs to call set_proxy_port() on startup
- Updated tests to reflect new behavior

This allows users to change the proxy port in settings without
breaking the system proxy detection logic.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(proxy): change default proxy port from 15721 to 5000

Update the default fallback port in get_proxy_port() from 15721 to 5000
to match the project's standard default port configuration.

Also updated test cases to use port 5000 consistently.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(proxy): revert default port back to 15721

Revert the default fallback port in get_proxy_port() from 5000 back to 15721
to align with the project's updated default port configuration.

Also updated test cases to use port 15721 consistently.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: ozbombor <ozbombor@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 09:10:17 +08:00
方程 164635f638 style(tray): 简化标题标签并优化菜单分隔符 (#796)
Co-authored-by: franco <1787003204@q.comq>
2026-01-28 22:53:38 +08:00
makoMako fcb5163710 fix(settings): correct Gemini default visibility to true (#818)
Frontend fallback value for visibleApps had gemini: false, which was
inconsistent with backend default (gemini: true). This caused Gemini
to be hidden by default on first install.

Fixes #817

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 21:35:10 +08:00
Jason 3095bf8e5c chore(presets): upgrade Kimi/Moonshot providers to k2.5 model
- Update Moonshot preset models from kimi-k2-thinking to kimi-k2.5
- Update OpenCode Kimi preset name and model to k2.5
- Remove explicit model config from Kimi preset (use API defaults)
2026-01-27 23:38:34 +08:00
Jason a48502235c docs(sponsors): add AICodeMirror and reorder sponsor list
- Add AICodeMirror as new sponsor with logo
- Sync changes across all README languages (EN/ZH/JA)
2026-01-27 22:51:22 +08:00
Jason c74f801d66 fix(settings): correct footer layout in advanced settings tab
- Establish proper flexbox height chain from App to SettingsPage
- Move save button to fixed footer outside scrollable area
- Align footer styling with FullScreenPanel for consistency
2026-01-27 10:46:27 +08:00
Dex Miller 785e1b5add Feat/pricing config enhancement (#781)
* feat(db): add pricing config fields to proxy_config table

- Add default_cost_multiplier field per app type
- Add pricing_model_source field (request/response)
- Add request_model field to proxy_request_logs table
- Implement schema migration v5

* feat(api): add pricing config commands and provider meta fields

- Add get/set commands for default cost multiplier
- Add get/set commands for pricing model source
- Extend ProviderMeta with cost_multiplier and pricing_model_source
- Register new commands in Tauri invoke handler

* fix(proxy): apply cost multiplier to total cost only

- Move multiplier calculation from per-item to total cost
- Add resolve_pricing_config for provider-level override
- Include request_model and cost_multiplier in usage logs
- Return new fields in get_request_logs API

* feat(ui): add pricing config UI and usage log enhancements

- Add pricing config section to provider advanced settings
- Refactor PricingConfigPanel to compact table layout
- Display all three apps (Claude/Codex/Gemini) in one view
- Add multiplier column and request model display to logs
- Add frontend API wrappers for pricing config

* feat(i18n): add pricing config translations

- Add zh/en/ja translations for pricing defaults config
- Add translations for multiplier, requestModel, responseModel
- Add provider pricing config translations

* fix(pricing): align backfill cost calculation with real-time logic

- Fix backfill to deduct cache_read_tokens from input (avoid double billing)
- Apply multiplier only to total cost, not to each item
- Add multiplier display in request detail panel with i18n support
- Use AppError::localized for backend error messages
- Fix init_proxy_config_rows to use per-app default values
- Fix silent failure in set_default_cost_multiplier/set_pricing_model_source
- Add clippy allow annotation for test mutex across await

* style: format code with cargo fmt and prettier

* fix(tests): correct error type assertions in proxy DAO tests

The tests expected AppError::InvalidInput but the DAO functions use
AppError::localized() which returns AppError::Localized variant.
Updated assertions to match the correct error type with key validation.

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-01-27 10:43:05 +08:00
Jason c00f431d67 feat(opencode): sync all providers to live config on directory change
Add additive mode support for OpenCode in sync_current_to_live:
- Add AppType::is_additive_mode() to distinguish switch vs additive mode
- Add AppType::all() iterator to avoid hardcoding app lists
- Add sync_all_providers_to_live() for additive mode apps
- Refactor sync_current_to_live to handle both modes

Frontend changes (directory settings):
- Track opencodeDirChanged in useDirectorySettings
- Trigger syncCurrentProvidersLiveSafe when OpenCode dir changes
- Add i18n strings for OpenCode directory settings
2026-01-26 15:46:51 +08:00
Jason 29a0643d74 refactor(config): consolidate get_home_dir into single public function
Follow-up to #644: Extract duplicate get_home_dir() implementations
into a single pub fn in config.rs, reducing code duplication across
codex_config.rs, gemini_config.rs, and settings.rs.

Also adds documentation comments to build.rs explaining the Windows
manifest workaround for test binaries.
2026-01-26 11:24:28 +08:00
Jason 5e92111771 feat(settings): add preferred terminal selection
Allow users to choose their preferred terminal app for opening
provider terminals. Supports platform-specific options:
- macOS: Terminal.app, iTerm2, Alacritty, Kitty, Ghostty
- Windows: cmd, PowerShell, Windows Terminal
- Linux: GNOME Terminal, Konsole, Xfce4, Alacritty, Kitty, Ghostty

Falls back to system default if selected terminal is unavailable.
2026-01-26 11:20:33 +08:00
Jason beebd9847f refactor(terminal): consolidate redundant terminal launch functions
Merge macOS Alacritty/Kitty/Ghostty launchers into a single generic
`launch_macos_open_app` function with a `use_e_flag` parameter.
Replace Windows cmd/PowerShell/wt launchers with a shared
`run_windows_start_command` helper. Reduces ~98 lines of duplicate code.
2026-01-26 11:20:33 +08:00
Xyfer d99a3c2fee fix(windows): stabilize test environment (#644)
* fix(windows): embed common-controls manifest

* fix(windows): prefer HOME for config paths

* test(windows): fix export_sql invalid path

* fix(windows): remove unused env import in config.rs
2026-01-26 11:18:14 +08:00
funnytime a0ca8c2517 feat: add silent startup option to prevent window popup on launch (issue #708) (#713) 2026-01-26 10:54:59 +08:00
Dex Miller 3434dcb87c fix(skills): prevent duplicate skill installation from different repos (#778)
- Add directory conflict detection before installation
- Fix installed status check to match repo owner and name
- Add i18n translations for conflict error messages
2026-01-25 23:35:04 +08:00
Jason 1c6689a0bc chore: update Cargo.lock version to 3.10.2 2026-01-24 23:49:06 +08:00
Jason 9404341f14 feat(ui): replace update badge dot with ArrowUpCircle icon
- Replace blue dot indicator with lucide ArrowUpCircle icon
- Change color scheme from blue to green for better visibility
- Enlarge button (h-8 w-8) and icon (h-5 w-5) for better UX
- Move UpdateBadge from title area to after settings icon
2026-01-24 23:32:05 +08:00
Jason 53dd0a90f3 chore: release v3.10.2 2026-01-24 22:15:21 +08:00
Jason 779fefd86d feat(skills): add skill sync method setting (symlink/copy)
- Add SyncMethod enum (Auto/Symlink/Copy) in Rust backend
- Implement sync_to_app_dir with symlink support (cross-platform)
- Add SkillSyncMethodSettings UI component (simplified 2-button selector)
- Add i18n support for zh/en/ja
- Replace copy_to_app with configurable sync_to_app_dir
- Add skill_sync_method field to AppSettings

User can now choose between symlink (disk space saving) or copy (best compatibility) in Settings > General.
2026-01-24 18:24:05 +08:00
Jason 096c1d57c4 feat(partner): add RightCode as official partner
Update RightCode referral link to use CCSWITCH affiliate code and mark
as official partner with promotional messages in all supported languages.
2026-01-23 22:59:48 +08:00
Jason adb868d0cf fix(prompt): clear prompt file when all prompts are disabled
When disabling a prompt, check if any other prompts remain enabled.
If all prompts are disabled, clear the prompt file to ensure UI state
matches the actual configuration that Claude Code reads.
2026-01-23 22:43:07 +08:00
Jason a6ad896db0 fix(opencode): preserve extra model fields during serialization
Add `extra` field with serde flatten to OpenCodeModel struct to capture
custom fields like cost, modalities, thinking, and variants that were
previously lost during save operations.
2026-01-23 20:42:50 +08:00
Jason d6cf4390ac fix(form): backfill model fields when editing Claude provider
Use lazy initialization in useState to parse model values from config
on first render, matching the pattern used in useApiKeyState. This
fixes an issue where model fields were not populated in edit mode.
2026-01-23 19:13:38 +08:00
Jason 0ef670325b chore: release v3.10.1
- Bump version to 3.10.1 across all config files
- Update CHANGELOG with all fixes since v3.10.0
- Fix Rust Clippy warning by using derive(Default)
- Apply code formatting
2026-01-23 10:41:38 +08:00
Jason 07fc6b175e fix(proxy): change rectifier default state to disabled
Change the default state of the rectifier from enabled to disabled.
This allows users to opt-in to the rectifier feature rather than having it enabled by default.

Changes:
- Set RectifierConfig::default() enabled and request_thinking_signature to false
- Update serde default attributes from default_true to default
- Update unit tests to reflect new default behavior
2026-01-23 09:45:57 +08:00
Jason 79d3ecc1b8 feat(icons): update RightCode provider icon
- Import new detailed RC icon from rc.svg (128x128 viewBox)
- Add icon configuration to Claude provider preset
- Remove duplicate old RC icon definition to fix TS1117 error
2026-01-23 09:25:09 +08:00
Jason 9c249d9486 fix(ui): increase app icon collapse threshold from 3 to 4
Improve readability by keeping app names visible when 3 apps are shown.
Icons now only collapse to compact mode when 4 or more apps are visible.
2026-01-23 00:12:33 +08:00
Jason 15f60e8ce2 fix(ui): improve ProviderIcon color validation to prevent black icons
Enhance the effectiveColor logic in ProviderIcon to properly validate
the color prop before using it. Now only uses color when it's a valid
non-empty string, otherwise falls back to icon metadata defaultColor.

This fixes the issue where Gemini icons would turn black when selected
in ProviderCard due to null/undefined/empty string color values being
passed through.
2026-01-23 00:02:11 +08:00
Jason 3d733a3b80 fix(ui): unify layout padding across all panels
Remove max-width constraints and standardize padding to px-6 for consistent full-width layout across header and all content panels.
2026-01-22 23:55:19 +08:00
Jason cfb113ac8d fix(settings): reorder window settings and change default values
Move "minimize to tray on close" to the bottom of window settings.
Change skipClaudeOnboarding default to false.
2026-01-22 23:35:41 +08:00
Jason 62a4ab2ad3 fix(terminal): keep Windows terminal window open after execution
Use /K instead of /C flag to prevent cmd window from closing
immediately after batch file execution. Also remove the buggy
errorlevel check that was checking del command's return value
instead of claude's.

Fixes #726
2026-01-22 23:04:18 +08:00
Jason aa0f191420 fix(config): correct OpenCode config path on Windows
OpenCode uses ~/.config/opencode on all platforms, not %APPDATA%\opencode
on Windows. Remove the platform-specific path handling to use unified
~/.config/opencode path across all operating systems.
2026-01-22 17:09:45 +08:00
Jason b8305f281b fix(ui): align panel content with header constraints
Add max-w-[56rem] and mx-auto to Skills, MCP, and Prompts panels
to match the header layout constraints, ensuring the back button
and action buttons align with the content area.
2026-01-22 12:41:06 +08:00
Jason b3af191fab docs: add v3.10.0 release notes in zh/en/ja 2026-01-22 11:58:25 +08:00
317 changed files with 36196 additions and 6367 deletions
+27 -16
View File
@@ -20,6 +20,8 @@ jobs:
include:
- os: windows-2022
- os: ubuntu-22.04
- os: ubuntu-22.04-arm
arch: arm64
- os: macos-14
steps:
@@ -57,7 +59,8 @@ jobs:
rpm \
flatpak \
flatpak-builder \
elfutils
elfutils \
xdg-utils
# GTK/GLib stack for gdk-3.0, glib-2.0, gio-2.0
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev \
@@ -85,8 +88,8 @@ jobs:
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: ${{ runner.os }}-pnpm-store-
key: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-
- name: Install frontend deps
run: pnpm install --frozen-lockfile
@@ -256,10 +259,11 @@ jobs:
set -euxo pipefail
mkdir -p release-assets
VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0
ARCH="${{ matrix.arch || 'x86_64' }}"
# Updater artifact: AppImage(含对应 .sig
APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true)
if [ -n "$APPIMAGE" ]; then
NEW_APPIMAGE="CC-Switch-${VERSION}-Linux.AppImage"
NEW_APPIMAGE="CC-Switch-${VERSION}-Linux-${ARCH}.AppImage"
cp "$APPIMAGE" "release-assets/$NEW_APPIMAGE"
[ -f "$APPIMAGE.sig" ] && cp "$APPIMAGE.sig" "release-assets/$NEW_APPIMAGE.sig" || echo ".sig for AppImage not found"
echo "AppImage copied: $NEW_APPIMAGE"
@@ -269,18 +273,16 @@ jobs:
# 额外上传 .deb(用于手动安装,不参与 Updater)
DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true)
if [ -n "$DEB" ]; then
NEW_DEB="CC-Switch-${VERSION}-Linux.deb"
cp "$DEB" "release-assets/$NEW_DEB"
echo "Deb package copied: $NEW_DEB"
cp "$DEB" "release-assets/CC-Switch-${VERSION}-Linux-${ARCH}.deb"
echo "Deb package copied: CC-Switch-${VERSION}-Linux-${ARCH}.deb"
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"
cp "$RPM" "release-assets/CC-Switch-${VERSION}-Linux-${ARCH}.rpm"
echo "RPM package copied: CC-Switch-${VERSION}-Linux-${ARCH}.rpm"
else
echo "No .rpm found (optional)"
fi
@@ -312,7 +314,8 @@ jobs:
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`Homebrew
- **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
- **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`AppImage)或 `CC-Switch-${{ github.ref_name }}-Linux.deb`Debian/Ubuntu)或 `CC-Switch-${{ github.ref_name }}-Linux.rpm`Fedora/RHEL/openSUSE
- **Linux (x86_64)**: `CC-Switch-${{ github.ref_name }}-Linux-x86_64.AppImage` / `.deb` / `.rpm`
- **Linux (ARM64)**: `CC-Switch-${{ github.ref_name }}-Linux-arm64.AppImage` / `.deb` / `.rpm`
---
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
@@ -360,7 +363,8 @@ jobs:
# 初始化空平台映射
mac_url=""; mac_sig=""
win_url=""; win_sig=""
linux_url=""; linux_sig=""
linux_x64_url=""; linux_x64_sig=""
linux_arm64_url=""; linux_arm64_sig=""
shopt -s nullglob
for sig in dl/*.sig; do
base=${sig%.sig}
@@ -371,8 +375,10 @@ jobs:
*.tar.gz)
# 视为 macOS updater artifact
mac_url="$url"; mac_sig="$sig_content";;
*.AppImage|*.appimage)
linux_url="$url"; linux_sig="$sig_content";;
*-Linux-arm64.AppImage|*-Linux-arm64.appimage)
linux_arm64_url="$url"; linux_arm64_sig="$sig_content";;
*-Linux-x86_64.AppImage|*-Linux-x86_64.appimage)
linux_x64_url="$url"; linux_x64_sig="$sig_content";;
*.msi|*.exe)
win_url="$url"; win_sig="$sig_content";;
esac
@@ -399,9 +405,14 @@ jobs:
echo " \"windows-x86_64\": {\"signature\": \"$win_sig\", \"url\": \"$win_url\"}"
first=0
fi
if [ -n "$linux_url" ] && [ -n "$linux_sig" ]; then
if [ -n "$linux_x64_url" ] && [ -n "$linux_x64_sig" ]; then
[ $first -eq 0 ] && echo ','
echo " \"linux-x86_64\": {\"signature\": \"$linux_sig\", \"url\": \"$linux_url\"}"
echo " \"linux-x86_64\": {\"signature\": \"$linux_x64_sig\", \"url\": \"$linux_x64_url\"}"
first=0
fi
if [ -n "$linux_arm64_url" ] && [ -n "$linux_arm64_sig" ]; then
[ $first -eq 0 ] && echo ','
echo " \"linux-aarch64\": {\"signature\": \"$linux_arm64_sig\", \"url\": \"$linux_arm64_url\"}"
first=0
fi
echo ' }'
+1 -1
View File
@@ -8,7 +8,7 @@ release/
*.tsbuildinfo
.npmrc
CLAUDE.md
AGENTS.md
# AGENTS.md
GEMINI.md
/.claude
/.codex
+102
View File
@@ -7,6 +7,108 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **Windows Home Dir Regression**: Prevent providers/settings “disappearing” after upgrading from v3.10.2 → v3.10.3 when `HOME` differs from the real user profile directory; restore default path resolution and auto-detect the v3.10.3 legacy database location.
---
## [3.10.3] - 2026-01-30
### Feature Release
This release introduces a generic API format selector, pricing configuration enhancements, and multiple UX improvements.
### Added
- **API Key Link for OpenCode**: API key link support for OpenCode provider form, enabling quick access to provider key management pages
- **AICodeMirror Partner Preset**: Added AICodeMirror partner preset for all apps (Claude, Codex, Gemini, OpenCode)
- **API Format Selector**: Generic API format chooser for Claude providers, replacing the OpenRouter-specific toggle. Supports Anthropic Messages (native) and OpenAI Chat Completions format
- **API Format Presets**: Allow preset providers to specify API format (anthropic or openai_chat) for third-party proxy services
- **Proxy Hint**: Display info toast when switching to OpenAI Chat format provider, reminding users to enable proxy
- **Pricing Config Enhancement**: Per-provider cost multiplier, pricing model source (request/response), request model logging, and enriched usage UI (#781)
- **Skills ZIP Install**: Install skills directly from local ZIP files with recursive scanning support
- **Preferred Terminal**: Choose preferred terminal app per platform (macOS: Terminal.app/iTerm2/Alacritty/Kitty/Ghostty; Windows: cmd/PowerShell/Windows Terminal; Linux: GNOME Terminal/Konsole/Xfce4/Alacritty/Kitty/Ghostty)
- **Silent Startup**: Option to prevent window popup on launch (#713)
- **OpenCode Environment Check**: Version detection with Go path scanning and one-click install from GitHub Releases
- **OpenCode Directory Sync**: Auto-sync all providers to live config on directory change with additive mode support
- **NVIDIA NIM Preset**: New provider preset for Claude and OpenCode with nvidia.svg icon
- **n1n.ai Preset**: New provider preset (#667)
- **Update Badge Icon**: Replace update badge dot with ArrowUpCircle icon
- **Linux ARM64**: CI build support for Linux ARM64 architecture
### Changed
- **API Format Migration**: Migrate api_format from settings_config to ProviderMeta to prevent polluting ~/.claude/settings.json
- **DeepSeek max_tokens**: Remove max_tokens clamp from proxy transform layer
- **Terminal Functions**: Consolidate redundant terminal launch functions
- **Home Dir Utility**: Consolidate get_home_dir into single public function
- **Kimi/Moonshot**: Upgrade provider presets to k2.5 model
### Fixed
- **Codex 404 & Timeout**: Fix 404 errors and connection timeout with custom base_url; improve /v1 prefix handling and system proxy detection (#760)
- **Proxy URL Building**: Fix duplicate /v1/v1 in URL; extend ?beta=true to /v1/chat/completions endpoint
- **OpenRouter Compat Mode**: Improve backward compatibility supporting number and string types
- **Gemini Visibility**: Correct Gemini default visibility to true (#818)
- **Footer Layout**: Correct footer layout in advanced settings tab
- **Claude Code Detection**: Prioritize native install path for detection
- **Tray Menu**: Simplify title labels and optimize menu separators (#796)
- **Duplicate Skills**: Prevent duplicate skill installation from different repos (#778)
- **Windows Tests**: Stabilize test environment (#644)
- **i18n**: Update apiFormatOpenAIChat label to mention proxy requirement
- **Error Display**: Use extractErrorMessage for complete error display in mutations
- **Sponsors**: Add AICodeMirror and reorder sponsor list
---
## [3.10.2] - 2026-01-24
### Patch Release
This maintenance release adds skill sync options and includes important bug fixes.
### Added
- **Skills**: Add skill sync method setting with symlink/copy options
- **Partners**: Add RightCode as official partner
### Fixed
- **Prompts**: Clear prompt file when all prompts are disabled
- **OpenCode**: Preserve extra model fields during serialization
- **Provider Form**: Backfill model fields when editing Claude provider
---
## [3.10.1] - 2026-01-23
### Patch Release
This maintenance release includes important bug fixes for Windows platform, UI improvements, and code quality enhancements.
### Added
- **Provider Icons**: Updated RightCode provider icon with improved visual design
### Changed
- **Proxy Rectifier**: Changed rectifier default state to disabled for better stability
- **Window Settings**: Reordered window settings and updated default values for improved UX
- **UI Layout**: Increased app icon collapse threshold from 3 to 4 icons
- **Code Quality**: Simplified `RectifierConfig` implementation using `#[derive(Default)]`
### Fixed
- **Windows Platform**:
- Fixed terminal window closing immediately after execution on Windows
- Corrected OpenCode config path resolution on Windows
- **UI Improvements**:
- Fixed ProviderIcon color validation to prevent black icons from appearing
- Unified layout padding across all panels for consistent spacing
- Fixed panel content alignment with header constraints
- **Code Quality**: Resolved Rust Clippy warnings and applied consistent formatting
---
## [3.10.0] - 2026-01-21
+35 -7
View File
@@ -2,7 +2,7 @@
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
[![Version](https://img.shields.io/badge/version-3.10.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.10.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -15,16 +15,18 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANG
## ❤️Sponsor
[![Zhipu GLM](assets/partners/banners/glm-en.jpg)](https://z.ai/subscribe?ic=8JVLJQFSKB)
[![MiniMax](assets/partners/banners/minimax-en.jpeg)](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
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)!
MiniMax-M2.5 is a SOTA large language model designed for real-world productivity. Trained in a diverse range of complex real-world digital working environments, M2.5 builds upon the coding expertise of M2.1 to extend into general office work, reaching fluency in generating and operating Word, Excel, and Powerpoint files, context switching between diverse software environments, and working across different agent and human teams. Scoring 80.2% on SWE-Bench Verified, 51.3% on Multi-SWE-Bench, and 76.3% on BrowseComp, M2.5 is also more token efficient than previous generations, having been trained to optimize its actions and output through planning.
[Click](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link) to get an exclusive 12% off the MiniMax Coding Plan!
---
<table>
<tr>
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during recharge to get 10% off.</td>
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during first recharge to get 10% off.</td>
</tr>
<tr>
@@ -33,8 +35,9 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
</tr>
<tr>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
<td>Thanks to DMXAPI for sponsoring this project! DMXAPI provides global large model API services to 200+ enterprise users. One API key for all global models. Features include: instant invoicing, unlimited concurrency, starting from $0.15, 24/7 technical support. GPT/Claude/Gemini all at 32% off, domestic models 20-50% off, Claude Code exclusive models at 66% off! <a href="https://www.dmxapi.cn/register?aff=bUHu">Register here</a></td>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support.
Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CC Switch users: register via <a href="https://www.aicodemirror.com/register?invitecode=9915W3">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
</tr>
<tr>
@@ -42,6 +45,31 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
<td>Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more with flexible billing options including pay-as-you-go and monthly plans. Cubence provides special discounts for CC Switch users: register using <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">this link</a> and enter the "CCSWITCH" promo code during recharge to get 10% off every top-up!</td>
</tr>
<tr>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
<td>Thanks to DMXAPI for sponsoring this project! DMXAPI provides global large model API services to 200+ enterprise users. One API key for all global models. Features include: instant invoicing, unlimited concurrency, starting from $0.15, 24/7 technical support. GPT/Claude/Gemini all at 32% off, domestic models 20-50% off, Claude Code exclusive models at 66% off! <a href="https://www.dmxapi.cn/register?aff=bUHu">Register here</a></td>
</tr>
<tr>
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
<td>Thank you to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini. It features a highly cost-effective Codex monthly subscription plan and <strong>supports quota rollovers—unused quota from one day can be carried over and used the next day.</strong> Invoices are available upon top-up. Enterprise and team users can receive dedicated one-on-one support. Right Code also offers an exclusive discount for CC Switch users: register via <a href="https://www.right.codes/register?aff=CCSWITCH">this link</a>, and with every top-up you will receive pay-as-you-go credit equivalent to 25% of the amount paid.</td>
</tr>
<tr>
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
<td>Thanks to AICoding.sh for sponsoring this project! AICoding.sh — Global AI Model API Relay Service at Unbeatable Prices! Claude Code at 19% of original price, GPT at just 1%! Trusted by hundreds of enterprises for cost-effective AI services. Supports Claude Code, GPT, Gemini and major domestic models, with enterprise-grade high concurrency, fast invoicing, and 24/7 dedicated technical support. CC Switch users who register via <a href="https://aicoding.sh/i/CCSWITCH">this link</a> get 10% off their first top-up!</td>
</tr>
<tr>
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
<td>Thanks to Crazyrouter for sponsoring this project! Crazyrouter is a high-performance AI API aggregation platform — one API key for 300+ models including Claude Code, Codex, Gemini CLI, and more. All models at 55% of official pricing with auto-failover, smart routing, and unlimited concurrency. Crazyrouter offers an exclusive deal for CC Switch users: register via <a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">this link</a> to get <strong>$2 free credit</strong> instantly, plus enter promo code `CCSWITCH` on your first top-up for an extra <strong>30% bonus credit</strong>! </td>
</tr>
<tr>
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
<td>Thanks to SSSAiCode for sponsoring this project! SSSAiCode is a stable and reliable API relay service, dedicated to providing stable, reliable, and affordable Claude and Codex model services, <strong>offering high cost-effective official Claude service at just ¥0.5/$ equivalent</strong>, supporting monthly and pay-as-you-go billing plans with same-day fast invoicing. SSSAiCode offers a special deal for CC Switch users: register via <a href="https://www.sssaicode.com/register?ref=DCP0SM">this link</a> to enjoy $10 extra credit on every top-up!</td>
</tr>
</table>
## Screenshots
@@ -52,7 +80,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
## Features
### Current Version: v3.10.0 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
### Current Version: v3.10.2 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
**v3.8.0 Major Update (2025-11-28)**
+34 -6
View File
@@ -2,7 +2,7 @@
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
[![Version](https://img.shields.io/badge/version-3.10.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.10.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -15,9 +15,11 @@
## ❤️スポンサー
[![Zhipu GLM](assets/partners/banners/glm-en.jpg)](https://z.ai/subscribe?ic=8JVLJQFSKB)
[![MiniMax](assets/partners/banners/minimax-en.jpeg)](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
本プロジェクトは 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% オフになります
MiniMax-M2.5 は、実際の生産性向上のために設計された最先端の大規模言語モデルです。多様で複雑な実環境のデジタルワークスペースでトレーニングされた M2.5 は、M2.1 のコーディング能力をベースに一般的なオフィス業務へと拡張し、Word・Excel・PowerPoint ファイルの生成と操作、多様なソフトウェア環境間のコンテキスト切り替え、異なるエージェントや人間チーム間での協働を流暢にこなします。SWE-Bench Verified で 80.2%、Multi-SWE-Bench で 51.3%、BrowseComp で 76.3% を達成し、計画的な行動と出力の最適化トレーニングにより、前世代よりもトークン効率に優れています
[こちら](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)から MiniMax Coding Plan の限定 12% オフを入手!
---
@@ -33,8 +35,9 @@
</tr>
<tr>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
<td>DMXAPI のご支援に感謝します!DMXAPI は 200 社以上の企業ユーザーにグローバル大規模モデル API サービスを提供しています。1 つの API キーで全世界のモデルにアクセス可能。即時請求書発行、同時接続数無制限、最低 $0.15 から、24 時間年中無休のテクニカルサポート。GPT/Claude/Gemini が全て 32% オフ、国内モデルは 20〜50% オフ、Claude Code 専用モデルは 66% オフ実施中!<a href="https://www.dmxapi.cn/register?aff=bUHu">登録はこちら</a></td>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定リレーサービスを提供しており、エンタープライズ級の同時接続、迅速な請求書発行、24時間年中無休の専用テクニカルサポートを備えています。
Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / 2% / 9%、チャージ時にはさらに割引!AICodeMirror は CC Switch ユーザー向けに特別特典を用意:<a href="https://www.aicodemirror.com/register?invitecode=9915W3">このリンク</a>から登録すると初回チャージ 20% オフ、法人のお客様は最大 25% オフ!</td>
</tr>
<tr>
@@ -42,6 +45,31 @@
<td>Cubence のご支援に感謝します!Cubence は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームで、従量課金や月額プランなど柔軟な料金体系を提供しています。CC Switch ユーザー向けの特別割引:<a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">このリンク</a>で登録し、チャージ時に「CCSWITCH」クーポンを入力すると、毎回 10% オフになります!</td>
</tr>
<tr>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
<td>DMXAPI のご支援に感謝します!DMXAPI は 200 社以上の企業ユーザーにグローバル大規模モデル API サービスを提供しています。1 つの API キーで全世界のモデルにアクセス可能。即時請求書発行、同時接続数無制限、最低 $0.15 から、24 時間年中無休のテクニカルサポート。GPT/Claude/Gemini が全て 32% オフ、国内モデルは 20〜50% オフ、Claude Code 専用モデルは 66% オフ実施中!<a href="https://www.dmxapi.cn/register?aff=bUHu">登録はこちら</a></td>
</tr>
<tr>
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
<td>本プロジェクトへのご支援として、Right Code にご協賛いただき誠にありがとうございます。Right Code は、Claude Code、Codex、Gemini などのモデルに対応した中継(プロキシ)サービスを安定して提供しています。特に高いコストパフォーマンスを誇る Codex の月額プランを主力としており、<strong>未使用分の利用枠を翌日に繰り越して利用できる(繰越対応)</strong>点が特長です。チャージ(入金)後に請求書の発行が可能で、企業・チーム向けには専任担当による個別対応も行っています。さらに CC Switch ユーザー向けの特別優待として、<a href="https://www.right.codes/register?aff=CCSWITCH">こちらのリンク</a>からご登録いただくと、チャージのたびに実支払額の 25% 相当の従量課金クレジットが付与されます。</td>
</tr>
<tr>
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
<td>AICoding.sh のご支援に感謝します!AICoding.sh —— グローバル AI モデル API 超お得な中継サービス!Claude Code 81% オフ、GPT 99% オフ!数百社の企業に高コストパフォーマンスの AI サービスを提供。Claude Code、GPT、Gemini および国内主要モデルに対応、エンタープライズ級の高同時接続、迅速な請求書発行、24 時間年中無休の専属テクニカルサポート。<a href="https://aicoding.sh/i/CCSWITCH">こちらのリンク</a>から登録した CC Switch ユーザーは、初回チャージ 10% オフ!</td>
</tr>
<tr>
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
<td>Crazyrouter のご支援に感謝します!Crazyrouter は高性能 AI API アグリゲーションプラットフォームです。1 つの API キーで Claude Code、Codex、Gemini CLI など 300 以上のモデルにアクセス可能。全モデルが公式価格の 55% で利用でき、自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。CC Switch ユーザー向けの限定特典:<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">こちらのリンク</a>から登録すると <strong>$2 の無料クレジット</strong> を即時進呈。さらに初回チャージ時にプロモコード `CCSWITCH` を入力すると <strong>30% のボーナスクレジット</strong> が追加されます!</td>
</tr>
<tr>
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
<td>SSSAiCode のご支援に感謝します!SSSAiCode は安定性と信頼性に優れた API 中継サービスで、安定的で信頼性が高く、手頃な価格の Claude・Codex モデルサービスを提供しています。<strong>高コストパフォーマンスの公式 Claude サービスを 0.5¥/$ 換算で提供</strong>、月額制・Paygo など多様な課金方式に対応し、当日の迅速な請求書発行をサポート。CC Switch ユーザー向けの特別特典:<a href="https://www.sssaicode.com/register?ref=DCP0SM">こちらのリンク</a>から登録すると、毎回のチャージで $10 の追加ボーナスを受けられます!</td>
</tr>
</table>
## スクリーンショット
@@ -52,7 +80,7 @@
## 特長
### 現在のバージョン:v3.10.0 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md)
### 現在のバージョン:v3.10.2 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md)
**v3.8.0 メジャーアップデート (2025-11-28)**
+36 -7
View File
@@ -2,7 +2,7 @@
# Claude Code / Codex / Gemini CLI 全方位辅助工具
[![Version](https://img.shields.io/badge/version-3.10.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.10.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -15,22 +15,36 @@
## ❤️赞助商
[![智谱 GLM](assets/partners/banners/glm-zh.jpg)](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
[![MiniMax](assets/partners/banners/minimax-zh.jpeg)](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)
感谢智谱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)购买可以享受九折优惠
MiniMax M2.5 在编程、工具调用与搜索、办公等核心生产力场景均达到或刷新行业 SOTA,拥有架构师级代码能力与高效任务拆解能力,推理速度较上一代提升 37%、token 消耗更优;100 token/s 连续工作一小时仅需 1 美金,让复杂 Agent 规模化部署经济可行,已在企业多职能场景深度落地,加速全民 Agent 时代到来
[点击](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)即可领取 MiniMax Coding Plan 专属 88 折优惠!
---
<table>
<tr>
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,可以享受9折优惠!</td>
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,首次充值可以享受9折优惠!</td>
</tr>
<tr>
<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.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。
Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 CCSwitch 的用户提供了特别福利,通过<a href="https://www.aicodemirror.com/register?invitecode=9915W3">此链接</a>注册的用户,可享受首充8折,企业客户最高可享 7.5 折!</td>
</tr>
<tr>
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
<td>感谢 Cubence 赞助本项目!Cubence 是一家可靠高效的 API 中继服务提供商,提供对 Claude Code、Codex、Gemini 等模型的中继服务,并提供按量、包月等灵活的计费方式。Cubence 为 CC Switch 的用户提供了特别优惠:使用 <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">此链接</a> 注册,并在充值时输入 "CCSWITCH" 优惠码,每次充值均可享受九折优惠!</td>
</tr>
<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用全球大模型。
@@ -38,8 +52,23 @@
</tr>
<tr>
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
<td>感谢 Cubence 赞助本项目!Cubence 是一家可靠高效的 API 中继服务提供商,提供对 Claude Code、Codex、Gemini 等模型的中服务,并提供按量、包月等灵活的计费方式。Cubence 为 CC Switch 的用户提供了特别优惠:使用 <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">此链接</a> 注册,并在充值时输入 "CCSWITCH" 优惠码,每次充值均可享受九折优惠</td>
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
<td>感谢 Right Code 赞助本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中服务。主打<strong>极高性价比</strong>的Codex包月套餐,<strong>提供额度转结,套餐当天用不完的额度,第二天还能接着用!</strong>充值即可开票,企业、团队用户一对一对接。同时为 CC Switch 的用户提供了特别优惠:通过<a href="https://www.right.codes/register?aff=CCSWITCH">此链接</a>注册,每次充值均可获得实付金额25%的按量额度</td>
</tr>
<tr>
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
<td>感谢 AICoding.sh 赞助了本项目!AICoding.sh —— 全球大模型 API 超值中转服务!Claude Code 1.9 折,GPT 0.1 折,已为数百家企业提供高性价比 AI 服务。支持 Claude Code、GPT、Gemini 及国内主流模型,企业级高并发、极速开票、7×24 专属技术支持,通过<a href="https://aicoding.sh/i/CCSWITCH">此链接</a> 注册的 CC Switch 用户,首充可享受九折优惠!</td>
</tr>
<tr>
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
<td>感谢 Crazyrouter 赞助了本项目!Crazyrouter 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 300+ 模型,包括 Claude Code、Codex、Gemini CLI 等。全部模型低至官方定价的 55%,支持自动故障转移、智能路由和无限并发。Crazyrouter 为 CC Switch 用户提供了专属优惠:通过<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">此链接</a>注册即可获得 <strong>$2 免费额度</strong>,首次充值时输入优惠码 `CCSWITCH` 还可获得额外 <strong>30% 奖励额度</strong></td>
</tr>
<tr>
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
<td>感谢 SSSAiCode 赞助了本项目!SSSAiCode 是一家稳定可靠的API中转站,致力于提供稳定、可靠、平价的Claude、CodeX模型服务,<strong>提供高性价比折合0.5¥/$的官方Claude服务</strong>,支持包月、Paygo多种计费方式、支持当日快速开票,SSSAiCode为本软件的用户提供特别优惠,使用<a href="https://www.sssaicode.com/register?ref=DCP0SM">此链接</a>注册每次充值均可享受10$的额外奖励!</td>
</tr>
</table>
@@ -52,7 +81,7 @@
## 功能特性
### 当前版本:v3.10.0 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md)
### 当前版本:v3.10.2 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md)
**v3.8.0 重大更新(2025-11-28**
Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

@@ -0,0 +1,162 @@
/**
* 统一供应商(Universal Provider)预设配置
*
* 统一供应商是跨应用共享的配置,修改后会自动同步到 Claude、Codex、Gemini 三个应用。
* 适用于 NewAPI 等支持多种协议的 API 网关。
*/
import type {
UniversalProvider,
UniversalProviderApps,
UniversalProviderModels,
} from "@/types";
/**
* 统一供应商预设接口
*/
export interface UniversalProviderPreset {
/** 预设名称 */
name: string;
/** 供应商类型标识 */
providerType: string;
/** 默认启用的应用 */
defaultApps: UniversalProviderApps;
/** 默认模型配置 */
defaultModels: UniversalProviderModels;
/** 网站链接 */
websiteUrl?: string;
/** 图标名称 */
icon?: string;
/** 图标颜色 */
iconColor?: string;
/** 描述 */
description?: string;
/** 是否为自定义模板(允许用户完全自定义) */
isCustomTemplate?: boolean;
}
/**
* NewAPI 默认模型配置
*/
const NEWAPI_DEFAULT_MODELS: UniversalProviderModels = {
claude: {
model: "claude-sonnet-4-20250514",
haikuModel: "claude-haiku-4-20250514",
sonnetModel: "claude-sonnet-4-20250514",
opusModel: "claude-sonnet-4-20250514",
},
codex: {
model: "gpt-4o",
reasoningEffort: "high",
},
gemini: {
model: "gemini-2.5-pro",
},
};
const N1N_DEFAULT_MODELS: UniversalProviderModels = {
claude: {
model: "claude-3-5-sonnet-20240620",
haikuModel: "claude-3-haiku-20240307",
sonnetModel: "claude-3-5-sonnet-20240620",
opusModel: "claude-3-opus-20240229",
},
codex: {
model: "gpt-4o",
reasoningEffort: "high",
},
gemini: {
model: "gemini-1.5-pro-latest",
},
};
/**
* 统一供应商预设列表
*/
export const universalProviderPresets: UniversalProviderPreset[] = [
{
name: "n1n.ai",
providerType: "n1n",
defaultApps: {
claude: true,
codex: true,
gemini: true,
},
defaultModels: N1N_DEFAULT_MODELS,
websiteUrl: "https://n1n.ai",
icon: "openai",
iconColor: "#000000",
description:
"n1n.ai - 聚合 OpenAI, Anthropic, Google 等主流大模型的一站式 AI 服务平台",
},
{
name: "NewAPI",
providerType: "newapi",
defaultApps: {
claude: true,
codex: true,
gemini: true,
},
defaultModels: NEWAPI_DEFAULT_MODELS,
websiteUrl: "https://www.newapi.pro",
icon: "newapi",
iconColor: "#00A67E",
description:
"NewAPI 是一个可自部署的 API 网关,支持 Anthropic、OpenAI、Gemini 等多种协议",
},
{
name: "自定义网关",
providerType: "custom_gateway",
defaultApps: {
claude: true,
codex: true,
gemini: true,
},
defaultModels: NEWAPI_DEFAULT_MODELS,
icon: "openai",
iconColor: "#6366F1",
description: "自定义配置的 API 网关",
isCustomTemplate: true,
},
];
/**
* 根据预设创建统一供应商
*/
export function createUniversalProviderFromPreset(
preset: UniversalProviderPreset,
id: string,
baseUrl: string,
apiKey: string,
customName?: string,
): UniversalProvider {
return {
id,
name: customName || preset.name,
providerType: preset.providerType,
apps: { ...preset.defaultApps },
baseUrl,
apiKey,
models: JSON.parse(JSON.stringify(preset.defaultModels)), // Deep copy
websiteUrl: preset.websiteUrl,
icon: preset.icon,
iconColor: preset.iconColor,
createdAt: Date.now(),
};
}
/**
* 获取预设的显示名称(用于 UI)
*/
export function getPresetDisplayName(preset: UniversalProviderPreset): string {
return preset.name;
}
/**
* 根据类型查找预设
*/
export function findPresetByType(
providerType: string,
): UniversalProviderPreset | undefined {
return universalProviderPresets.find((p) => p.providerType === providerType);
}
+206
View File
@@ -0,0 +1,206 @@
# CC Switch v3.10.0
> OpenCode Support, Global Proxy, Claude Rectifier & Multi-App Experience Enhancements
**[中文版 →](release-note-v3.10.0-zh.md) | [日本語版 →](release-note-v3.10.0-ja.md)**
---
## Overview
CC Switch v3.10.0 introduces OpenCode support, becoming the fourth managed CLI application.
This release also brings global proxy settings, Claude Rectifier (thinking signature fixer), enhanced health checks, per-provider configuration, and many other important features, along with comprehensive improvements to multi-app workflows and terminal experience.
**Release Date**: 2026-01-21
---
## Highlights
- OpenCode Support: Full management of providers, MCP servers, and Skills with auto-import on first launch
- Global Proxy: Configure a unified proxy for all outbound network requests
- Claude Rectifier: Thinking signature fixer for better compatibility with third-party APIs
- Enhanced Health Checks: Configurable prompts and CLI-compatible request format
- Per-Provider Config: Persistent provider-specific configuration support
- App Visibility Control: Freely show/hide apps with synchronized tray menu updates
- Terminal Improvements: Provider-specific terminal buttons, fnm path support, cross-platform safe launch
- WSL Tool Detection: Detect tool versions in WSL environment with security hardening
---
## Main Features
### OpenCode Support (New Fourth App)
- Complete OpenCode provider management: add, edit, switch, delete
- MCP server management: unified architecture with Claude/Codex/Gemini
- Skills support: OpenCode can also use Skills functionality
- Auto-import on first launch: automatically imports existing OpenCode configuration when detected
- Full internationalization: Chinese/English/Japanese support (#695)
### Global Proxy
- Configure a unified proxy for all outbound network requests (#596, thanks @yovinchen)
- Supports HTTP/HTTPS proxy protocols
- Suitable for network environments requiring proxy access to external APIs
### Claude Rectifier (Thinking Signature Fixer)
- Automatically fixes Claude API thinking signatures (#595, thanks @yovinchen)
- Resolves incompatible thinking block formats returned by some third-party API gateways
- Can be enabled/disabled in Advanced Settings
### Enhanced Health Checks
- Configurable custom prompts for streaming health checks (#623, thanks @yovinchen)
- Supports CLI-compatible request format for better simulation of real usage scenarios
- Improves fault detection accuracy
### Per-Provider Config
- Support for saving configuration separately for each provider (#663, thanks @yovinchen)
- Persistent configuration: provider-specific settings retained after restart
- Suitable for scenarios where different providers require different configurations
### App Visibility Control
- Freely show/hide any app (Gemini hidden by default)
- Tray menu automatically syncs visibility settings
- Hidden apps won't appear in the main interface or tray menu
### Takeover Compact Mode
- Automatically uses compact layout when 3 or more visible apps are displayed
- Optimizes space utilization in multi-app scenarios
### Terminal Improvements
- Provider-specific terminal button: one-click to use current provider in terminal (#564, thanks @kkkman22)
- `fnm` path support: automatically recognizes Node.js paths managed by fnm
- Cross-platform safe launch: improved terminal launch logic for Windows/macOS/Linux
### WSL Tool Detection
- Detect tool versions in WSL environment (#627, thanks @yovinchen)
- Added security hardening to prevent command injection risks
### Skills Preset Enhancements
- Added `baoyu-skills` preset repository
- Automatically supplements missing default repositories for out-of-the-box experience
---
## Experience Improvements
- Keyboard shortcuts: Press `ESC` to quickly return/close panels (#670, thanks @xxk8)
- Simplified proxy logs: cleaner and more readable output (#585, thanks @yovinchen)
- Pricing editor UX: unified `FullScreenPanel` style
- Advanced settings layout: Rectifier section moved below Failover for better logical flow
- OpenRouter compatibility mode: disabled by default, UI toggle hidden (reduces clutter)
---
## Bug Fixes
### Proxy & Failover
- Immediately switch to P1 when auto-failover is enabled (instead of waiting for next request)
### Provider Management
- Fixed stale data when reopening provider edit dialog after save (#654, thanks @YangYongAn)
- Fixed baseUrl and apiKey state not resetting when switching presets
- Fixed endpoint auto-selection state not persisting (#611, thanks @yovinchen)
- Automatically apply default color when icon color is not set
### Deep Links
- Support multi-endpoint import (#597, thanks @yovinchen)
- Prefer `GOOGLE_GEMINI_BASE_URL` over `GEMINI_BASE_URL`
### MCP
- Skip `cmd /c` wrapper for WSL target paths (#592, thanks @cxyfer)
### Usage Templates
- Added variable hints, fixed validation issues (#628, thanks @YangYongAn)
- Prevent configuration leakage between providers
- Usage block offset automatically adapts to action button width (#613, thanks @yovinchen)
### Gemini
- Convert timeout parameters to Gemini CLI format (#580, thanks @cxyfer)
### UI
- Fixed Select dropdown rendering issues in `FullScreenPanel`
---
## Notes & Considerations
- **OpenCode is a newly supported app**: OpenCode CLI must be installed first to use related features.
- **Global proxy affects all outbound requests**: including usage queries, health checks, and other network operations.
- **Rectifier is experimental**: can be disabled in Advanced Settings if issues occur.
---
## Special Thanks
Thanks to @yovinchen @YangYongAn @cxyfer @xxk8 @kkkman22 @Shuimo03 for their contributions to this release!
Thanks to @libukai for designing the elegant failover-related UI!
---
## Download & Installation
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
### System Requirements
| System | Minimum Version | Architecture |
| ------- | ------------------------------ | ----------------------------------- |
| Windows | Windows 10 or later | x64 |
| macOS | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |
| Linux | See table below | x64 |
### Windows
| File | Description |
| ---------------------------------------- | ---------------------------------------------------- |
| `CC-Switch-v3.10.0-Windows.msi` | **Recommended** - MSI installer with auto-update |
| `CC-Switch-v3.10.0-Windows-Portable.zip` | Portable version, extract and run, no registry write |
### macOS
| File | Description |
| -------------------------------- | ------------------------------------------------------------------ |
| `CC-Switch-v3.10.0-macOS.zip` | **Recommended** - Extract and drag to Applications, Universal Binary |
| `CC-Switch-v3.10.0-macOS.tar.gz` | For Homebrew installation and auto-update |
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and it will open normally afterwards.
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
Update:
```bash
brew upgrade --cask cc-switch
```
### Linux
| Distribution | Recommended Format | Installation Method |
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | Add execute permission and run directly, or use AUR |
| Other distributions / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+206
View File
@@ -0,0 +1,206 @@
# CC Switch v3.10.0
> OpenCode サポート、グローバルプロキシ、Claude Rectifier とマルチアプリ体験の強化
**[中文版 →](release-note-v3.10.0-zh.md) | [English →](release-note-v3.10.0-en.md)**
---
## 概要
CC Switch v3.10.0 では OpenCode サポートが追加され、4番目の管理対象 CLI アプリケーションとなりました。
また、グローバルプロキシ設定、Claude Rectifierthinking 署名修正機能)、ヘルスチェックの強化、プロバイダー別設定など、多くの重要な機能が追加され、マルチアプリワークフローとターミナル体験が全面的に改善されました。
**リリース日**: 2026-01-21
---
## ハイライト
- OpenCode サポート:プロバイダー、MCP サーバー、Skills の完全管理、初回起動時の自動インポート
- グローバルプロキシ:すべての送信ネットワークリクエストに統一プロキシを設定
- Claude Rectifierthinking 署名修正機能、サードパーティ API との互換性向上
- ヘルスチェック強化:カスタムプロンプト設定、CLI 互換リクエスト形式
- プロバイダー別設定:プロバイダー固有の設定の永続化をサポート
- アプリ表示制御:アプリの表示/非表示を自由に設定、トレイメニューと同期
- ターミナル改善:プロバイダー専用ターミナルボタン、fnm パスサポート、クロスプラットフォーム安全起動
- WSL ツール検出:WSL 環境でのツールバージョン検出とセキュリティ強化
---
## 主な機能
### OpenCode サポート(新しい4番目のアプリ)
- 完全な OpenCode プロバイダー管理:追加、編集、切り替え、削除
- MCP サーバー管理:Claude/Codex/Gemini と統一されたアーキテクチャ
- Skills サポート:OpenCode でも Skills 機能を使用可能
- 初回起動時の自動インポート:既存の OpenCode 設定を検出すると自動的にインポート
- 完全な国際化:中国語/英語/日本語サポート (#695)
### グローバルプロキシ
- すべての送信ネットワークリクエストに統一プロキシを設定 (#596@yovinchen に感謝)
- HTTP/HTTPS プロキシプロトコルをサポート
- 外部 API へのプロキシアクセスが必要なネットワーク環境に適用
### Claude RectifierThinking 署名修正機能)
- Claude API の thinking 署名を自動修正 (#595@yovinchen に感謝)
- 一部のサードパーティ API ゲートウェイが返す互換性のない thinking ブロック形式を解決
- 詳細設定で有効/無効を切り替え可能
### ヘルスチェック強化
- ストリーミングヘルスチェック用のカスタムプロンプトを設定可能 (#623@yovinchen に感謝)
- CLI 互換リクエスト形式をサポートし、実際の使用シナリオをより良くシミュレート
- 障害検出の精度を向上
### プロバイダー別設定
- 各プロバイダーごとに設定を個別に保存可能 (#663@yovinchen に感謝)
- 設定の永続化:再起動後もプロバイダー固有の設定を保持
- 異なるプロバイダーに異なる設定が必要なシナリオに適用
### アプリ表示制御
- 任意のアプリを自由に表示/非表示(Gemini はデフォルトで非表示)
- トレイメニューは表示設定と自動的に同期
- 非表示のアプリはメインインターフェースとトレイメニューに表示されない
### Takeover コンパクトモード
- 3つ以上の表示アプリがある場合、自動的にコンパクトレイアウトを使用
- マルチアプリシナリオでのスペース利用を最適化
### ターミナル改善
- プロバイダー専用ターミナルボタン:ワンクリックでターミナルで現在のプロバイダーを使用 (#564@kkkman22 に感謝)
- `fnm` パスサポート:fnm で管理された Node.js パスを自動認識
- クロスプラットフォーム安全起動:Windows/macOS/Linux のターミナル起動ロジックを改善
### WSL ツール検出
- WSL 環境でツールバージョンを検出 (#627@yovinchen に感謝)
- コマンドインジェクションリスクを防ぐためのセキュリティ強化を追加
### Skills プリセット強化
- `baoyu-skills` プリセットリポジトリを追加
- 不足しているデフォルトリポジトリを自動補完し、すぐに使える状態を確保
---
## 体験の改善
- キーボードショートカット:`ESC` を押してパネルをすばやく戻る/閉じる (#670@xxk8 に感謝)
- プロキシログの簡素化:より明確で読みやすい出力 (#585@yovinchen に感謝)
- 価格エディター UX:統一された `FullScreenPanel` スタイル
- 詳細設定レイアウト:Rectifier セクションを Failover の下に移動し、論理的な流れを改善
- OpenRouter 互換モード:デフォルトで無効、UI トグルを非表示(煩雑さを軽減)
---
## バグ修正
### プロキシとフェイルオーバー
- 自動フェイルオーバーが有効な場合、すぐに P1 に切り替え(次のリクエストを待たずに)
### プロバイダー管理
- 保存後にプロバイダー編集ダイアログを再度開いたときにデータが古い問題を修正 (#654@YangYongAn に感謝)
- プリセット切り替え時に baseUrl と apiKey の状態がリセットされない問題を修正
- エンドポイント自動選択状態が永続化されない問題を修正 (#611@yovinchen に感謝)
- アイコンカラーが設定されていない場合、デフォルトカラーを自動適用
### ディープリンク
- マルチエンドポイントインポートをサポート (#597@yovinchen に感謝)
- `GEMINI_BASE_URL` より `GOOGLE_GEMINI_BASE_URL` を優先
### MCP
- WSL ターゲットパスの `cmd /c` ラッパーをスキップ (#592@cxyfer に感謝)
### 使用量テンプレート
- 変数ヒントを追加、検証の問題を修正 (#628@YangYongAn に感謝)
- プロバイダー間での設定漏洩を防止
- 使用量ブロックのオフセットがアクションボタンの幅に自動適応 (#613@yovinchen に感謝)
### Gemini
- タイムアウトパラメータを Gemini CLI 形式に変換 (#580@cxyfer に感謝)
### UI
- `FullScreenPanel` での Select ドロップダウンのレンダリング問題を修正
---
## 注意事項
- **OpenCode は新しくサポートされたアプリです**:関連機能を使用するには、まず OpenCode CLI をインストールする必要があります。
- **グローバルプロキシはすべての送信リクエストに影響します**:使用量クエリ、ヘルスチェックなどのネットワーク操作を含みます。
- **Rectifier は実験的機能です**:問題が発生した場合は、詳細設定で無効にできます。
---
## 特別な感謝
@yovinchen @YangYongAn @cxyfer @xxk8 @kkkman22 @Shuimo03 の皆様、このリリースへの貢献に感謝します!
@libukai 様、エレガントなフェイルオーバー関連 UI のデザインに感謝します!
---
## ダウンロードとインストール
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。
### システム要件
| システム | 最小バージョン | アーキテクチャ |
| -------- | -------------------------------- | ----------------------------------- |
| Windows | Windows 10 以降 | x64 |
| macOS | macOS 10.15 (Catalina) 以降 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 下表参照 | x64 |
### Windows
| ファイル | 説明 |
| ---------------------------------------- | ---------------------------------------------------- |
| `CC-Switch-v3.10.0-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
| `CC-Switch-v3.10.0-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし |
### macOS
| ファイル | 説明 |
| -------------------------------- | ----------------------------------------------------------------- |
| `CC-Switch-v3.10.0-macOS.zip` | **推奨** - 解凍して Applications にドラッグ、Universal Binary |
| `CC-Switch-v3.10.0-macOS.tar.gz` | Homebrew インストールと自動更新用 |
> **注意**:作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元を確認できません」という警告が表示される場合があります。一度閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、その後は正常に開けます。
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
| ディストリビューション | 推奨形式 | インストール方法 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 実行権限を追加して直接実行、または AUR を使用 |
| その他のディストリビューション / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+206
View File
@@ -0,0 +1,206 @@
# CC Switch v3.10.0
> OpenCode 支持、全局代理、Claude Rectifier 与多应用体验增强
**[English →](release-note-v3.10.0-en.md) | [日本語版 →](release-note-v3.10.0-ja.md)**
---
## 概览
CC Switch v3.10.0 新增 OpenCode 支持,成为第四个受管理的 CLI 应用。
同时带来全局代理设置、Claude Rectifierthinking 签名修正器)、健康检查增强、按供应商配置等多项重要功能,并对多应用工作流与终端体验做了全面改进。
**发布日期**2026-01-21
---
## 重点内容
- OpenCode 支持:供应商、MCP 服务器、Skills 全面管理,首次启动自动导入
- 全局代理:为出站网络请求统一配置代理
- Claude Rectifierthinking 签名修正器,兼容更多第三方 API
- 健康检查增强:可配置提示词、CLI 兼容请求
- 按供应商配置:支持供应商特定配置的持久化
- 应用可见性控制:自由显示/隐藏应用,托盘菜单同步更新
- 终端改进:供应商专属终端按钮、fnm 路径支持、跨平台安全启动
- WSL 工具检测:在 WSL 环境检测工具版本,并增加安全加固
---
## 主要功能
### OpenCode 支持(新增第四应用)
- 完整的 OpenCode 供应商管理:新增、编辑、切换、删除
- MCP 服务器管理:与 Claude/Codex/Gemini 统一架构
- Skills 支持:OpenCode 也可使用 Skills 功能
- 首次启动自动导入:检测到已有 OpenCode 配置时自动导入
- 完整国际化:中/英/日三语支持(#695
### 全局代理(Global Proxy
- 为所有出站网络请求配置统一代理(#596,感谢 @yovinchen
- 支持 HTTP/HTTPS 代理协议
- 适用于需要代理访问外部 API 的网络环境
### Claude RectifierThinking 签名修正器)
- 自动修正 Claude API 的 thinking 签名(#595,感谢 @yovinchen
- 解决部分第三方 API 网关返回的 thinking 块格式不兼容问题
- 在高级设置中可开启/关闭
### 健康检查增强
- 可配置自定义提示词(prompt)用于流式健康检查(#623,感谢 @yovinchen
- 支持 CLI 兼容请求格式,更好地模拟真实使用场景
- 提升故障检测的准确性
### 按供应商配置(Per-Provider Config
- 支持为每个供应商单独保存配置(#663,感谢 @yovinchen
- 配置持久化:重启后保留供应商专属设置
- 适用于不同供应商需要不同配置的场景
### 应用可见性控制
- 自由显示/隐藏任意应用(Gemini 默认隐藏)
- 托盘菜单自动同步可见性设置
- 隐藏的应用不会出现在主界面和托盘菜单中
### Takeover Compact Mode
- 当显示 3 个及以上可见应用时,自动使用紧凑布局
- 优化多应用场景下的空间利用
### 终端改进
- 供应商专属终端按钮:一键在终端中使用当前供应商(#564,感谢 @kkkman22
- `fnm` 路径支持:自动识别 fnm 管理的 Node.js 路径
- 跨平台安全启动:改进 Windows/macOS/Linux 的终端启动逻辑
### WSL 工具检测
- 在 WSL 环境中检测工具版本(#627,感谢 @yovinchen
- 增加安全加固,防止命令注入风险
### Skills 预设增强
- 新增 `baoyu-skills` 预设仓库
- 自动补充缺失的默认仓库,确保开箱即用
---
## 体验优化
- 键盘快捷键:按 `ESC` 快速返回/关闭面板(#670,感谢 @xxk8
- 代理日志简化:输出更清晰易读(#585,感谢 @yovinchen
- 定价编辑器 UX:统一使用 `FullScreenPanel` 风格
- 高级设置布局:Rectifier 区块移至 Failover 下方,逻辑更顺畅
- OpenRouter 兼容模式:默认禁用,UI 开关隐藏(减少干扰)
---
## Bug 修复
### 代理与故障切换
- 启用自动故障切换时立即切换到 P1(而非等待下次请求)
### 供应商管理
- 修复供应商编辑对话框保存后重新打开时数据过时的问题(#654,感谢 @YangYongAn
- 修复切换预设时 baseUrl 和 apiKey 状态未重置的问题
- 修复端点自动选择状态未持久化的问题(#611,感谢 @yovinchen
- 未设置图标颜色时自动应用默认颜色
### 深链接
- 支持多端点导入(#597,感谢 @yovinchen
- 优先使用 `GOOGLE_GEMINI_BASE_URL` 而非 `GEMINI_BASE_URL`
### MCP
- WSL 目标路径跳过 `cmd /c` 包裹(#592,感谢 @cxyfer
### 用量模板
- 新增变量提示,修复验证问题(#628,感谢 @YangYongAn
- 防止配置在供应商之间泄漏
- 用量区块偏移量根据操作按钮宽度自动适应(#613,感谢 @yovinchen
### Gemini
- 超时参数转换为 Gemini CLI 格式(#580,感谢 @cxyfer
### UI
- 修复 `FullScreenPanel` 中 Select 下拉框渲染问题
---
## 说明与注意事项
- **OpenCode 为新支持的应用**:需要先安装 OpenCode CLI 才能使用相关功能。
- **全局代理会影响所有出站请求**:包括用量查询、健康检查等网络操作。
- **Rectifier 功能为实验性**:如遇问题可在高级设置中关闭。
---
## 特别感谢
感谢 @yovinchen @YangYongAn @cxyfer @xxk8 @kkkman22 @Shuimo03 为本版本做出的贡献!
感谢 @libukai 设计的故障转移相关 UI,非常优雅!
---
## 下载与安装
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
### 系统要求
| 系统 | 最低版本 | 架构 |
| ------- | ----------------------------- | ----------------------------------- |
| Windows | Windows 10 及以上 | x64 |
| macOS | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 见下表 | x64 |
### Windows
| 文件 | 说明 |
| ---------------------------------------- | ----------------------------------- |
| `CC-Switch-v3.10.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
| `CC-Switch-v3.10.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
### macOS
| 文件 | 说明 |
| -------------------------------- | --------------------------------------------------------- |
| `CC-Switch-v3.10.0-macOS.zip` | **推荐** - 解压后拖入 Applications 即可,Universal Binary |
| `CC-Switch-v3.10.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
### HomebrewmacOS
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
| 发行版 | 推荐格式 | 安装方式 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb``sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm``sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
@@ -0,0 +1,64 @@
# 1.1 软件介绍
## 什么是 CC Switch
CC Switch 是一款跨平台桌面应用,专为使用 AI 编程工具的开发者设计。它帮助你统一管理 **Claude Code**、**Codex**、**Gemini CLI**、**OpenCode** 四大 AI 编程工具的配置。
## 解决什么问题
在日常开发中,你可能会遇到这些痛点:
- **多供应商切换麻烦**:使用不同的 API 供应商(官方、中转服务商),需要手动修改配置文件
- **配置分散难管理**Claude、Codex、Gemini、OpenCode 各有独立的配置文件,格式不同
- **无法监控用量**:不知道 API 调用了多少次,花了多少钱
- **服务不稳定**:单一供应商出问题时,整个工作流中断
CC Switch 通过统一的界面解决这些问题。
## 核心功能
### 供应商管理
- 一键切换多个 API 供应商配置
- 支持预设模板,快速添加常用供应商
- 统一供应商功能,跨应用共享配置
- 用量查询与余额显示
- 端点速度测试
### 扩展功能
- **MCP 服务器**:管理 Model Context Protocol 服务器,扩展 AI 能力
- **Prompts**:管理系统提示词预设,快速切换不同场景
- **Skills**:安装和管理技能扩展
### 代理与高可用
- 本地代理服务,记录请求日志和用量统计
- 自动故障转移,主供应商失败时自动切换备用
- 熔断器机制,防止频繁重试失败的供应商
- 详细的 Token 用量追踪与成本估算
## 支持的应用
| 应用 | 说明 |
|------|------|
| **Claude Code** | Anthropic 官方的 AI 编程助手 |
| **Codex** | OpenAI 的代码生成工具 |
| **Gemini CLI** | Google 的 AI 命令行工具 |
| **OpenCode** | 开源 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,169 @@
# 1.3 界面概览
## 主界面布局
![image-20260108001629138](../assets/image-20260108001629138.png)
## 顶部导航栏
| 序号 | 元素 | 功能说明 |
|------|------|----------|
| ① | Logo | 点击访问 GitHub 项目页 |
| ② | 设置按钮 | 打开设置页面(快捷键 `Cmd/Ctrl + ,` |
| ③ | 代理开关 | 启动/停止本地代理服务 |
| ④ | 应用切换器 | 切换 Claude / Codex / Gemini / OpenCode |
| ⑤ | 功能区 | Skills / Prompts / MCP 入口 |
| ⑥ | 添加按钮 | 添加新供应商 |
### 应用切换器
点击下拉菜单切换当前管理的应用:
- **Claude** - 管理 Claude Code 配置
- **Codex** - 管理 Codex 配置
- **Gemini** - 管理 Gemini CLI 配置
- **OpenCode** - 管理 OpenCode 配置
切换后,供应商列表会显示对应应用的配置。
### 功能区按钮
| 按钮 | 功能 | 可见条件 |
|------|------|----------|
| Skills | 技能扩展管理 | 始终可见 |
| Prompts | 系统提示词管理 | 始终可见 |
| MCP | MCP 服务器管理 | 始终可见 |
## 供应商卡片
每个供应商以卡片形式展示,从左到右依次包含以下元素:
### 卡片元素(从左到右)
| 序号 | 元素 | 图标 | 功能说明 |
|------|------|------|----------|
| ① | 拖拽手柄 | ≡ | 按住上下拖动调整供应商顺序 |
| ② | 供应商图标 | 🔷 | 显示供应商品牌图标,可自定义颜色 |
| ③ | 供应商信息 | - | 名称、备注/端点地址(可点击打开官网) |
| ④ | 用量信息 | - | 显示剩余额度,多套餐时显示套餐数量 |
| ⑤ | 启用按钮 | ▶ | 切换为当前使用的供应商 |
| ⑥ | 编辑按钮 | ✏️ | 编辑供应商配置 |
| ⑦ | 复制按钮 | 📋 | 复制供应商(创建副本) |
| ⑧ | 测速按钮 | 🧪 | 测试模型可用性和响应速度 |
| ⑨ | 用量查询 | 📊 | 配置用量查询脚本 |
| ⑩ | 删除按钮 | 🗑️ | 删除供应商(当前启用时禁用) |
> 💡 **提示**:操作按钮区域(⑤-⑩)在鼠标悬停时显示,平时隐藏以保持界面简洁。
### 按钮详细说明
| 按钮 | 状态变化 | 说明 |
|------|----------|------|
| **启用** | 已启用时显示 ✓ 并禁用 | 故障转移模式下变为「加入/已加入」 |
| **编辑** | 始终可用 | 打开编辑面板修改配置 |
| **复制** | 始终可用 | 创建供应商副本,名称后缀 `copy` |
| **测速** | 测试中显示加载动画 | 仅代理服务运行时可用 |
| **用量查询** | 始终可用 | 配置自定义用量查询脚本 |
| **删除** | 当前启用时半透明禁用 | 需先切换到其他供应商才能删除 |
### 卡片状态
| 状态 | 边框颜色 | 说明 |
|------|----------|------|
| **当前启用** | 🔵 蓝色边框 | 普通模式下当前使用的供应商 |
| **代理活跃** | 🟢 绿色边框 | 代理接管模式下实际使用的供应商 |
| **普通状态** | 默认边框 | 未启用的供应商 |
| **故障转移中** | 显示优先级徽章 | 如 P1、P2 表示故障转移优先级 |
### 健康状态徽章
在代理模式下,加入故障转移队列的供应商会显示健康状态:
| 徽章 | 颜色 | 说明 |
|------|------|------|
| 健康 | 🟢 绿色 | 连续失败 0 次 |
| 警告 | 🟡 黄色 | 连续失败 1-2 次 |
| 不健康 | 🔴 红色 | 连续失败 ≥3 次,可能触发熔断 |
## 系统托盘
CC Switch 在系统托盘显示图标,提供快速操作入口。
### 托盘菜单结构
![image-20260108002153668](../assets/image-20260108002153668.png)
### 菜单功能
| 菜单项 | 功能 |
|--------|------|
| 打开主界面 | 显示主窗口并聚焦 |
| 应用分组 | 按 Claude/Codex/Gemini/OpenCode 分组显示供应商 |
| 供应商列表 | 点击切换,当前启用的显示勾选标记 |
| 退出 | 完全退出应用 |
### 多语言支持
托盘菜单支持三种语言,根据设置自动切换:
| 语言 | 打开主界面 | 退出 |
|------|-----------|------|
| 中文 | 打开主界面 | 退出 |
| 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
```
点击「复制」按钮可复制到剪贴板。
+320
View File
@@ -0,0 +1,320 @@
# 2.1 添加供应商
## 打开添加面板
点击主界面右上角的 **+** 按钮,打开添加供应商面板。
面板分为两个 Tab
- **应用专属供应商**:仅用于当前选中的应用(Claude/Codex/Gemini/OpenCode
- **统一供应商**:跨应用共享的配置
## 使用预设添加
预设是预先配置好的供应商模板,只需填写 API Key 即可使用。
### 操作步骤
1. 在「预设」下拉框中选择供应商
2. 名称和端点会自动填充
3. 填写你的 **API Key**
4. (可选)填写备注
5. 点击「添加」
### 常用预设
#### Claude 预设
| 预设名称 | 说明 |
|----------|------|
| Claude 官方 | 使用 Anthropic 官方账号登录 |
| DeepSeek | DeepSeek 模型 |
| 智谱 GLM | 智谱 AI 的 GLM 模型 |
| 智谱 GLM en | 智谱 AI(英文版) |
| 百炼 | 阿里云百炼(通义千问) |
| Kimi | Moonshot Kimi 模型 |
| Kimi For Coding | Kimi 编程专用模型 |
| ModelScope | 魔搭社区 |
| KAT-Coder | KAT-Coder 模型 |
| Longcat | Longcat AI |
| MiniMax | MiniMax 模型 |
| MiniMax en | MiniMax(英文版) |
| DouBaoSeed | 豆包 Seed 模型 |
| BaiLing | 百灵 AI |
| AiHubMix | AiHubMix 聚合服务 |
| SiliconFlow | 硅基流动 |
| SiliconFlow en | 硅基流动(英文版) |
| DMXAPI | DMXAPI 中转服务 |
| PackyCode | PackyCode 中转服务 ⭐ |
| Cubence | Cubence 服务 |
| AIGoCode | AIGoCode 服务 |
| RightCode | RightCode 服务 |
| AICodeMirror | AICodeMirror 服务 |
| OpenRouter | 聚合路由服务 |
| Nvidia | Nvidia AI 服务 |
| Xiaomi MiMo | 小米 MiMo 模型 |
> ⭐ 标注为官方合作伙伴。预设列表可能随版本更新,以应用内实际显示为准。
#### Codex 预设
| 预设名称 | 说明 |
|----------|------|
| OpenAI 官方 | 使用 OpenAI 官方账号登录 |
| Azure OpenAI | Azure OpenAI 服务 |
| AiHubMix | AiHubMix 聚合服务 |
| DMXAPI | DMXAPI 中转服务 |
| PackyCode | PackyCode 中转服务 |
| Cubence | Cubence 服务 |
| AIGoCode | AIGoCode 服务 |
| RightCode | RightCode 服务 |
| AICodeMirror | AICodeMirror 服务 |
| OpenRouter | 聚合路由服务 |
#### Gemini 预设
| 预设名称 | 说明 |
|----------|------|
| Google 官方 | 使用 Google OAuth 登录 |
| PackyCode | PackyCode 中转服务 |
| Cubence | Cubence 服务 |
| AIGoCode | AIGoCode 服务 |
| AICodeMirror | AICodeMirror 服务 |
| OpenRouter | 聚合路由服务 |
| 自定义 | 手动配置所有参数 |
#### OpenCode 预设
| 预设名称 | 说明 |
|----------|------|
| DeepSeek | DeepSeek 模型 |
| 智谱 GLM | 智谱 AI 的 GLM 模型 |
| 智谱 GLM en | 智谱 AI(英文版) |
| 百炼 | 阿里云百炼 |
| Kimi k2.5 | Moonshot Kimi-k2.5 模型 |
| Kimi For Coding | Kimi 编程专用模型 |
| ModelScope | 魔搭社区 |
| KAT-Coder | KAT-Coder 模型 |
| Longcat | Longcat AI |
| MiniMax | MiniMax 模型 |
| MiniMax en | MiniMax(英文版) |
| DouBaoSeed | 豆包 Seed 模型 |
| BaiLing | 百灵 AI |
| Xiaomi MiMo | 小米 MiMo 模型 |
| AiHubMix | AiHubMix 聚合服务 |
| DMXAPI | DMXAPI 中转服务 |
| OpenRouter | 聚合路由服务 |
| Nvidia | Nvidia AI 服务 |
| PackyCode | PackyCode 中转服务 |
| Cubence | Cubence 服务 |
| AIGoCode | AIGoCode 服务 |
| RightCode | RightCode 服务 |
| AICodeMirror | AICodeMirror 服务 |
| OpenAI Compatible | OpenAI 兼容接口 |
| Oh My OpenCode | Oh My OpenCode 服务 |
> 💡 预设列表持续更新中,以应用内实际显示为准。
## 自定义配置
选择「自定义」预设后,需要手动编辑 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"
}
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `GEMINI_API_KEY` | 是 | API 密钥 |
| `GOOGLE_GEMINI_BASE_URL` | 否 | 自定义端点地址 |
| `GEMINI_MODEL` | 否 | 指定模型 |
> 💡 认证类型由 CC Switch 自动检测(PackyCode API 代理 / Google OAuth / 通用 API Key),无需手动配置。
## 统一供应商
统一供应商可以跨 Claude/Codex/Gemini/OpenCode 共享配置,适用于支持多种 API 格式的中转服务。
### 创建统一供应商
1. 切换到「统一供应商」Tab
2. 点击「添加统一供应商」
3. 填写通用配置:
- 名称
- API Key
- 端点地址
4. 勾选要同步的应用(Claude/Codex/Gemini/OpenCode
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/OpenCode
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)会安全存储在本地
+207
View File
@@ -0,0 +1,207 @@
# 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` |
| OpenCode | 同步到 OpenCode | `~/.opencode/config.json``mcpServers` |
### 开关实现机制
当开启某个应用的开关时,CC Switch 会:
1. **更新数据库**:将服务器的 `apps.claude/codex/gemini/opencode` 状态设为 `true`
2. **同步到 Live 配置**:将服务器配置写入对应应用的配置文件
3. **即时生效**:下次启动 CLI 工具时自动加载新的 MCP 服务器
当关闭某个应用的开关时,CC Switch 会:
1. **更新数据库**:将对应应用状态设为 `false`
2. **从 Live 配置移除**:从应用配置文件中删除该服务器
3. **即时生效**:下次启动 CLI 工具时不再加载该 MCP 服务器
### 同步条件
MCP 服务器同步仅在对应应用已安装时执行:
- **Claude**:需存在 `~/.claude/` 目录或 `~/.claude.json` 文件
- **Codex**:需存在 `~/.codex/` 目录
- **Gemini**:需存在 `~/.gemini/` 目录
- **OpenCode**:需存在 `~/.opencode/` 目录
> 💡 **提示**:如果某个 CLI 工具未安装,开启对应开关不会报错,但配置不会写入。
关闭开关后,配置会从文件中移除。
## 编辑服务器
1. 点击服务器行右侧的「编辑」按钮
2. 修改配置
3. 点击「保存」
修改会立即同步到已启用的应用配置文件。
## 删除服务器
1. 点击服务器行右侧的「删除」按钮
2. 确认删除
删除后,配置会从所有应用的配置文件中移除。
## 导入现有配置
如果你已经在 CLI 工具中配置了 MCP 服务器,可以导入到 CC Switch:
1. 点击「导入」按钮
2. 选择要导入的应用(Claude/Codex/Gemini/OpenCode
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,158 @@
# 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` |
| OpenCode | `~/.opencode/AGENTS.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 的预设
- 切换到 OpenCode 时,显示 OpenCode 的预设
如需在多个应用使用相同的提示词,需要分别创建。
## 导入导出
### 通过深度链接分享
可以生成深度链接分享预设:
```
ccswitch://import/prompt?data=<base64编码的预设>
```
### 通过配置导出
导出配置时会包含所有预设,导入后可恢复。
+199
View File
@@ -0,0 +1,199 @@
# 3.3 Skills 技能管理
## 功能说明
Skills 是可复用的能力扩展,让 AI 工具获得特定领域的专业能力。
技能以文件夹形式存在,包含:
- 提示词模板
- 工具定义
- 示例代码
## 支持的应用
Skills 功能支持所有四种应用:
- **Claude Code**
- **Codex**
- **Gemini CLI**
- **OpenCode**
## 打开 Skills 页面
点击顶部导航栏的 **Skills** 按钮。
> 注意: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/` |
| Gemini | `~/.gemini/skills/` |
| OpenCode | `~/.opencode/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` |
| 监听端口 | 代理监听的端口 | `15721` |
| 启用日志 | 是否记录请求日志 | 开启 |
### 修改配置
1. **停止代理服务**(必须先停止)
2. 修改监听地址或端口
3. 点击「保存」
4. 重新启动代理
> ⚠️ 修改地址/端口需要先停止代理服务
### 监听地址说明
| 地址 | 说明 |
|------|------|
| `127.0.0.1` | 仅本机可访问(推荐) |
| `0.0.0.0` | 允许局域网访问 |
## 运行状态
代理运行时,面板显示以下信息:
### 服务地址
```
http://127.0.0.1:15721
```
点击「复制」按钮可复制地址。
### 当前供应商
显示各应用当前使用的供应商:
```
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:15721"
}
}
```
**Codex**
```toml
base_url = "http://127.0.0.1:15721/v1"
```
**Gemini**
```
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
```
## 停止代理
### 方式一:主界面开关
点击代理开关按钮关闭。
### 方式二:设置页面
在代理服务面板中关闭开关。
### 停止后的处理
停止代理时,CC Switch 会:
1. 恢复应用配置到原始状态
2. 保存请求日志
3. 关闭所有连接
## 日志记录
### 开启日志
在代理面板中开启「启用日志」开关。
### 日志内容
每条请求记录包含:
| 字段 | 说明 |
|------|------|
| 时间 | 请求时间 |
| 应用 | Claude/Codex/Gemini/OpenCode |
| 供应商 | 使用的供应商 |
| 模型 | 请求的模型 |
| Token | 输入/输出 token 数 |
| 延迟 | 请求耗时 |
| 状态 | 成功/失败 |
### 查看日志
在「设置 → 用量」Tab 中查看请求日志。
## 常见问题
### 端口被占用
错误信息:`Address already in use`
解决方法:
1. 更换端口(如 5001
2. 或关闭占用端口的程序
### 代理启动失败
检查:
- 端口是否被占用
- 是否有足够权限
- 防火墙是否阻止
### 请求超时
可能原因:
- 网络问题
- 供应商服务器问题
- 代理配置错误
解决方法:
- 检查网络连接
- 尝试直接访问供应商 API
- 检查供应商配置
+196
View File
@@ -0,0 +1,196 @@
# 4.2 应用接管
## 功能说明
应用接管是指让 CC Switch 代理接管特定应用的 API 请求。
开启接管后:
- 应用的 API 请求会通过本地代理转发
- 可以记录请求日志和统计用量
- 可以使用故障转移功能
## 前提条件
使用应用接管功能前,需要先启动代理服务。
## 开启接管
### 操作位置
设置 → 高级 → 代理服务 → 应用接管区域
### 操作步骤
1. 确保代理服务已启动
2. 找到「应用接管」区域
3. 为需要的应用开启开关
### 接管开关
| 开关 | 作用 |
|------|------|
| Claude 接管 | 接管 Claude Code 的请求 |
| Codex 接管 | 接管 Codex 的请求 |
| Gemini 接管 | 接管 Gemini CLI 的请求 |
| OpenCode 接管 | 接管 OpenCode 的请求 |
可以同时开启多个应用的接管。
## 接管原理
### 配置修改
开启接管后,CC Switch 会修改应用的配置文件,将 API 端点指向本地代理。
**Claude 配置变更**
```json
// 接管前
{
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
}
}
// 接管后
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
}
}
```
**Codex 配置变更**
```toml
# 接管前
base_url = "https://api.openai.com/v1"
# 接管后
base_url = "http://127.0.0.1:15721/v1"
```
**Gemini 配置变更**
```bash
# 接管前
GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
# 接管后
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
```
### 请求转发
代理收到请求后:
1. 识别请求来源(Claude/Codex/Gemini/OpenCode
2. 查找该应用当前启用的供应商
3. 将请求转发到供应商的实际端点
4. 记录请求日志
5. 返回响应给应用
## 接管状态指示
### 主界面指示
开启接管后,主界面会有以下变化:
- **代理 Logo 颜色**:从无色变为绿色
- **供应商卡片**:当前活跃的供应商显示绿色边框
### 供应商卡片状态
| 状态 | 边框颜色 | 说明 |
|------|----------|------|
| 当前启用 | 蓝色 | 配置文件中的供应商(非代理模式) |
| 代理活跃 | 绿色 | 代理实际使用的供应商 |
| 普通 | 默认 | 未使用的供应商 |
## 关闭接管
### 操作步骤
1. 在代理面板中关闭对应应用的接管开关
2. 或直接停止代理服务
### 配置恢复
关闭接管时,CC Switch 会:
1. 将应用配置恢复到接管前的状态
2. 保存当前的请求日志
## 接管与供应商切换
### 接管模式下切换供应商
在接管模式下切换供应商:
1. 在主界面点击供应商的「启用」按钮
2. 代理立即使用新供应商转发请求
3. **无需重启 CLI 工具**
这是接管模式的一大优势:切换供应商即时生效。
### 非接管模式下切换
在非接管模式下切换供应商:
1. 修改配置文件
2. 需要重启 CLI 工具才能生效
## 多应用接管
可以同时接管多个应用,每个应用独立管理:
- 独立的供应商配置
- 独立的故障转移队列
- 独立的请求统计
## 使用场景
### 场景一:用量监控
开启接管 + 日志记录,监控 API 使用情况。
### 场景二:快速切换
开启接管后,切换供应商无需重启 CLI 工具。
### 场景三:故障转移
开启接管是使用故障转移功能的前提。
## 注意事项
### 性能影响
代理会增加少量延迟(通常 < 10ms),对于大多数场景可以忽略。
### 网络要求
接管模式下,CLI 工具需要能够访问本地代理地址。
### 配置备份
开启接管前,CC Switch 会备份原始配置,关闭时恢复。
## 常见问题
### 接管后请求失败
检查:
- 代理服务是否正常运行
- 供应商配置是否正确
- 网络是否正常
### 关闭接管后配置未恢复
可能原因:
- 代理异常退出
- 配置文件被其他程序修改
解决方法:
- 手动编辑供应商,重新保存
- 或重新启用再关闭接管
+233
View File
@@ -0,0 +1,233 @@
# 4.3 故障转移
## 功能说明
故障转移功能在主供应商请求失败时,自动切换到备用供应商,确保服务不中断。
**适用场景**
- 供应商服务不稳定
- 需要高可用性
- 长时间运行的任务
## 前提条件
使用故障转移功能需要:
1. ✅ 启动代理服务
2. ✅ 开启应用接管
3. ✅ 配置故障转移队列
4. ✅ 开启自动故障转移
## 配置故障转移队列
### 打开配置页面
设置 → 高级 → 故障转移
### 选择应用
页面顶部有四个 Tab
- Claude
- Codex
- Gemini
- OpenCode
选择要配置的应用。
### 添加备用供应商
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[返回错误]
```
## 熔断器配置
熔断器防止频繁重试失败的供应商。
### 配置项
不同应用有独立的默认配置。以下为通用默认值,Claude 有独立的宽松配置。
| 配置 | 说明 | 通用默认值 | Claude 默认值 | 范围 |
|------|------|--------|--------|------|
| 失败阈值 | 连续失败多少次触发熔断 | 4 | 8 | 1-20 |
| 恢复成功阈值 | 半开状态下成功多少次后关闭熔断器 | 2 | 3 | 1-10 |
| 恢复等待时间 | 熔断后多久尝试恢复(秒) | 60 | 90 | 0-300 |
| 错误率阈值 | 错误率超过此值时打开熔断器 | 60% | 70% | 0-100% |
| 最小请求数 | 计算错误率前的最小请求数 | 10 | 15 | 5-100 |
> 💡 Claude 由于请求耗时较长,默认配置更为宽松,容忍更多失败次数。
### 超时配置
| 配置 | 说明 | 通用默认值 | Claude 默认值 | 范围 |
|------|------|--------|--------|------|
| 流式首字节超时 | 等待首个数据块的最大时间(秒) | 60 | 90 | 1-120 |
| 流式静默超时 | 数据块之间的最大间隔(秒) | 120 | 180 | 60-600(填 0 禁用) |
| 非流式超时 | 非流式请求的总超时时间(秒) | 600 | 600 | 60-1200 |
### 重试配置
| 配置 | 说明 | 通用默认值 | Claude 默认值 | 范围 |
|------|------|--------|--------|------|
| 最大重试次数 | 请求失败时的重试次数 | 3 | 6 | 0-10 |
> 💡 Gemini 的默认最大重试次数为 5。
### 熔断状态
| 状态 | 说明 |
|------|------|
| 关闭 | 正常状态,允许请求 |
| 开启 | 熔断状态,跳过此供应商 |
| 半开 | 尝试恢复,发送试探请求 |
### 状态转换
```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. 重置熔断状态
+291
View File
@@ -0,0 +1,291 @@
# 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 / OpenCode |
| 状态码 | 全部 / 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-opus-4-1 | $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 配额
- 建议使用低成本模型进行测试
- 测试频率不宜过高,避免浪费配额
- 不同供应商支持的模型可能不同
+278
View File
@@ -0,0 +1,278 @@
# 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 | 供应商配置 |
| provider_endpoints | 供应商端点候选列表 |
| mcp_servers | MCP 服务器配置 |
| prompts | 提示词预设 |
| skills | 技能安装状态 |
| skill_repos | 技能仓库配置 |
| proxy_config | 代理配置 |
| proxy_request_logs | 代理请求日志 |
| provider_health | 供应商健康状态 |
| model_pricing | 模型定价 |
| settings | 应用设置 |
### 设备设置
`settings.json` 存储设备级设置:
```json
{
"language": "zh",
"theme": "system",
"windowBehavior": "minimize",
"autoStart": false,
"claudeConfigDir": null,
"codexConfigDir": null,
"geminiConfigDir": null,
"opencodeConfigDir": 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
{
"mcpServers": {
"mcp-fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"]
}
}
}
```
| 字段 | 说明 |
|------|------|
| `mcpServers` | MCP 服务器配置 |
## OpenCode 配置
### 配置目录
默认:`~/.opencode/`
### 主要文件
```
~/.opencode/
├── config.json # 主配置文件
├── AGENTS.md # 系统提示词
└── skills/ # 技能目录
└── ...
```
## 配置优先级
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.7.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\`
+256
View File
@@ -0,0 +1,256 @@
# 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. 分享给他人或在其他设备使用
## 协议格式
### V1 协议
使用 URL 参数格式,易读易生成:
```
ccswitch://v1/import?resource={type}&app={app}&name={name}&...
```
**通用参数**
| 参数 | 必填 | 说明 |
|------|------|------|
| `resource` | 是 | 资源类型:`provider` / `mcp` / `prompt` / `skill` |
| `app` | 是 | 应用类型:`claude` / `codex` / `gemini` / `opencode` |
| `name` | 是 | 名称 |
**供应商参数**resource=provider):
| 参数 | 必填 | 说明 |
|------|------|------|
| `endpoint` | 否 | API 端点地址(支持逗号分隔多个 URL) |
| `apiKey` | 否 | API 密钥 |
| `homepage` | 否 | 供应商官网 |
| `model` | 否 | 默认模型 |
| `haikuModel` | 否 | Haiku 模型(仅 Claude |
| `sonnetModel` | 否 | Sonnet 模型(仅 Claude |
| `opusModel` | 否 | Opus 模型(仅 Claude |
| `notes` | 否 | 备注 |
| `icon` | 否 | 图标 |
| `config` | 否 | Base64 编码的配置内容 |
| `configFormat` | 否 | 配置格式:`json` / `toml` |
| `configUrl` | 否 | 远程配置 URL |
| `enabled` | 否 | 是否启用(布尔值) |
| `usageScript` | 否 | 用量查询脚本 |
| `usageEnabled` | 否 | 是否启用用量查询(默认 true) |
| `usageApiKey` | 否 | 用量查询专用 API Key |
| `usageBaseUrl` | 否 | 用量查询专用地址 |
| `usageAccessToken` | 否 | 用量查询访问令牌 |
| `usageUserId` | 否 | 用量查询用户 ID |
| `usageAutoInterval` | 否 | 自动查询间隔(分钟) |
**提示词参数**resource=prompt):
| 参数 | 必填 | 说明 |
|------|------|------|
| `content` | 是 | 提示词内容 |
| `description` | 否 | 描述 |
| `enabled` | 否 | 是否启用(布尔值) |
**MCP 参数**resource=mcp):
| 参数 | 必填 | 说明 |
|------|------|------|
| `apps` | 是 | 应用列表(逗号分隔,如 `claude,codex,gemini` |
| `config` | 是 | MCP 服务器配置(JSON 格式) |
| `enabled` | 否 | 是否启用(布尔值) |
**Skill 参数**resource=skill):
| 参数 | 必填 | 说明 |
|------|------|------|
| `repo` | 是 | 仓库(格式:`owner/name` |
| `directory` | 否 | 目录路径 |
| `branch` | 否 | Git 分支 |
**示例**
```
ccswitch://v1/import?resource=provider&app=claude&name=My%20Provider&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-xxx
```
## 导入类型示例
### 导入供应商
```
ccswitch://v1/import?resource=provider&app=claude&name=My%20Provider&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-xxx
```
### 导入 MCP 服务器
```
ccswitch://v1/import?resource=mcp&apps=claude,codex&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-server-fetch%22%5D%7D&name=mcp-fetch
```
### 导入 Prompt 预设
```
ccswitch://v1/import?resource=prompt&app=claude&name=%E4%BB%A3%E7%A0%81%E5%AE%A1%E6%9F%A5&content=%23%20%E8%A7%92%E8%89%B2%0A%E4%BD%A0%E6%98%AF%E4%B8%80%E4%B8%AA%E4%B8%93%E4%B8%9A%E7%9A%84%E4%BB%A3%E7%A0%81%E5%AE%A1%E6%9F%A5%E4%B8%93%E5%AE%B6
```
### 导入 Skill
```
ccswitch://v1/import?resource=skill&name=my-skill&repo=owner/repo&directory=skills/my-skill&branch=main
```
## 生成深度链接
### 手动生成
1. 准备参数
2. 按 V1 协议格式拼接 URL
3. URL 编码特殊字符
**示例**
```javascript
const params = new URLSearchParams({
resource: 'provider',
app: 'claude',
name: 'My Provider',
endpoint: 'https://api.example.com',
apiKey: 'sk-xxx'
});
const url = `ccswitch://v1/import?${params.toString()}`;
```
### 在线工具
使用 CC Switch 官方提供的在线深度链接生成工具更方便。
## 使用深度链接
### 点击链接
在浏览器或其他应用中点击深度链接:
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://v1/import?resource=provider&app=claude&name=Test%20Provider&apiKey=sk-xxx&endpoint=https%3A%2F%2Fapi.example.com
```
### 示例:导入 MCP 服务器
```
ccswitch://v1/import?resource=mcp&name=mcp-fetch&apps=claude,codex,gemini&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-server-fetch%22%5D%7D
```
## 故障排除
### 链接无法打开
**检查**
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 / OpenCode 全方位辅助工具
## 目录结构
```
📚 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.10.3
- 最后更新:2026-02-09
- 适用于 CC Switch v3.10.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

+6 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.10.0",
"version": "3.10.3",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": {
@@ -54,13 +54,17 @@
"@lobehub/icons-static-svg": "^1.73.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.4",
"@tanstack/react-query": "^5.90.3",
"@tauri-apps/api": "^2.8.0",
@@ -72,6 +76,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"codemirror": "^6.0.2",
"flexsearch": "^0.8.212",
"framer-motion": "^12.23.25",
"i18next": "^25.5.2",
"jsonc-parser": "^3.2.1",
+139
View File
@@ -50,6 +50,9 @@ importers:
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-collapsible':
specifier: ^1.1.12
version: 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -59,6 +62,12 @@ importers:
'@radix-ui/react-label':
specifier: ^2.1.7
version: 2.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-popover':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-scroll-area':
specifier: ^1.2.10
version: 1.2.10(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-select':
specifier: ^2.2.6
version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -71,6 +80,9 @@ importers:
'@radix-ui/react-tabs':
specifier: ^1.1.13
version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-visually-hidden':
specifier: ^1.2.4
version: 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -104,6 +116,9 @@ importers:
codemirror:
specifier: ^6.0.2
version: 6.0.2
flexsearch:
specifier: ^0.8.212
version: 0.8.212
framer-motion:
specifier: ^12.23.25
version: 12.23.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -1039,6 +1054,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-popover@1.1.15':
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
@@ -1117,6 +1145,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-scroll-area@1.2.10':
resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-select@2.2.6':
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
peerDependencies:
@@ -1174,6 +1215,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-tooltip@1.2.8':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:
@@ -1323,56 +1377,67 @@ packages:
resolution: {integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.46.2':
resolution: {integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.46.2':
resolution: {integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.46.2':
resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loongarch64-gnu@4.46.2':
resolution: {integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.46.2':
resolution: {integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.46.2':
resolution: {integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.46.2':
resolution: {integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.46.2':
resolution: {integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.46.2':
resolution: {integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.46.2':
resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.46.2':
resolution: {integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==}
@@ -1429,30 +1494,35 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.8.1':
resolution: {integrity: sha512-VK/zwBzQY9SfyK7RSrxlIRQLJyhyssoByYWPK/FJMre8SV/y8zZ071cTQNG9dPWM1f+onI1WPTleG+TBUq/0Gw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.8.1':
resolution: {integrity: sha512-bFw3zK6xkyurDR5kw2QgiU6YFlFNrfgtli3wRdTRv8zSVLZMQ2iZ8keYnd57vpvsbZ9PusFPYAMS7Fkzkf9I4g==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.8.1':
resolution: {integrity: sha512-zOnFX+Rppuz0UVVSeCi67lMet8le+yT4UIiQ6t/QYGtpoWO/D4GpMoVYehJlR14klNXrC2CRxT9b3BUWTCEBwA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.8.1':
resolution: {integrity: sha512-gLy6eisaeOTC6NQirs3a0XZNCVT/i7JPYHkXx6ArH6+Kb9IU8ogthTY4MQoYbkWmdOp3ijKX+RT1dD3IZURrEg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.8.1':
resolution: {integrity: sha512-ciZ93Dm847zFDqRyc1e0YRiu/cdWne1bMhvifcZOibbyqSKB9o+b95Y5axMtXqR4Wsd2mHiC5TE+MVF3NDsdEw==}
@@ -1995,6 +2065,9 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
flexsearch@0.8.212:
resolution: {integrity: sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw==}
form-data@4.0.4:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
@@ -2211,24 +2284,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.1:
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.1:
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.1:
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.1:
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
@@ -3832,6 +3909,29 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1)
aria-hidden: 1.2.6
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-remove-scroll: 2.7.1(@types/react@18.3.23)(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -3905,6 +4005,23 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-scroll-area@1.2.10(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/number': 1.1.1
@@ -3979,6 +4096,26 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.23)(react@18.3.1)':
dependencies:
react: 18.3.1
@@ -4758,6 +4895,8 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
flexsearch@0.8.212: {}
form-data@4.0.4:
dependencies:
asynckit: 0.4.0
+268
View File
@@ -0,0 +1,268 @@
# 会话管理(Session Manager)需求文档(PRD / Markdown
> 目标:对 **Codex / Claude Code** 的本地会话记录进行可视化管理,并提供“一键复制 / 一键终端恢复”能力。
> 范围:**v1 仅 macOS**,但必须预留多平台扩展入口。
---
## 1. 背景与问题
开发者同时使用 Codex CLI、Claude Code 时,常见痛点:
- 会话记录落在本地不同位置,**难以发现/检索**
- 找到会话后,恢复命令需要记忆或翻历史,**恢复成本高**
- 恢复时经常忘了当时的工作目录,导致命令在错误目录运行
- 希望在常用终端(macOS Terminal、kitty 等)中直接恢复,提高效率
---
## 2. 目标与非目标
### 2.1 Goalsv1 必达)
1. 扫描并展示本机所有 Codex / Claude Code 会话:列表 + 详情(会话内容)
2. 支持恢复会话:
- 复制恢复命令(按钮)
- 复制会话目录(按钮,若能获取/推断)
- 可选:直接在终端执行恢复(macOS Terminal、kitty;可扩展)
3. 仅 macOS 支持,但代码结构需支持未来扩展 Windows/Linux
### 2.2 Non-Goalsv1 不做)
- 不新增/依赖云端 API;默认不上传任何内容
- 不承诺解析所有 provider 的全部内部格式(尽量兼容、可配置、可降级)
- 不做复杂的团队协作/分享/同步(后续版本再考虑)
---
## 3. 用户画像与使用场景
### 3.1 典型用户
- 高频使用多个 AI 编程工具的工程师/技术负责人/PM
- 多项目、多分支并行,频繁“中断—恢复—继续推进”
### 3.2 核心场景(Top
1. **找回会话**:我记得一个会话讨论过某段逻辑 → 搜索关键词 → 打开详情
2. **快速恢复**:我想继续昨天的会话 → 复制恢复命令 / 一键在终端恢复
3. **回到正确目录**:恢复前先复制目录或自动 cd 到目录
---
## 4. 产品形态与信息架构
### 4.1 信息架构
- Session Manager
- 会话列表(List
- 会话详情(Detail
- 设置(Settings
- Provider 配置(路径/启用禁用)
- 终端集成(默认终端、权限提示、降级策略)
- 索引与隐私选项(是否缓存、缓存大小、敏感信息遮罩)
---
## 5. 功能需求(Functional Requirements
### 5.1 会话发现与索引(Discovery & Indexing
**FR-1** 扫描本地会话数据源,生成统一的 Session 列表
- 支持 ProviderCodex、Claude Code(可扩展)
- 支持全量扫描 + 增量更新
- 支持缺失/异常文件的容错(不中断 UI)
**FR-2** 本地索引(Cache/DB
- 用于加速列表加载与搜索
- 索引字段至少包含:sessionId、provider、lastActiveAt、projectDir(可空)、summary(可空)、filePath(可空)
**FR-3** 数据源路径探测(可配置 + 多候选)
- 默认使用常见路径;允许用户在 Settings 覆盖
- 若无法探测到 provider 安装/数据目录:在 UI 显示未启用/不可用状态,但不报错崩溃
---
### 5.2 会话列表(List
**FR-4** 列表展示字段(建议最小集)
- ProviderCodex / Claude
- Session 标识(id/short id
- 最近活跃时间(lastActiveAt
- 目录(projectDir,若未知显示 “Unknown”)
- 摘要(summary:最后一条/首条截断或规则生成)
**FR-5** 列表交互
- 搜索(跨会话,关键词匹配 transcript/summary/目录)
- 过滤:Provider、是否有目录、时间范围
- 排序:最近活跃(默认)、最早、按目录
**FR-6** 空态/异常态
- 未发现任何会话:给出“如何启用/设置路径”的指引
- 发现会话但无法解析内容:列表仍可显示基本信息,并在详情页提示“解析失败”
---
### 5.3 会话详情(Detail
**FR-7** 会话内容展示
- 时间线展示消息(roleuser/assistant/tool 等)
- 支持在当前会话内搜索 + 高亮
- 展示元信息:
- provider、sessionId、创建/最近活跃时间
- projectDir(可空)
- 原始文件路径(可选显示,便于 debug)
**FR-8** 性能策略
- 默认按需加载(打开详情才加载全文)
- 对超长 transcript 支持分页/虚拟列表(防止卡顿)
---
### 5.4 恢复能力(Resume / Restore
#### 5.4.1 复制恢复命令(必做)
**FR-9** “复制恢复命令”按钮
- 根据 provider 生成恢复命令(模板可配置)
- 点击后写入剪贴板,并 toast 提示成功
> 说明:不同版本 CLI 命令可能略有差异,建议将命令模板做成可配置项(Settings),默认提供推荐模板。
#### 5.4.2 复制会话目录(尽量做)
**FR-10** “复制会话目录”按钮
- 当 projectDir 可得时启用;不可得时置灰,并提示原因(无法推断目录)
- 复制内容为可直接 `cd` 的绝对路径(或原样)
#### 5.4.3 一键终端恢复(可选但强烈建议)
**FR-11** “在终端恢复”按钮(或下拉菜单)
- 默认目标:macOS Terminal
- 支持 kittyv1 要求)
- 执行策略:
- `cd "<projectDir>" && <resumeCommand>`(若 projectDir 为空则仅执行 resumeCommand
- 失败降级:
- 无权限/终端不可用 → 自动降级为“仅复制命令”,并提示用户如何修复(例如开启 Automation 权限、kitty remote control
**FR-12** 终端目标选择与记忆
- 下拉选择:Terminal / kitty /(预留 iTerm2/ 仅复制
- 记住上次选择作为默认
---
## 6. 平台与扩展性设计(macOS v1 + Future-proof
### 6.1 Provider Adapter 抽象(必须)
统一接口(示例):
- `detect(): boolean`
- `scanSessions(): SessionMeta[]`
- `loadTranscript(sessionId): Message[]`
- `getResumeCommand(sessionId): string`
- `getProjectDir(sessionId): string | null`
### 6.2 Terminal Launcher 抽象(必须)
- `launch(command: string, cwd?: string, targetTerminal: TerminalKind): Result`
- macOS v1 实现:TerminalLauncherMac
- FutureTerminalLauncherWindows / TerminalLauncherLinux
### 6.3 Path Resolver(必须)
- `resolveProviderDataPaths(providerId): string[]`
- v1 返回 macOS 默认候选;允许 Settings 覆盖
---
## 7. 隐私与安全(Privacy & Security
**默认原则:全本地、只读、不上传。**
- transcript 默认不出网
- 本地索引默认仅存必要字段(可选:是否缓存全文内容)
- 提供“敏感信息遮罩”(可选):
- 简单正则:token/key/password 等
- 提示用户:会话内容可能包含敏感信息,导出/复制时注意
---
## 8. 非功能需求(Non-Functional Requirements
### 8.1 性能
- 首次打开:列表可在 1s 内展示(允许先展示缓存,再后台增量刷新)
- 搜索:在 1k 会话量级可用(建立索引或增量缓存)
- 详情页:打开后 300ms 内渲染骨架屏,内容流式/分段加载
### 8.2 稳定性
- 任一 provider 数据源损坏不影响整体(隔离失败)
- 扫描过程可中断/可重试
### 8.3 可观测性(可选)
- 本地日志:扫描耗时、解析失败原因、终端启动失败原因(便于 debug)
---
## 9. 关键数据结构(建议)
### 9.1 SessionMeta
- `providerId: "codex" | "claude" | string`
- `sessionId: string`
- `title?: string`
- `summary?: string`
- `projectDir?: string | null`
- `createdAt?: number`
- `lastActiveAt?: number`
- `sourcePath?: string`
### 9.2 Message
- `role: "user" | "assistant" | "tool" | "system" | string`
- `content: string`
- `ts?: number`
- `raw?: any`(保留原始字段,便于兼容未来格式)
---
## 10. 交互流程(UX Flows
### 10.1 Flow A:搜索并查看
1) 打开 Session Manager → 看到列表
2) 输入关键词搜索 → 命中会话
3) 点击会话 → 进入详情 → 浏览内容 / 在会话内搜索
### 10.2 Flow B:复制恢复命令
1) 列表或详情页点击“复制恢复命令”
2) toast 成功 → 用户粘贴到终端执行
### 10.3 Flow C:一键终端恢复
1) 详情页点击“在终端恢复”(默认 Terminal)
2) 系统打开终端新窗口/新 tab
3) 自动执行:`cd projectDir && resumeCommand`
4) 失败 → toast 提示,并提供“复制命令”降级路径
---
## 11. 边界情况与降级策略
- 无法获取 projectDir:仍可恢复(只执行 resume),目录按钮置灰
- 无法解析 transcript:列表仍显示,详情提示“无法解析”,可提供“打开原始文件路径”
- CLI 命令模板不匹配:允许 Settings 自定义模板;默认模板可更新
- 终端权限问题(Automation):提示用户在系统设置中开启对应权限,并允许降级为复制命令
- kitty 未开启 remote control:提示如何配置,降级为复制命令
---
## 12. 里程碑与交付(建议)
### M1(核心可用)
- Provider 扫描:Codex / Claude
- 列表 + 详情(可读)
- 复制恢复命令
- 复制目录(若可得)
### M2(效率提升)
- 跨会话搜索、过滤/排序
- 增量索引与文件监听(可选)
- “在 macOS Terminal 恢复”
### M3(终端覆盖与可扩展)
- “在 kitty 恢复”
- 终端目标下拉与记忆
- 插件化接口/扩展点文档
---
## 13. 后续功能候选(Backlog / Ideas
- 收藏/Pin 会话
- 会话标签(项目/主题/状态)
- 会话摘要(本地生成)
- Fork 会话继续(避免污染原会话)
- 导出 Markdown/JSONL
- 按项目聚合(Repo 视图)
- 会话清理/归档(磁盘管理)
---
+64 -1
View File
@@ -701,7 +701,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.10.0"
version = "3.10.3"
dependencies = [
"anyhow",
"async-stream",
@@ -714,6 +714,7 @@ dependencies = [
"futures",
"hyper",
"indexmap 2.11.4",
"json5",
"log",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
@@ -727,6 +728,7 @@ dependencies = [
"serde_json",
"serde_yaml",
"serial_test",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
@@ -746,6 +748,7 @@ dependencies = [
"tower-http 0.5.2",
"url",
"uuid",
"webkit2gtk",
"winreg 0.52.0",
"zip 2.4.2",
]
@@ -2519,6 +2522,17 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "json5"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1"
dependencies = [
"pest",
"pest_derive",
"serde",
]
[[package]]
name = "jsonptr"
version = "0.6.3"
@@ -3403,6 +3417,49 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pest"
version = "2.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7"
dependencies = [
"memchr",
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed"
dependencies = [
"pest",
"pest_generator",
]
[[package]]
name = "pest_generator"
version = "2.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "pest_meta"
version = "2.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365"
dependencies = [
"pest",
"sha2",
]
[[package]]
name = "phf"
version = "0.8.0"
@@ -5886,6 +5943,12 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "ucd-trie"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "uds_windows"
version = "1.1.0"
+7 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.10.0"
version = "3.10.3"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
@@ -57,14 +57,19 @@ url = "2.5"
auto-launch = "0.5"
once_cell = "1.21.3"
base64 = "0.22"
rusqlite = { version = "0.31", features = ["bundled", "backup"] }
rusqlite = { version = "0.31", features = ["bundled", "backup", "hooks"] }
indexmap = { version = "2", features = ["serde"] }
rust_decimal = "1.33"
uuid = { version = "1.11", features = ["v4"] }
sha2 = "0.10"
json5 = "0.4"
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"
[target.'cfg(target_os = "linux")'.dependencies]
webkit2gtk = { version = "2.0.1", features = ["v2_16"] }
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.52"
+26 -1
View File
@@ -1,3 +1,28 @@
fn main() {
tauri_build::build()
tauri_build::build();
// Windows: Embed Common Controls v6 manifest for test binaries
//
// When running `cargo test`, the generated test executables don't include
// the standard Tauri application manifest. Without Common Controls v6,
// `tauri::test` calls fail with STATUS_ENTRYPOINT_NOT_FOUND.
//
// This workaround:
// 1. Embeds the manifest into test binaries via /MANIFEST:EMBED
// 2. Uses /MANIFEST:NO for the main binary to avoid duplicate resources
// (Tauri already handles manifest embedding for the app binary)
#[cfg(target_os = "windows")]
{
let manifest_path = std::path::PathBuf::from(
std::env::var("CARGO_MANIFEST_DIR").expect("missing CARGO_MANIFEST_DIR"),
)
.join("common-controls.manifest");
let manifest_arg = format!("/MANIFESTINPUT:{}", manifest_path.display());
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg={}", manifest_arg);
// Avoid duplicate manifest resources in binary builds.
println!("cargo:rustc-link-arg-bins=/MANIFEST:NO");
println!("cargo:rerun-if-changed={}", manifest_path.display());
}
}
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"/>
</dependentAssembly>
</dependency>
</assembly>
+65 -58
View File
@@ -25,6 +25,7 @@ impl McpApps {
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
AppType::OpenClaw => false, // OpenClaw doesn't support MCP
}
}
@@ -35,6 +36,7 @@ impl McpApps {
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore
}
}
@@ -83,6 +85,7 @@ impl SkillApps {
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
AppType::OpenClaw => false, // OpenClaw doesn't support Skills
}
}
@@ -93,6 +96,7 @@ impl SkillApps {
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore
}
}
@@ -125,6 +129,20 @@ impl SkillApps {
apps.set_enabled_for(app, true);
apps
}
/// 从来源标签列表构建启用状态
///
/// 标签与 AppType::as_str() 一致时启用对应应用,
/// 其他标签(如 "agents", "cc-switch")忽略。
pub fn from_labels(labels: &[String]) -> Self {
let mut apps = Self::default();
for label in labels {
if let Ok(app) = label.parse::<AppType>() {
apps.set_enabled_for(&app, true);
}
}
apps
}
}
/// 已安装的 Skillv3.10.0+ 统一结构)
@@ -171,6 +189,8 @@ pub struct UnmanagedSkill {
pub description: Option<String>,
/// 在哪些应用目录中发现(如 ["claude", "codex"]
pub found_in: Vec<String>,
/// 发现路径(首个匹配的完整路径)
pub path: String,
}
/// MCP 服务器定义(v3.7.0 统一结构)
@@ -222,6 +242,9 @@ pub struct McpRoot {
/// OpenCode MCP 配置(v4.0.0+,实际使用 opencode.json
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub opencode: McpConfig,
/// OpenClaw MCP 配置(v4.1.0+,实际使用 openclaw.json
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub openclaw: McpConfig,
}
impl Default for McpRoot {
@@ -234,6 +257,7 @@ impl Default for McpRoot {
codex: McpConfig::default(),
gemini: McpConfig::default(),
opencode: McpConfig::default(),
openclaw: McpConfig::default(),
}
}
}
@@ -256,6 +280,8 @@ pub struct PromptRoot {
pub gemini: PromptConfig,
#[serde(default)]
pub opencode: PromptConfig,
#[serde(default)]
pub openclaw: PromptConfig,
}
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
@@ -271,6 +297,7 @@ pub enum AppType {
Codex,
Gemini,
OpenCode,
OpenClaw,
}
impl AppType {
@@ -280,8 +307,29 @@ impl AppType {
AppType::Codex => "codex",
AppType::Gemini => "gemini",
AppType::OpenCode => "opencode",
AppType::OpenClaw => "openclaw",
}
}
/// Check if this app uses additive mode
///
/// - Switch mode (false): Only the current provider is written to live config (Claude, Codex, Gemini)
/// - Additive mode (true): All providers are written to live config (OpenCode, OpenClaw)
pub fn is_additive_mode(&self) -> bool {
matches!(self, AppType::OpenCode | AppType::OpenClaw)
}
/// Return an iterator over all app types
pub fn all() -> impl Iterator<Item = AppType> {
[
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
AppType::OpenClaw,
]
.into_iter()
}
}
impl FromStr for AppType {
@@ -294,53 +342,16 @@ impl FromStr for AppType {
"codex" => Ok(AppType::Codex),
"gemini" => Ok(AppType::Gemini),
"opencode" => Ok(AppType::OpenCode),
"openclaw" => Ok(AppType::OpenClaw),
other => Err(AppError::localized(
"unsupported_app",
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode。"),
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode."),
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw"),
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw."),
)),
}
}
}
/// 通用配置片段(按应用分治)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CommonConfigSnippets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode: Option<String>,
}
impl CommonConfigSnippets {
/// 获取指定应用的通用配置片段
pub fn get(&self, app: &AppType) -> Option<&String> {
match app {
AppType::Claude => self.claude.as_ref(),
AppType::Codex => self.codex.as_ref(),
AppType::Gemini => self.gemini.as_ref(),
AppType::OpenCode => self.opencode.as_ref(),
}
}
/// 设置指定应用的通用配置片段
pub fn set(&mut self, app: &AppType, snippet: Option<String>) {
match app {
AppType::Claude => self.claude = snippet,
AppType::Codex => self.codex = snippet,
AppType::Gemini => self.gemini = snippet,
AppType::OpenCode => self.opencode = snippet,
}
}
}
/// 多应用配置结构(向后兼容)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MultiAppConfig {
@@ -358,12 +369,6 @@ pub struct MultiAppConfig {
/// Claude Skills 配置
#[serde(default)]
pub skills: SkillStore,
/// 通用配置片段(按应用分治)
#[serde(default)]
pub common_config_snippets: CommonConfigSnippets,
/// Claude 通用配置片段(旧字段,用于向后兼容迁移)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_common_config_snippet: Option<String>,
}
fn default_version() -> u32 {
@@ -377,6 +382,7 @@ impl Default for MultiAppConfig {
apps.insert("codex".to_string(), ProviderManager::default());
apps.insert("gemini".to_string(), ProviderManager::default());
apps.insert("opencode".to_string(), ProviderManager::default());
apps.insert("openclaw".to_string(), ProviderManager::default());
Self {
version: 2,
@@ -384,8 +390,6 @@ impl Default for MultiAppConfig {
mcp: McpRoot::default(),
prompts: PromptRoot::default(),
skills: SkillStore::default(),
common_config_snippets: CommonConfigSnippets::default(),
claude_common_config_snippet: None,
}
}
}
@@ -479,15 +483,6 @@ impl MultiAppConfig {
updated = true;
}
// 迁移通用配置片段:claude_common_config_snippet → common_config_snippets.claude
if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() {
log::info!(
"迁移通用配置:claude_common_config_snippet → common_config_snippets.claude"
);
config.common_config_snippets.claude = Some(old_claude_snippet);
updated = true;
}
if updated {
log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置...");
config.save()?;
@@ -536,6 +531,7 @@ impl MultiAppConfig {
AppType::Codex => &self.mcp.codex,
AppType::Gemini => &self.mcp.gemini,
AppType::OpenCode => &self.mcp.opencode,
AppType::OpenClaw => &self.mcp.openclaw,
}
}
@@ -546,6 +542,7 @@ impl MultiAppConfig {
AppType::Codex => &mut self.mcp.codex,
AppType::Gemini => &mut self.mcp.gemini,
AppType::OpenCode => &mut self.mcp.opencode,
AppType::OpenClaw => &mut self.mcp.openclaw,
}
}
@@ -560,6 +557,7 @@ impl MultiAppConfig {
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?;
Ok(config)
}
@@ -580,6 +578,7 @@ impl MultiAppConfig {
|| !self.prompts.codex.prompts.is_empty()
|| !self.prompts.gemini.prompts.is_empty()
|| !self.prompts.opencode.prompts.is_empty()
|| !self.prompts.openclaw.prompts.is_empty()
{
return Ok(false);
}
@@ -592,6 +591,7 @@ impl MultiAppConfig {
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
AppType::OpenClaw,
] {
// 复用已有的单应用导入逻辑
if Self::auto_import_prompt_if_exists(self, app)? {
@@ -662,6 +662,7 @@ impl MultiAppConfig {
AppType::Codex => &mut config.prompts.codex.prompts,
AppType::Gemini => &mut config.prompts.gemini.prompts,
AppType::OpenCode => &mut config.prompts.opencode.prompts,
AppType::OpenClaw => &mut config.prompts.openclaw.prompts,
};
prompts.insert(id, prompt);
@@ -690,12 +691,18 @@ impl MultiAppConfig {
let mut conflicts = Vec::new();
// 收集所有应用的 MCP
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
let old_servers = match app {
AppType::Claude => &self.mcp.claude.servers,
AppType::Codex => &self.mcp.codex.servers,
AppType::Gemini => &self.mcp.gemini.servers,
AppType::OpenCode => &self.mcp.opencode.servers,
AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip
};
for (id, entry) in old_servers {
+2 -9
View File
@@ -2,21 +2,14 @@
use std::path::PathBuf;
use crate::config::{
atomic_write, delete_file, sanitize_provider_name, write_json_file, write_text_file,
atomic_write, delete_file, get_home_dir, sanitize_provider_name, write_json_file,
write_text_file,
};
use crate::error::AppError;
use serde_json::Value;
use std::fs;
use std::path::Path;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_codex_override_dir() {
+40 -79
View File
@@ -9,7 +9,6 @@ use crate::codex_config;
use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::settings;
/// 获取 Claude Code 配置状态
#[tauri::command]
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
Ok(config::get_claude_config_status())
@@ -60,16 +59,23 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
Ok(ConfigStatus { exists, path })
}
AppType::OpenClaw => {
let config_path = crate::openclaw_config::get_openclaw_config_path();
let exists = config_path.exists();
let path = crate::openclaw_config::get_openclaw_dir()
.to_string_lossy()
.to_string();
Ok(ConfigStatus { exists, path })
}
}
}
/// 获取 Claude Code 配置文件路径
#[tauri::command]
pub async fn get_claude_code_config_path() -> Result<String, String> {
Ok(get_claude_settings_path().to_string_lossy().to_string())
}
/// 获取当前生效的配置目录
#[tauri::command]
pub async fn get_config_dir(app: String) -> Result<String, String> {
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
@@ -77,12 +83,12 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
};
Ok(dir.to_string_lossy().to_string())
}
/// 打开配置文件夹
#[tauri::command]
pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool, String> {
let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
@@ -90,6 +96,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
};
if !config_dir.exists() {
@@ -104,7 +111,6 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
Ok(true)
}
/// 弹出系统目录选择器并返回用户选择的路径
#[tauri::command]
pub async fn pick_directory(
app: AppHandle,
@@ -136,14 +142,12 @@ pub async fn pick_directory(
}
}
/// 获取应用配置文件路径
#[tauri::command]
pub async fn get_app_config_path() -> Result<String, String> {
let config_path = config::get_app_config_path();
Ok(config_path.to_string_lossy().to_string())
}
/// 打开应用配置文件夹
#[tauri::command]
pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
let config_dir = config::get_app_config_dir();
@@ -160,42 +164,6 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
Ok(true)
}
/// 获取 Claude 通用配置片段(已废弃,使用 get_common_config_snippet
#[tauri::command]
pub async fn get_claude_common_config_snippet(
state: tauri::State<'_, crate::store::AppState>,
) -> Result<Option<String>, String> {
state
.db
.get_config_snippet("claude")
.map_err(|e| e.to_string())
}
/// 设置 Claude 通用配置片段(已废弃,使用 set_common_config_snippet
#[tauri::command]
pub async fn set_claude_common_config_snippet(
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
// 验证是否为有效的 JSON(如果不为空)
if !snippet.trim().is_empty() {
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
}
let value = if snippet.trim().is_empty() {
None
} else {
Some(snippet)
};
state
.db
.set_config_snippet("claude", value)
.map_err(|e| e.to_string())?;
Ok(())
}
/// 获取通用配置片段(统一接口)
#[tauri::command]
pub async fn get_common_config_snippet(
app_type: String,
@@ -207,25 +175,19 @@ pub async fn get_common_config_snippet(
.map_err(|e| e.to_string())
}
/// 设置通用配置片段(统一接口)
#[tauri::command]
pub async fn set_common_config_snippet(
app_type: String,
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
// 验证格式(根据应用类型)
if !snippet.trim().is_empty() {
match app_type.as_str() {
"claude" | "gemini" => {
// 验证 JSON 格式
"claude" | "gemini" | "omo" | "omo-slim" => {
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(invalid_json_format_error)?;
}
"codex" => {
// TOML 格式暂不验证(或可使用 toml crate)
// 注意:TOML 验证较为复杂,暂时跳过
}
"codex" => {}
_ => {}
}
}
@@ -240,33 +202,32 @@ pub async fn set_common_config_snippet(
.db
.set_config_snippet(&app_type, value)
.map_err(|e| e.to_string())?;
if app_type == "omo"
&& state
.db
.get_current_omo_provider("opencode", "omo")
.map_err(|e| e.to_string())?
.is_some()
{
crate::services::OmoService::write_config_to_file(
state.inner(),
&crate::services::omo::STANDARD,
)
.map_err(|e| e.to_string())?;
}
if app_type == "omo-slim"
&& state
.db
.get_current_omo_provider("opencode", "omo-slim")
.map_err(|e| e.to_string())?
.is_some()
{
crate::services::OmoService::write_config_to_file(
state.inner(),
&crate::services::omo::SLIM,
)
.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())
}
+58 -17
View File
@@ -5,10 +5,17 @@ use std::path::PathBuf;
use tauri::State;
use tauri_plugin_dialog::DialogExt;
use crate::commands::sync_support::{
post_sync_warning_from_result, run_post_import_sync, success_payload_with_warning,
};
use crate::database::backup::BackupEntry;
use crate::database::Database;
use crate::error::AppError;
use crate::services::provider::ProviderService;
use crate::store::AppState;
// ─── File import/export ──────────────────────────────────────
/// 导出数据库为 SQL 备份
#[tauri::command]
pub async fn export_config_to_file(
@@ -37,27 +44,15 @@ pub async fn import_config_from_file(
state: State<'_, AppState>,
) -> Result<Value, String> {
let db = state.db.clone();
let db_for_state = db.clone();
let db_for_sync = db.clone();
tauri::async_runtime::spawn_blocking(move || {
let path_buf = PathBuf::from(&filePath);
let backup_id = db.import_sql(&path_buf)?;
// 导入后同步当前供应商到各自的 live 配置
let app_state = AppState::new(db_for_state);
if let Err(err) = ProviderService::sync_current_to_live(&app_state) {
log::warn!("导入后同步 live 配置失败: {err}");
let warning = post_sync_warning_from_result(Ok(run_post_import_sync(db_for_sync)));
if let Some(msg) = warning.as_ref() {
log::warn!("[Import] post-import sync warning: {msg}");
}
// 重新加载设置到内存缓存,确保导入的设置生效
if let Err(err) = crate::settings::reload_settings() {
log::warn!("导入后重载设置失败: {err}");
}
Ok::<_, AppError>(json!({
"success": true,
"message": "SQL imported successfully",
"backupId": backup_id
}))
Ok::<_, AppError>(success_payload_with_warning(backup_id, warning))
})
.await
.map_err(|e| format!("导入配置失败: {e}"))?
@@ -80,6 +75,8 @@ pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<V
.map_err(|e: AppError| e.to_string())
}
// ─── File dialogs ────────────────────────────────────────────
/// 保存文件对话框
#[tauri::command]
pub async fn save_file_dialog<R: tauri::Runtime>(
@@ -109,3 +106,47 @@ pub async fn open_file_dialog<R: tauri::Runtime>(
Ok(result.map(|p| p.to_string()))
}
/// 打开 ZIP 文件选择对话框
#[tauri::command]
pub async fn open_zip_file_dialog<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Option<String>, String> {
let dialog = app.dialog();
let result = dialog
.file()
.add_filter("ZIP", &["zip"])
.blocking_pick_file();
Ok(result.map(|p| p.to_string()))
}
// ─── Database backup management ─────────────────────────────
/// List all database backup files
#[tauri::command]
pub fn list_db_backups() -> Result<Vec<BackupEntry>, String> {
Database::list_backups().map_err(|e| e.to_string())
}
/// Restore database from a backup file
#[tauri::command]
pub async fn restore_db_backup(
state: State<'_, AppState>,
filename: String,
) -> Result<String, String> {
let db = state.db.clone();
tauri::async_runtime::spawn_blocking(move || db.restore_from_backup(&filename))
.await
.map_err(|e| format!("Restore failed: {e}"))?
.map_err(|e: AppError| e.to_string())
}
/// Rename a database backup file
#[tauri::command]
pub fn rename_db_backup(
#[allow(non_snake_case)] oldFilename: String,
#[allow(non_snake_case)] newName: String,
) -> Result<String, String> {
Database::rename_backup(&oldFilename, &newName).map_err(|e| e.to_string())
}
+666 -110
View File
@@ -5,6 +5,7 @@ use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
use crate::services::ProviderService;
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashMap;
use std::path::Path;
use std::str::FromStr;
use tauri::AppHandle;
@@ -85,49 +86,122 @@ pub struct ToolVersion {
version: Option<String>,
latest_version: Option<String>, // 新增字段:最新版本
error: Option<String>,
/// 工具运行环境: "windows", "wsl", "macos", "linux", "unknown"
env_type: String,
/// 当 env_type 为 "wsl" 时,返回该工具绑定的 WSL distro(用于按 distro 探测 shells
wsl_distro: Option<String>,
}
const VALID_TOOLS: [&str; 4] = ["claude", "codex", "gemini", "opencode"];
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WslShellPreferenceInput {
#[serde(default)]
pub wsl_shell: Option<String>,
#[serde(default)]
pub wsl_shell_flag: Option<String>,
}
// Keep platform-specific env detection in one place to avoid repeating cfg blocks.
#[cfg(target_os = "windows")]
fn tool_env_type_and_wsl_distro(tool: &str) -> (String, Option<String>) {
if let Some(distro) = wsl_distro_for_tool(tool) {
("wsl".to_string(), Some(distro))
} else {
("windows".to_string(), None)
}
}
#[cfg(target_os = "macos")]
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
("macos".to_string(), None)
}
#[cfg(target_os = "linux")]
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
("linux".to_string(), None)
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
("unknown".to_string(), None)
}
#[tauri::command]
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
let tools = vec!["claude", "codex", "gemini"];
pub async fn get_tool_versions(
tools: Option<Vec<String>>,
wsl_shell_by_tool: Option<HashMap<String, WslShellPreferenceInput>>,
) -> Result<Vec<ToolVersion>, String> {
let requested: Vec<&str> = if let Some(tools) = tools.as_ref() {
let set: std::collections::HashSet<&str> = tools.iter().map(|s| s.as_str()).collect();
VALID_TOOLS
.iter()
.copied()
.filter(|t| set.contains(t))
.collect()
} else {
VALID_TOOLS.to_vec()
};
let mut results = Vec::new();
for tool in requested {
let pref = wsl_shell_by_tool.as_ref().and_then(|m| m.get(tool));
let tool_wsl_shell = pref.and_then(|p| p.wsl_shell.as_deref());
let tool_wsl_shell_flag = pref.and_then(|p| p.wsl_shell_flag.as_deref());
results.push(get_single_tool_version_impl(tool, tool_wsl_shell, tool_wsl_shell_flag).await);
}
Ok(results)
}
/// 获取单个工具的版本信息(内部实现)
async fn get_single_tool_version_impl(
tool: &str,
wsl_shell: Option<&str>,
wsl_shell_flag: Option<&str>,
) -> ToolVersion {
debug_assert!(
VALID_TOOLS.contains(&tool),
"unexpected tool name in get_single_tool_version_impl: {tool}"
);
// 判断该工具的运行环境 & WSL distro(如有)
let (env_type, wsl_distro) = tool_env_type_and_wsl_distro(tool);
// 使用全局 HTTP 客户端(已包含代理配置)
let client = crate::proxy::http_client::get();
for tool in tools {
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
let (local_version, local_error) = if let Some(distro) = wsl_distro_for_tool(tool) {
try_get_version_wsl(tool, &distro)
// 1. 获取本地版本
let (local_version, local_error) = if let Some(distro) = wsl_distro.as_deref() {
try_get_version_wsl(tool, distro, wsl_shell, wsl_shell_flag)
} else {
let direct_result = try_get_version(tool);
if direct_result.0.is_some() {
direct_result
} else {
// 先尝试直接执行
let direct_result = try_get_version(tool);
scan_cli_version(tool)
}
};
if direct_result.0.is_some() {
direct_result
} else {
// 扫描常见的 npm 全局安装路径
scan_cli_version(tool)
}
};
// 2. 获取远程最新版本
let latest_version = match tool {
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
"opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await,
_ => None,
};
// 2. 获取远程最新版本
let latest_version = match tool {
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
_ => None,
};
results.push(ToolVersion {
name: tool.to_string(),
version: local_version,
latest_version,
error: local_error,
});
ToolVersion {
name: tool.to_string(),
version: local_version,
latest_version,
error: local_error,
env_type,
wsl_distro,
}
Ok(results)
}
/// Helper function to fetch latest version from npm registry
@@ -148,6 +222,29 @@ async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Op
}
}
/// Helper function to fetch latest version from GitHub releases
async fn fetch_github_latest_version(client: &reqwest::Client, repo: &str) -> Option<String> {
let url = format!("https://api.github.com/repos/{repo}/releases/latest");
match client
.get(&url)
.header("User-Agent", "cc-switch")
.header("Accept", "application/vnd.github+json")
.send()
.await
{
Ok(resp) => {
if let Ok(json) = resp.json::<serde_json::Value>().await {
json.get("tag_name")
.and_then(|v| v.as_str())
.map(|s| s.strip_prefix('v').unwrap_or(s).to_string())
} else {
None
}
}
Err(_) => None,
}
}
/// 预编译的版本号正则表达式
static VERSION_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex"));
@@ -218,13 +315,43 @@ fn is_valid_wsl_distro_name(name: &str) -> bool {
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}
/// Validate that the given shell name is one of the allowed shells.
#[cfg(target_os = "windows")]
fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<String>) {
fn is_valid_shell(shell: &str) -> bool {
matches!(
shell.rsplit('/').next().unwrap_or(shell),
"sh" | "bash" | "zsh" | "fish" | "dash"
)
}
/// Validate that the given shell flag is one of the allowed flags.
#[cfg(target_os = "windows")]
fn is_valid_shell_flag(flag: &str) -> bool {
matches!(flag, "-c" | "-lc" | "-lic")
}
/// Return the default invocation flag for the given shell.
#[cfg(target_os = "windows")]
fn default_flag_for_shell(shell: &str) -> &'static str {
match shell.rsplit('/').next().unwrap_or(shell) {
"dash" | "sh" => "-c",
"fish" => "-lc",
_ => "-lic",
}
}
#[cfg(target_os = "windows")]
fn try_get_version_wsl(
tool: &str,
distro: &str,
force_shell: Option<&str>,
force_shell_flag: Option<&str>,
) -> (Option<String>, Option<String>) {
use std::process::Command;
// 防御性断言:tool 只能是预定义的值
debug_assert!(
["claude", "codex", "gemini"].contains(&tool),
["claude", "codex", "gemini", "opencode"].contains(&tool),
"unexpected tool name: {tool}"
);
@@ -233,15 +360,47 @@ fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<Stri
return (None, Some(format!("[WSL:{distro}] invalid distro name")));
}
// 构建 Shell 脚本检测逻辑
let (shell, flag, cmd) = if let Some(shell) = force_shell {
// Defensive validation: never allow an arbitrary executable name here.
if !is_valid_shell(shell) {
return (None, Some(format!("[WSL:{distro}] invalid shell: {shell}")));
}
let shell = shell.rsplit('/').next().unwrap_or(shell);
let flag = if let Some(flag) = force_shell_flag {
if !is_valid_shell_flag(flag) {
return (
None,
Some(format!("[WSL:{distro}] invalid shell flag: {flag}")),
);
}
flag
} else {
default_flag_for_shell(shell)
};
(shell.to_string(), flag, format!("{tool} --version"))
} else {
let cmd = if let Some(flag) = force_shell_flag {
if !is_valid_shell_flag(flag) {
return (
None,
Some(format!("[WSL:{distro}] invalid shell flag: {flag}")),
);
}
format!("\"${{SHELL:-sh}}\" {flag} '{tool} --version'")
} else {
// 兜底:自动尝试 -lic, -lc, -c
format!(
"\"${{SHELL:-sh}}\" -lic '{tool} --version' 2>/dev/null || \"${{SHELL:-sh}}\" -lc '{tool} --version' 2>/dev/null || \"${{SHELL:-sh}}\" -c '{tool} --version'"
)
};
("sh".to_string(), "-c", cmd)
};
let output = Command::new("wsl.exe")
.args([
"-d",
distro,
"--",
"sh",
"-lc",
&format!("{tool} --version"),
])
.args(["-d", distro, "--", &shell, flag, &cmd])
.creation_flags(CREATE_NO_WINDOW)
.output();
@@ -282,93 +441,191 @@ fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<Stri
/// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。
/// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。
#[cfg(not(target_os = "windows"))]
fn try_get_version_wsl(_tool: &str, _distro: &str) -> (Option<String>, Option<String>) {
fn try_get_version_wsl(
_tool: &str,
_distro: &str,
_force_shell: Option<&str>,
_force_shell_flag: Option<&str>,
) -> (Option<String>, Option<String>) {
(
None,
Some("WSL check not supported on this platform".to_string()),
)
}
fn push_unique_path(paths: &mut Vec<std::path::PathBuf>, path: std::path::PathBuf) {
if path.as_os_str().is_empty() {
return;
}
if !paths.iter().any(|existing| existing == &path) {
paths.push(path);
}
}
fn push_env_single_dir(paths: &mut Vec<std::path::PathBuf>, value: Option<std::ffi::OsString>) {
if let Some(raw) = value {
push_unique_path(paths, std::path::PathBuf::from(raw));
}
}
fn extend_from_path_list(
paths: &mut Vec<std::path::PathBuf>,
value: Option<std::ffi::OsString>,
suffix: Option<&str>,
) {
if let Some(raw) = value {
for p in std::env::split_paths(&raw) {
let dir = match suffix {
Some(s) => p.join(s),
None => p,
};
push_unique_path(paths, dir);
}
}
}
/// OpenCode install.sh 路径优先级(见 https://github.com/anomalyco/opencode README:
/// $OPENCODE_INSTALL_DIR > $XDG_BIN_DIR > $HOME/bin > $HOME/.opencode/bin
/// 额外扫描 Go 安装路径(~/go/bin、$GOPATH/*/bin)。
fn opencode_extra_search_paths(
home: &Path,
opencode_install_dir: Option<std::ffi::OsString>,
xdg_bin_dir: Option<std::ffi::OsString>,
gopath: Option<std::ffi::OsString>,
) -> Vec<std::path::PathBuf> {
let mut paths = Vec::new();
push_env_single_dir(&mut paths, opencode_install_dir);
push_env_single_dir(&mut paths, xdg_bin_dir);
if !home.as_os_str().is_empty() {
push_unique_path(&mut paths, home.join("bin"));
push_unique_path(&mut paths, home.join(".opencode").join("bin"));
push_unique_path(&mut paths, home.join("go").join("bin"));
}
extend_from_path_list(&mut paths, gopath, Some("bin"));
paths
}
fn tool_executable_candidates(tool: &str, dir: &Path) -> Vec<std::path::PathBuf> {
#[cfg(target_os = "windows")]
{
vec![
dir.join(format!("{tool}.cmd")),
dir.join(format!("{tool}.exe")),
dir.join(tool),
]
}
#[cfg(not(target_os = "windows"))]
{
vec![dir.join(tool)]
}
}
/// 扫描常见路径查找 CLI
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
use std::process::Command;
let home = dirs::home_dir().unwrap_or_default();
// 常见的 npm 全局安装路径
let mut search_paths: Vec<std::path::PathBuf> = vec![
home.join(".npm-global/bin"),
home.join(".local/bin"),
home.join("n/bin"), // n version manager
];
// 常见的安装路径(原生安装优先)
let mut search_paths: Vec<std::path::PathBuf> = Vec::new();
if !home.as_os_str().is_empty() {
push_unique_path(&mut search_paths, home.join(".local/bin"));
push_unique_path(&mut search_paths, home.join(".npm-global/bin"));
push_unique_path(&mut search_paths, home.join("n/bin"));
push_unique_path(&mut search_paths, home.join(".volta/bin"));
}
#[cfg(target_os = "macos")]
{
search_paths.push(std::path::PathBuf::from("/opt/homebrew/bin"));
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
push_unique_path(
&mut search_paths,
std::path::PathBuf::from("/opt/homebrew/bin"),
);
push_unique_path(
&mut search_paths,
std::path::PathBuf::from("/usr/local/bin"),
);
}
#[cfg(target_os = "linux")]
{
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
search_paths.push(std::path::PathBuf::from("/usr/bin"));
push_unique_path(
&mut search_paths,
std::path::PathBuf::from("/usr/local/bin"),
);
push_unique_path(&mut search_paths, std::path::PathBuf::from("/usr/bin"));
}
#[cfg(target_os = "windows")]
{
if let Some(appdata) = dirs::data_dir() {
search_paths.push(appdata.join("npm"));
push_unique_path(&mut search_paths, appdata.join("npm"));
}
search_paths.push(std::path::PathBuf::from("C:\\Program Files\\nodejs"));
push_unique_path(
&mut search_paths,
std::path::PathBuf::from("C:\\Program Files\\nodejs"),
);
}
// 添加 fnm 路径支持
let fnm_base = home.join(".local/state/fnm_multishells");
if fnm_base.exists() {
if let Ok(entries) = std::fs::read_dir(&fnm_base) {
for entry in entries.flatten() {
let bin_path = entry.path().join("bin");
if bin_path.exists() {
search_paths.push(bin_path);
push_unique_path(&mut search_paths, bin_path);
}
}
}
}
// 扫描 nvm 目录下的所有 node 版本
let nvm_base = home.join(".nvm/versions/node");
if nvm_base.exists() {
if let Ok(entries) = std::fs::read_dir(&nvm_base) {
for entry in entries.flatten() {
let bin_path = entry.path().join("bin");
if bin_path.exists() {
search_paths.push(bin_path);
push_unique_path(&mut search_paths, bin_path);
}
}
}
}
// 在每个路径中查找工具
if tool == "opencode" {
let extra_paths = opencode_extra_search_paths(
&home,
std::env::var_os("OPENCODE_INSTALL_DIR"),
std::env::var_os("XDG_BIN_DIR"),
std::env::var_os("GOPATH"),
);
for path in extra_paths {
push_unique_path(&mut search_paths, path);
}
}
let current_path = std::env::var("PATH").unwrap_or_default();
for path in &search_paths {
let tool_path = if cfg!(target_os = "windows") {
path.join(format!("{tool}.cmd"))
} else {
path.join(tool)
};
#[cfg(target_os = "windows")]
let new_path = format!("{};{}", path.display(), current_path);
if tool_path.exists() {
// 构建 PATH 环境变量,确保 node 可被找到
let current_path = std::env::var("PATH").unwrap_or_default();
#[cfg(not(target_os = "windows"))]
let new_path = format!("{}:{}", path.display(), current_path);
#[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);
for tool_path in tool_executable_candidates(tool, path) {
if !tool_path.exists() {
continue;
}
#[cfg(target_os = "windows")]
let output = {
// 使用 cmd /C 包装执行,确保子进程也在隐藏的控制台中运行
Command::new("cmd")
.args(["/C", &format!("\"{}\" --version", tool_path.display())])
.env("PATH", &new_path)
@@ -405,6 +662,7 @@ fn wsl_distro_for_tool(tool: &str) -> Option<String> {
"claude" => crate::settings::get_claude_override_dir(),
"codex" => crate::settings::get_codex_override_dir(),
"gemini" => crate::settings::get_gemini_override_dir(),
"opencode" => crate::settings::get_opencode_override_dir(),
_ => None,
}?;
@@ -581,18 +839,19 @@ fn write_claude_config(
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
}
/// macOS: 使用 Terminal.app 启动
/// macOS: 根据用户首选终端启动
#[cfg(target_os = "macos")]
fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
let preferred = crate::settings::get_preferred_terminal();
let terminal = preferred.as_deref().unwrap_or("terminal");
let temp_dir = std::env::temp_dir();
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
let config_path = config_file.to_string_lossy();
// Write the shell script to a temp file (no escaping needed!)
// Write the shell script to a temp file
let script_content = format!(
r#"#!/bin/bash
trap 'rm -f "{config_path}" "{script_file}"' EXIT
@@ -611,7 +870,35 @@ exec bash --norc --noprofile
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
// Simple AppleScript - just execute the script file
// Try the preferred terminal first, fall back to Terminal.app if it fails
// Note: Kitty doesn't need the -e flag, others do
let result = match terminal {
"iterm2" => launch_macos_iterm2(&script_file),
"alacritty" => launch_macos_open_app("Alacritty", &script_file, true),
"kitty" => launch_macos_open_app("kitty", &script_file, false),
"ghostty" => launch_macos_open_app("Ghostty", &script_file, true),
"wezterm" => launch_macos_open_app("WezTerm", &script_file, true),
_ => launch_macos_terminal_app(&script_file), // "terminal" or default
};
// If preferred terminal fails and it's not the default, try Terminal.app as fallback
if result.is_err() && terminal != "terminal" {
log::warn!(
"首选终端 {} 启动失败,回退到 Terminal.app: {:?}",
terminal,
result.as_ref().err()
);
return launch_macos_terminal_app(&script_file);
}
result
}
/// macOS: Terminal.app
#[cfg(target_os = "macos")]
fn launch_macos_terminal_app(script_file: &std::path::Path) -> Result<(), String> {
use std::process::Command;
let applescript = format!(
r#"tell application "Terminal"
activate
@@ -627,12 +914,9 @@ end tell"#,
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
if !output.status.success() {
// Clean up on failure
let _ = std::fs::remove_file(&script_file);
let _ = std::fs::remove_file(config_file);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"AppleScript 执行失败 (exit code: {:?}): {}",
"Terminal.app 执行失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
@@ -641,13 +925,86 @@ end tell"#,
Ok(())
}
/// Linux: 尝试使用常见终端启动
/// macOS: iTerm2
#[cfg(target_os = "macos")]
fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> {
use std::process::Command;
let applescript = format!(
r#"tell application "iTerm"
activate
tell current window
create tab with default profile
tell current session
write text "bash '{}'"
end tell
end tell
end tell"#,
script_file.display()
);
let output = Command::new("osascript")
.arg("-e")
.arg(&applescript)
.output()
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"iTerm2 执行失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
}
Ok(())
}
/// macOS: 使用 open -a 启动支持 --args 参数的终端(Alacritty/Kitty/Ghostty
#[cfg(target_os = "macos")]
fn launch_macos_open_app(
app_name: &str,
script_file: &std::path::Path,
use_e_flag: bool,
) -> Result<(), String> {
use std::process::Command;
let mut cmd = Command::new("open");
cmd.arg("-a").arg(app_name).arg("--args");
if use_e_flag {
cmd.arg("-e");
}
cmd.arg("bash").arg(script_file);
let output = cmd
.output()
.map_err(|e| format!("启动 {app_name} 失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"{} 启动失败 (exit code: {:?}): {}",
app_name,
output.status.code(),
stderr
));
}
Ok(())
}
/// Linux: 根据用户首选终端启动
#[cfg(target_os = "linux")]
fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
let terminals = [
let preferred = crate::settings::get_preferred_terminal();
// Default terminal list with their arguments
let default_terminals = [
("gnome-terminal", vec!["--"]),
("konsole", vec!["-e"]),
("xfce4-terminal", vec!["-e"]),
@@ -655,9 +1012,10 @@ fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
("lxterminal", vec!["-e"]),
("alacritty", vec!["-e"]),
("kitty", vec!["-e"]),
("ghostty", vec!["-e"]),
];
// Create temp script file (same approach as macOS)
// Create temp script file
let temp_dir = std::env::temp_dir();
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
let config_path = config_file.to_string_lossy();
@@ -679,25 +1037,48 @@ exec bash --norc --noprofile
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
// Build terminal list: preferred terminal first (if specified), then defaults
let terminals_to_try: Vec<(&str, Vec<&str>)> = if let Some(ref pref) = preferred {
// Find the preferred terminal's args from default list
let pref_args = default_terminals
.iter()
.find(|(name, _)| *name == pref.as_str())
.map(|(_, args)| args.iter().map(|s| *s).collect::<Vec<&str>>())
.unwrap_or_else(|| vec!["-e"]); // Default args for unknown terminals
let mut list = vec![(pref.as_str(), pref_args)];
// Add remaining terminals as fallbacks
for (name, args) in &default_terminals {
if *name != pref.as_str() {
list.push((*name, args.iter().map(|s| *s).collect()));
}
}
list
} else {
default_terminals
.iter()
.map(|(name, args)| (*name, args.iter().map(|s| *s).collect()))
.collect()
};
let mut last_error = String::from("未找到可用的终端");
for (terminal, args) in terminals {
// Check if terminal exists
if std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
for (terminal, args) in terminals_to_try {
// Check if terminal exists in common paths
let terminal_exists = std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
{
|| std::path::Path::new(&format!("/usr/local/bin/{}", terminal)).exists()
|| which_command(terminal);
if terminal_exists {
let result = Command::new(terminal)
.args(&args)
.arg("bash")
.arg(script_file.to_string_lossy().as_ref())
.output();
.spawn();
match result {
Ok(output) if output.status.success() => return Ok(()),
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
last_error = format!("启动 {} 失败: {}", terminal, stderr);
}
Ok(_) => return Ok(()),
Err(e) => {
last_error = format!("执行 {} 失败: {}", terminal, e);
}
@@ -711,13 +1092,25 @@ exec bash --norc --noprofile
Err(last_error)
}
/// Windows: 创建临时批处理文件启动
/// Check if a command exists using `which`
#[cfg(target_os = "linux")]
fn which_command(cmd: &str) -> bool {
use std::process::Command;
Command::new("which")
.arg(cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Windows: 根据用户首选终端启动
#[cfg(target_os = "windows")]
fn launch_windows_terminal(
temp_dir: &std::path::Path,
config_file: &std::path::Path,
) -> Result<(), String> {
use std::process::Command;
let preferred = crate::settings::get_preferred_terminal();
let terminal = preferred.as_deref().unwrap_or("cmd");
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
@@ -729,30 +1122,57 @@ echo {}
claude --settings \"{}\"
del \"{}\" >nul 2>&1
del \"%~f0\" >nul 2>&1
if errorlevel 1 (
echo.
echo Press any key to close...
pause >nul
)",
",
config_path_for_batch, config_path_for_batch, config_path_for_batch
);
std::fs::write(&bat_file, content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
let bat_path = bat_file.to_string_lossy();
let ps_cmd = format!("& '{}'", bat_path);
// Try the preferred terminal first
let result = match terminal {
"powershell" => run_windows_start_command(
&["powershell", "-NoExit", "-Command", &ps_cmd],
"PowerShell",
),
"wt" => run_windows_start_command(&["wt", "cmd", "/K", &bat_path], "Windows Terminal"),
_ => run_windows_start_command(&["cmd", "/K", &bat_path], "cmd"), // "cmd" or default
};
// If preferred terminal fails and it's not the default, try cmd as fallback
if result.is_err() && terminal != "cmd" {
log::warn!(
"首选终端 {} 启动失败,回退到 cmd: {:?}",
terminal,
result.as_ref().err()
);
return run_windows_start_command(&["cmd", "/K", &bat_path], "cmd");
}
result
}
/// Windows: Run a start command with common error handling
#[cfg(target_os = "windows")]
fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> {
use std::process::Command;
let mut full_args = vec!["/C", "start"];
full_args.extend(args);
// Use output() to capture errors from the start command
let output = Command::new("cmd")
.args(["/C", "start", "cmd", "/C", &bat_file.to_string_lossy()])
.args(&full_args)
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| format!("执行 cmd 失败: {e}"))?;
.map_err(|e| format!("启动 {} 失败: {e}", terminal_name))?;
if !output.status.success() {
// Clean up on failure
let _ = std::fs::remove_file(&bat_file);
let _ = std::fs::remove_file(config_file);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"启动 Windows 终端失败 (exit code: {:?}): {}",
"{} 启动失败 (exit code: {:?}): {}",
terminal_name,
output.status.code(),
stderr
));
@@ -760,3 +1180,139 @@ if errorlevel 1 (
Ok(())
}
/// 设置窗口主题(Windows/macOS 标题栏颜色)
/// theme: "dark" | "light" | "system"
#[tauri::command]
pub async fn set_window_theme(window: tauri::Window, theme: String) -> Result<(), String> {
use tauri::Theme;
let tauri_theme = match theme.as_str() {
"dark" => Some(Theme::Dark),
"light" => Some(Theme::Light),
_ => None, // system default
};
window.set_theme(tauri_theme).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_extract_version() {
assert_eq!(extract_version("claude 1.0.20"), "1.0.20");
assert_eq!(extract_version("v2.3.4-beta.1"), "2.3.4-beta.1");
assert_eq!(extract_version("no version here"), "no version here");
}
#[cfg(target_os = "windows")]
mod wsl_helpers {
use super::super::*;
#[test]
fn test_is_valid_shell() {
assert!(is_valid_shell("bash"));
assert!(is_valid_shell("zsh"));
assert!(is_valid_shell("sh"));
assert!(is_valid_shell("fish"));
assert!(is_valid_shell("dash"));
assert!(is_valid_shell("/usr/bin/bash"));
assert!(is_valid_shell("/bin/zsh"));
assert!(!is_valid_shell("powershell"));
assert!(!is_valid_shell("cmd"));
assert!(!is_valid_shell(""));
}
#[test]
fn test_is_valid_shell_flag() {
assert!(is_valid_shell_flag("-c"));
assert!(is_valid_shell_flag("-lc"));
assert!(is_valid_shell_flag("-lic"));
assert!(!is_valid_shell_flag("-x"));
assert!(!is_valid_shell_flag(""));
assert!(!is_valid_shell_flag("--login"));
}
#[test]
fn test_default_flag_for_shell() {
assert_eq!(default_flag_for_shell("sh"), "-c");
assert_eq!(default_flag_for_shell("dash"), "-c");
assert_eq!(default_flag_for_shell("/bin/dash"), "-c");
assert_eq!(default_flag_for_shell("fish"), "-lc");
assert_eq!(default_flag_for_shell("bash"), "-lic");
assert_eq!(default_flag_for_shell("zsh"), "-lic");
assert_eq!(default_flag_for_shell("/usr/bin/zsh"), "-lic");
}
#[test]
fn test_is_valid_wsl_distro_name() {
assert!(is_valid_wsl_distro_name("Ubuntu"));
assert!(is_valid_wsl_distro_name("Ubuntu-22.04"));
assert!(is_valid_wsl_distro_name("my_distro"));
assert!(!is_valid_wsl_distro_name(""));
assert!(!is_valid_wsl_distro_name("distro with spaces"));
assert!(!is_valid_wsl_distro_name(&"a".repeat(65)));
}
}
#[test]
fn opencode_extra_search_paths_includes_install_and_fallback_dirs() {
let home = PathBuf::from("/home/tester");
let install_dir = Some(std::ffi::OsString::from("/custom/opencode/bin"));
let xdg_bin_dir = Some(std::ffi::OsString::from("/xdg/bin"));
let gopath =
std::env::join_paths([PathBuf::from("/go/path1"), PathBuf::from("/go/path2")]).ok();
let paths = opencode_extra_search_paths(&home, install_dir, xdg_bin_dir, gopath);
assert_eq!(paths[0], PathBuf::from("/custom/opencode/bin"));
assert_eq!(paths[1], PathBuf::from("/xdg/bin"));
assert!(paths.contains(&PathBuf::from("/home/tester/bin")));
assert!(paths.contains(&PathBuf::from("/home/tester/.opencode/bin")));
assert!(paths.contains(&PathBuf::from("/home/tester/go/bin")));
assert!(paths.contains(&PathBuf::from("/go/path1/bin")));
assert!(paths.contains(&PathBuf::from("/go/path2/bin")));
}
#[test]
fn opencode_extra_search_paths_deduplicates_repeated_entries() {
let home = PathBuf::from("/home/tester");
let same_dir = Some(std::ffi::OsString::from("/same/path"));
let paths = opencode_extra_search_paths(&home, same_dir.clone(), same_dir.clone(), None);
let count = paths
.iter()
.filter(|path| **path == PathBuf::from("/same/path"))
.count();
assert_eq!(count, 1);
}
#[cfg(not(target_os = "windows"))]
#[test]
fn tool_executable_candidates_non_windows_uses_plain_binary_name() {
let dir = PathBuf::from("/usr/local/bin");
let candidates = tool_executable_candidates("opencode", &dir);
assert_eq!(candidates, vec![PathBuf::from("/usr/local/bin/opencode")]);
}
#[cfg(target_os = "windows")]
#[test]
fn tool_executable_candidates_windows_includes_cmd_exe_and_plain_name() {
let dir = PathBuf::from("C:\\tools");
let candidates = tool_executable_candidates("opencode", &dir);
assert_eq!(
candidates,
vec![
PathBuf::from("C:\\tools\\opencode.cmd"),
PathBuf::from("C:\\tools\\opencode.exe"),
PathBuf::from("C:\\tools\\opencode"),
]
);
}
}
+11
View File
@@ -8,14 +8,20 @@ mod global_proxy;
mod import_export;
mod mcp;
mod misc;
mod omo;
mod openclaw;
mod plugin;
mod prompt;
mod provider;
mod proxy;
mod session_manager;
mod settings;
pub mod skill;
mod stream_check;
mod sync_support;
mod usage;
mod webdav_sync;
mod workspace;
pub use config::*;
pub use deeplink::*;
@@ -25,11 +31,16 @@ pub use global_proxy::*;
pub use import_export::*;
pub use mcp::*;
pub use misc::*;
pub use omo::*;
pub use openclaw::*;
pub use plugin::*;
pub use prompt::*;
pub use provider::*;
pub use proxy::*;
pub use session_manager::*;
pub use settings::*;
pub use skill::*;
pub use stream_check::*;
pub use usage::*;
pub use webdav_sync::*;
pub use workspace::*;
+99
View File
@@ -0,0 +1,99 @@
use tauri::State;
use crate::services::omo::{OmoLocalFileData, SLIM, STANDARD};
use crate::services::OmoService;
use crate::store::AppState;
#[tauri::command]
pub async fn read_omo_local_file() -> Result<OmoLocalFileData, String> {
OmoService::read_local_file(&STANDARD).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_current_omo_provider_id(state: State<'_, AppState>) -> Result<String, String> {
let provider = state
.db
.get_current_omo_provider("opencode", "omo")
.map_err(|e| e.to_string())?;
Ok(provider.map(|p| p.id).unwrap_or_default())
}
#[tauri::command]
pub async fn disable_current_omo(state: State<'_, AppState>) -> Result<(), String> {
let providers = state
.db
.get_all_providers("opencode")
.map_err(|e| e.to_string())?;
for (id, p) in &providers {
if p.category.as_deref() == Some("omo") {
state
.db
.clear_omo_provider_current("opencode", id, "omo")
.map_err(|e| e.to_string())?;
}
}
OmoService::delete_config_file(&STANDARD).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn get_omo_provider_count(state: State<'_, AppState>) -> Result<usize, String> {
let providers = state
.db
.get_all_providers("opencode")
.map_err(|e| e.to_string())?;
let count = providers
.values()
.filter(|p| p.category.as_deref() == Some("omo"))
.count();
Ok(count)
}
// ── OMO Slim commands ───────────────────────────────────────
#[tauri::command]
pub async fn read_omo_slim_local_file() -> Result<OmoLocalFileData, String> {
OmoService::read_local_file(&SLIM).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_current_omo_slim_provider_id(
state: State<'_, AppState>,
) -> Result<String, String> {
let provider = state
.db
.get_current_omo_provider("opencode", "omo-slim")
.map_err(|e| e.to_string())?;
Ok(provider.map(|p| p.id).unwrap_or_default())
}
#[tauri::command]
pub async fn disable_current_omo_slim(state: State<'_, AppState>) -> Result<(), String> {
let providers = state
.db
.get_all_providers("opencode")
.map_err(|e| e.to_string())?;
for (id, p) in &providers {
if p.category.as_deref() == Some("omo-slim") {
state
.db
.clear_omo_provider_current("opencode", id, "omo-slim")
.map_err(|e| e.to_string())?;
}
}
OmoService::delete_config_file(&SLIM).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn get_omo_slim_provider_count(state: State<'_, AppState>) -> Result<usize, String> {
let providers = state
.db
.get_all_providers("opencode")
.map_err(|e| e.to_string())?;
let count = providers
.values()
.filter(|p| p.category.as_deref() == Some("omo-slim"))
.count();
Ok(count)
}
+108
View File
@@ -0,0 +1,108 @@
use std::collections::HashMap;
use tauri::State;
use crate::openclaw_config;
use crate::store::AppState;
// ============================================================================
// OpenClaw Provider Commands (migrated from provider.rs)
// ============================================================================
/// Import providers from OpenClaw live config to database.
///
/// OpenClaw uses additive mode — users may already have providers
/// configured in openclaw.json.
#[tauri::command]
pub fn import_openclaw_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
crate::services::provider::import_openclaw_providers_from_live(state.inner())
.map_err(|e| e.to_string())
}
/// Get provider IDs in the OpenClaw live config.
#[tauri::command]
pub fn get_openclaw_live_provider_ids() -> Result<Vec<String>, String> {
openclaw_config::get_providers()
.map(|providers| providers.keys().cloned().collect())
.map_err(|e| e.to_string())
}
// ============================================================================
// Agents Configuration Commands
// ============================================================================
/// Get OpenClaw default model config (agents.defaults.model)
#[tauri::command]
pub fn get_openclaw_default_model() -> Result<Option<openclaw_config::OpenClawDefaultModel>, String>
{
openclaw_config::get_default_model().map_err(|e| e.to_string())
}
/// Set OpenClaw default model config (agents.defaults.model)
#[tauri::command]
pub fn set_openclaw_default_model(
model: openclaw_config::OpenClawDefaultModel,
) -> Result<(), String> {
openclaw_config::set_default_model(&model).map_err(|e| e.to_string())
}
/// Get OpenClaw model catalog/allowlist (agents.defaults.models)
#[tauri::command]
pub fn get_openclaw_model_catalog(
) -> Result<Option<HashMap<String, openclaw_config::OpenClawModelCatalogEntry>>, String> {
openclaw_config::get_model_catalog().map_err(|e| e.to_string())
}
/// Set OpenClaw model catalog/allowlist (agents.defaults.models)
#[tauri::command]
pub fn set_openclaw_model_catalog(
catalog: HashMap<String, openclaw_config::OpenClawModelCatalogEntry>,
) -> Result<(), String> {
openclaw_config::set_model_catalog(&catalog).map_err(|e| e.to_string())
}
/// Get full agents.defaults config (all fields)
#[tauri::command]
pub fn get_openclaw_agents_defaults(
) -> Result<Option<openclaw_config::OpenClawAgentsDefaults>, String> {
openclaw_config::get_agents_defaults().map_err(|e| e.to_string())
}
/// Set full agents.defaults config (all fields)
#[tauri::command]
pub fn set_openclaw_agents_defaults(
defaults: openclaw_config::OpenClawAgentsDefaults,
) -> Result<(), String> {
openclaw_config::set_agents_defaults(&defaults).map_err(|e| e.to_string())
}
// ============================================================================
// Env Configuration Commands
// ============================================================================
/// Get OpenClaw env config (env section of openclaw.json)
#[tauri::command]
pub fn get_openclaw_env() -> Result<openclaw_config::OpenClawEnvConfig, String> {
openclaw_config::get_env_config().map_err(|e| e.to_string())
}
/// Set OpenClaw env config (env section of openclaw.json)
#[tauri::command]
pub fn set_openclaw_env(env: openclaw_config::OpenClawEnvConfig) -> Result<(), String> {
openclaw_config::set_env_config(&env).map_err(|e| e.to_string())
}
// ============================================================================
// Tools Configuration Commands
// ============================================================================
/// Get OpenClaw tools config (tools section of openclaw.json)
#[tauri::command]
pub fn get_openclaw_tools() -> Result<openclaw_config::OpenClawToolsConfig, String> {
openclaw_config::get_tools_config().map_err(|e| e.to_string())
}
/// Set OpenClaw tools config (tools section of openclaw.json)
#[tauri::command]
pub fn set_openclaw_tools(tools: openclaw_config::OpenClawToolsConfig) -> Result<(), String> {
openclaw_config::set_tools_config(&tools).map_err(|e| e.to_string())
}
+30 -55
View File
@@ -4,11 +4,12 @@ use tauri::State;
use crate::app_config::AppType;
use crate::error::AppError;
use crate::provider::Provider;
use crate::services::{EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService};
use crate::services::{
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
};
use crate::store::AppState;
use std::str::FromStr;
/// 获取所有供应商
#[tauri::command]
pub fn get_providers(
state: State<'_, AppState>,
@@ -18,14 +19,12 @@ pub fn get_providers(
ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string())
}
/// 获取当前供应商ID
#[tauri::command]
pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result<String, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::current(state.inner(), app_type).map_err(|e| e.to_string())
}
/// 添加供应商
#[tauri::command]
pub fn add_provider(
state: State<'_, AppState>,
@@ -36,7 +35,6 @@ pub fn add_provider(
ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string())
}
/// 更新供应商
#[tauri::command]
pub fn update_provider(
state: State<'_, AppState>,
@@ -47,7 +45,6 @@ pub fn update_provider(
ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string())
}
/// 删除供应商
#[tauri::command]
pub fn delete_provider(
state: State<'_, AppState>,
@@ -60,18 +57,23 @@ pub fn delete_provider(
.map_err(|e| e.to_string())
}
/// Remove provider from live config only (for additive mode apps like OpenCode)
/// Does NOT delete from database - provider remains in the list
#[tauri::command]
pub fn remove_provider_from_live_config(app: String, id: String) -> Result<bool, String> {
pub fn remove_provider_from_live_config(
state: tauri::State<'_, AppState>,
app: String,
id: String,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::remove_from_live_config(app_type, &id)
ProviderService::remove_from_live_config(state.inner(), app_type, &id)
.map(|_| true)
.map_err(|e| e.to_string())
}
/// 切换供应商
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
fn switch_provider_internal(
state: &AppState,
app_type: AppType,
id: &str,
) -> Result<SwitchResult, AppError> {
ProviderService::switch(state, app_type, id)
}
@@ -80,7 +82,7 @@ pub fn switch_provider_test_hook(
state: &AppState,
app_type: AppType,
id: &str,
) -> Result<(), AppError> {
) -> Result<SwitchResult, AppError> {
switch_provider_internal(state, app_type, id)
}
@@ -89,15 +91,15 @@ pub fn switch_provider(
state: State<'_, AppState>,
app: String,
id: String,
) -> Result<bool, String> {
) -> Result<SwitchResult, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
switch_provider_internal(&state, app_type, &id)
.map(|_| true)
.map_err(|e| e.to_string())
switch_provider_internal(&state, app_type, &id).map_err(|e| e.to_string())
}
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
ProviderService::import_default_config(state, app_type)
let imported = ProviderService::import_default_config(state, app_type)?;
Ok(imported)
}
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
@@ -108,14 +110,12 @@ pub fn import_default_config_test_hook(
import_default_config_internal(state, app_type)
}
/// 导入当前配置为默认供应商
#[tauri::command]
pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
import_default_config_internal(&state, app_type).map_err(Into::into)
}
/// 查询供应商用量
#[allow(non_snake_case)]
#[tauri::command]
pub async fn queryProviderUsage(
@@ -129,7 +129,6 @@ pub async fn queryProviderUsage(
.map_err(|e| e.to_string())
}
/// 测试用量脚本(使用当前编辑器中的脚本,不保存)
#[allow(non_snake_case)]
#[allow(clippy::too_many_arguments)]
#[tauri::command]
@@ -162,14 +161,18 @@ pub async fn testUsageScript(
.map_err(|e| e.to_string())
}
/// 读取当前生效的配置内容
#[tauri::command]
pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
}
/// 测试第三方/自定义供应商端点的网络延迟
#[tauri::command]
pub fn patch_claude_live_settings(patch: serde_json::Value) -> Result<bool, String> {
ProviderService::patch_claude_live(patch).map_err(|e| e.to_string())?;
Ok(true)
}
#[tauri::command]
pub async fn test_api_endpoints(
urls: Vec<String>,
@@ -180,7 +183,6 @@ pub async fn test_api_endpoints(
.map_err(|e| e.to_string())
}
/// 获取自定义端点列表
#[tauri::command]
pub fn get_custom_endpoints(
state: State<'_, AppState>,
@@ -192,7 +194,6 @@ pub fn get_custom_endpoints(
.map_err(|e| e.to_string())
}
/// 添加自定义端点
#[tauri::command]
pub fn add_custom_endpoint(
state: State<'_, AppState>,
@@ -205,7 +206,6 @@ pub fn add_custom_endpoint(
.map_err(|e| e.to_string())
}
/// 删除自定义端点
#[tauri::command]
pub fn remove_custom_endpoint(
state: State<'_, AppState>,
@@ -218,7 +218,6 @@ pub fn remove_custom_endpoint(
.map_err(|e| e.to_string())
}
/// 更新端点最后使用时间
#[tauri::command]
pub fn update_endpoint_last_used(
state: State<'_, AppState>,
@@ -231,7 +230,6 @@ pub fn update_endpoint_last_used(
.map_err(|e| e.to_string())
}
/// 更新多个供应商的排序
#[tauri::command]
pub fn update_providers_sort_order(
state: State<'_, AppState>,
@@ -242,24 +240,16 @@ pub fn update_providers_sort_order(
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",
@@ -270,7 +260,6 @@ fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
);
}
/// 获取所有统一供应商
#[tauri::command]
pub fn get_universal_providers(
state: State<'_, AppState>,
@@ -278,7 +267,6 @@ pub fn get_universal_providers(
ProviderService::list_universal(state.inner()).map_err(|e| e.to_string())
}
/// 获取单个统一供应商
#[tauri::command]
pub fn get_universal_provider(
state: State<'_, AppState>,
@@ -287,7 +275,6 @@ pub fn get_universal_provider(
ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string())
}
/// 添加或更新统一供应商
#[tauri::command]
pub fn upsert_universal_provider(
app: AppHandle,
@@ -298,13 +285,11 @@ pub fn upsert_universal_provider(
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,
@@ -314,13 +299,11 @@ pub fn delete_universal_provider(
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,
@@ -330,32 +313,24 @@ pub fn sync_universal_provider(
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)
}
// ============================================================================
// OpenCode 专属命令
// ============================================================================
/// 从 OpenCode live 配置导入供应商到数据库
///
/// 这是 OpenCode 特有的功能,因为 OpenCode 使用累加模式,
/// 用户可能已经在 opencode.json 中配置了供应商。
#[tauri::command]
pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
crate::services::provider::import_opencode_providers_from_live(state.inner())
.map_err(|e| e.to_string())
}
/// 获取 OpenCode live 配置中的供应商 ID 列表
///
/// 用于前端判断供应商是否已添加到 opencode.json
#[tauri::command]
pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
crate::opencode_config::get_providers()
.map(|providers| providers.keys().cloned().collect())
.map_err(|e| e.to_string())
}
// ============================================================================
// OpenClaw 专属命令 → 已迁移至 commands/openclaw.rs
// ============================================================================
+115
View File
@@ -2,6 +2,7 @@
//!
//! 提供前端调用的 API 接口
use crate::error::AppError;
use crate::proxy::types::*;
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
use crate::store::AppState;
@@ -119,6 +120,120 @@ pub async fn update_proxy_config_for_app(
.map_err(|e| e.to_string())
}
async fn get_default_cost_multiplier_internal(
state: &AppState,
app_type: &str,
) -> Result<String, AppError> {
let db = &state.db;
db.get_default_cost_multiplier(app_type).await
}
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
pub async fn get_default_cost_multiplier_test_hook(
state: &AppState,
app_type: &str,
) -> Result<String, AppError> {
get_default_cost_multiplier_internal(state, app_type).await
}
/// 获取默认成本倍率
#[tauri::command]
pub async fn get_default_cost_multiplier(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<String, String> {
get_default_cost_multiplier_internal(&state, &app_type)
.await
.map_err(|e| e.to_string())
}
async fn set_default_cost_multiplier_internal(
state: &AppState,
app_type: &str,
value: &str,
) -> Result<(), AppError> {
let db = &state.db;
db.set_default_cost_multiplier(app_type, value).await
}
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
pub async fn set_default_cost_multiplier_test_hook(
state: &AppState,
app_type: &str,
value: &str,
) -> Result<(), AppError> {
set_default_cost_multiplier_internal(state, app_type, value).await
}
/// 设置默认成本倍率
#[tauri::command]
pub async fn set_default_cost_multiplier(
state: tauri::State<'_, AppState>,
app_type: String,
value: String,
) -> Result<(), String> {
set_default_cost_multiplier_internal(&state, &app_type, &value)
.await
.map_err(|e| e.to_string())
}
async fn get_pricing_model_source_internal(
state: &AppState,
app_type: &str,
) -> Result<String, AppError> {
let db = &state.db;
db.get_pricing_model_source(app_type).await
}
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
pub async fn get_pricing_model_source_test_hook(
state: &AppState,
app_type: &str,
) -> Result<String, AppError> {
get_pricing_model_source_internal(state, app_type).await
}
/// 获取计费模式来源
#[tauri::command]
pub async fn get_pricing_model_source(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<String, String> {
get_pricing_model_source_internal(&state, &app_type)
.await
.map_err(|e| e.to_string())
}
async fn set_pricing_model_source_internal(
state: &AppState,
app_type: &str,
value: &str,
) -> Result<(), AppError> {
let db = &state.db;
db.set_pricing_model_source(app_type, value).await
}
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
pub async fn set_pricing_model_source_test_hook(
state: &AppState,
app_type: &str,
value: &str,
) -> Result<(), AppError> {
set_pricing_model_source_internal(state, app_type, value).await
}
/// 设置计费模式来源
#[tauri::command]
pub async fn set_pricing_model_source(
state: tauri::State<'_, AppState>,
app_type: String,
value: String,
) -> Result<(), String> {
set_pricing_model_source_internal(&state, &app_type, &value)
.await
.map_err(|e| e.to_string())
}
/// 检查代理服务器是否正在运行
#[tauri::command]
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
+59
View File
@@ -0,0 +1,59 @@
#![allow(non_snake_case)]
use crate::session_manager;
#[tauri::command]
pub async fn list_sessions() -> Result<Vec<session_manager::SessionMeta>, String> {
let sessions = tauri::async_runtime::spawn_blocking(session_manager::scan_sessions)
.await
.map_err(|e| format!("Failed to scan sessions: {e}"))?;
Ok(sessions)
}
#[tauri::command]
pub async fn get_session_messages(
providerId: String,
sourcePath: String,
) -> Result<Vec<session_manager::SessionMessage>, String> {
let provider_id = providerId.clone();
let source_path = sourcePath.clone();
tauri::async_runtime::spawn_blocking(move || {
session_manager::load_messages(&provider_id, &source_path)
})
.await
.map_err(|e| format!("Failed to load session messages: {e}"))?
}
#[tauri::command]
pub async fn launch_session_terminal(
command: String,
cwd: Option<String>,
custom_config: Option<String>,
) -> Result<bool, String> {
let command = command.clone();
let cwd = cwd.clone();
let custom_config = custom_config.clone();
// Read preferred terminal from global settings
let preferred = crate::settings::get_preferred_terminal();
// Map global setting terminal names to session terminal names
// Global uses "iterm2", session terminal uses "iterm"
let target = match preferred.as_deref() {
Some("iterm2") => "iterm".to_string(),
Some(t) => t.to_string(),
None => "terminal".to_string(), // Default to Terminal.app on macOS
};
tauri::async_runtime::spawn_blocking(move || {
session_manager::terminal::launch_terminal(
&target,
&command,
cwd.as_deref(),
custom_config.as_deref(),
)
})
.await
.map_err(|e| format!("Failed to launch terminal: {e}"))??;
Ok(true)
}
+66 -2
View File
@@ -2,16 +2,28 @@
use tauri::AppHandle;
fn merge_settings_for_save(
mut incoming: crate::settings::AppSettings,
existing: &crate::settings::AppSettings,
) -> crate::settings::AppSettings {
if incoming.webdav_sync.is_none() {
incoming.webdav_sync = existing.webdav_sync.clone();
}
incoming
}
/// 获取设置
#[tauri::command]
pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
Ok(crate::settings::get_settings())
Ok(crate::settings::get_settings_for_frontend())
}
/// 保存设置
#[tauri::command]
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
crate::settings::update_settings(settings).map_err(|e| e.to_string())?;
let existing = crate::settings::get_settings();
let merged = merge_settings_for_save(settings, &existing);
crate::settings::update_settings(merged).map_err(|e| e.to_string())?;
Ok(true)
}
@@ -54,6 +66,58 @@ pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
Ok(true)
}
#[cfg(test)]
mod tests {
use super::merge_settings_for_save;
use crate::settings::{AppSettings, WebDavSyncSettings};
#[test]
fn save_settings_should_preserve_existing_webdav_when_payload_omits_it() {
let mut existing = AppSettings::default();
existing.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "secret".to_string(),
..WebDavSyncSettings::default()
});
let incoming = AppSettings::default();
let merged = merge_settings_for_save(incoming, &existing);
assert!(merged.webdav_sync.is_some());
assert_eq!(
merged.webdav_sync.as_ref().map(|v| v.base_url.as_str()),
Some("https://dav.example.com")
);
}
#[test]
fn save_settings_should_keep_incoming_webdav_when_present() {
let mut existing = AppSettings::default();
existing.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.old.example.com".to_string(),
username: "old".to_string(),
password: "old-pass".to_string(),
..WebDavSyncSettings::default()
});
let mut incoming = AppSettings::default();
incoming.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.new.example.com".to_string(),
username: "new".to_string(),
password: "new-pass".to_string(),
..WebDavSyncSettings::default()
});
let merged = merge_settings_for_save(incoming, &existing);
assert_eq!(
merged.webdav_sync.as_ref().map(|v| v.base_url.as_str()),
Some("https://dav.new.example.com")
);
}
}
/// 获取开机自启状态
#[tauri::command]
pub async fn get_auto_launch_status() -> Result<bool, String> {
+13
View File
@@ -249,3 +249,16 @@ pub fn remove_skill_repo(
.map_err(|e| e.to_string())?;
Ok(true)
}
/// 从 ZIP 文件安装 Skills
#[tauri::command]
pub fn install_skills_from_zip(
file_path: String,
current_app: String,
app_state: State<'_, AppState>,
) -> Result<Vec<InstalledSkill>, String> {
let app_type = parse_app_type(&current_app)?;
let path = std::path::Path::new(&file_path);
SkillService::install_from_zip(&app_state.db, path, &app_type).map_err(|e| e.to_string())
}
+97
View File
@@ -0,0 +1,97 @@
use serde_json::{json, Value};
use std::sync::Arc;
use crate::database::Database;
use crate::error::AppError;
use crate::services::provider::ProviderService;
use crate::settings;
use crate::store::AppState;
pub(crate) fn run_post_import_sync(db: Arc<Database>) -> Result<(), AppError> {
let app_state = AppState::new(db);
ProviderService::sync_current_to_live(&app_state)?;
settings::reload_settings()?;
Ok(())
}
fn post_sync_warning<E: std::fmt::Display>(err: E) -> String {
AppError::localized(
"sync.post_operation_sync_failed",
format!("后置同步状态失败: {err}"),
format!("Post-operation synchronization failed: {err}"),
)
.to_string()
}
pub(crate) fn post_sync_warning_from_result(
result: Result<Result<(), AppError>, String>,
) -> Option<String> {
match result {
Ok(Ok(())) => None,
Ok(Err(err)) => Some(post_sync_warning(err)),
Err(err) => Some(post_sync_warning(err)),
}
}
pub(crate) fn attach_warning(mut value: Value, warning: Option<String>) -> Value {
if let Some(message) = warning {
if let Some(obj) = value.as_object_mut() {
obj.insert("warning".to_string(), Value::String(message));
}
}
value
}
pub(crate) fn success_payload_with_warning(backup_id: String, warning: Option<String>) -> Value {
attach_warning(
json!({
"success": true,
"message": "SQL imported successfully",
"backupId": backup_id
}),
warning,
)
}
#[cfg(test)]
mod tests {
use super::{attach_warning, post_sync_warning_from_result};
use serde_json::json;
#[test]
fn post_sync_warning_from_result_returns_none_on_success() {
let warning = post_sync_warning_from_result(Ok(Ok(())));
assert!(warning.is_none());
}
#[test]
fn post_sync_warning_from_result_returns_some_on_sync_error() {
let warning =
post_sync_warning_from_result(Ok(Err(crate::error::AppError::Config("boom".into()))));
assert!(warning.is_some());
}
#[tokio::test]
async fn post_sync_warning_from_result_returns_some_on_join_error() {
let handle = tokio::spawn(async move {
panic!("forced join error");
});
let join_err = handle.await.expect_err("task should panic");
let warning = post_sync_warning_from_result(Err(join_err.to_string()));
assert!(warning.is_some());
}
#[test]
fn attach_warning_adds_warning_without_dropping_existing_fields() {
let payload = json!({ "status": "downloaded" });
let updated = attach_warning(payload, Some("post sync warning".to_string()));
assert_eq!(
updated.get("status").and_then(|v| v.as_str()),
Some("downloaded")
);
assert_eq!(
updated.get("warning").and_then(|v| v.as_str()),
Some("post sync warning")
);
}
}
+357
View File
@@ -0,0 +1,357 @@
#![allow(non_snake_case)]
use serde_json::{json, Value};
use tauri::State;
use crate::commands::sync_support::{
attach_warning, post_sync_warning_from_result, run_post_import_sync,
};
use crate::error::AppError;
use crate::services::webdav_sync as webdav_sync_service;
use crate::settings::{self, WebDavSyncSettings};
use crate::store::AppState;
fn persist_sync_error(settings: &mut WebDavSyncSettings, error: &AppError, source: &str) {
settings.status.last_error = Some(error.to_string());
settings.status.last_error_source = Some(source.to_string());
let _ = settings::update_webdav_sync_status(settings.status.clone());
}
fn webdav_not_configured_error() -> String {
AppError::localized(
"webdav.sync.not_configured",
"未配置 WebDAV 同步",
"WebDAV sync is not configured.",
)
.to_string()
}
fn webdav_sync_disabled_error() -> String {
AppError::localized(
"webdav.sync.disabled",
"WebDAV 同步未启用",
"WebDAV sync is disabled.",
)
.to_string()
}
fn require_enabled_webdav_settings() -> Result<WebDavSyncSettings, String> {
let settings = settings::get_webdav_sync_settings().ok_or_else(webdav_not_configured_error)?;
if !settings.enabled {
return Err(webdav_sync_disabled_error());
}
Ok(settings)
}
fn resolve_password_for_request(
mut incoming: WebDavSyncSettings,
existing: Option<WebDavSyncSettings>,
preserve_empty_password: bool,
) -> WebDavSyncSettings {
if let Some(existing_settings) = existing {
if preserve_empty_password && incoming.password.is_empty() {
incoming.password = existing_settings.password;
}
}
incoming
}
#[cfg(test)]
fn webdav_sync_mutex() -> &'static tokio::sync::Mutex<()> {
webdav_sync_service::sync_mutex()
}
async fn run_with_webdav_lock<T, Fut>(operation: Fut) -> Result<T, AppError>
where
Fut: std::future::Future<Output = Result<T, AppError>>,
{
webdav_sync_service::run_with_sync_lock(operation).await
}
fn map_sync_result<T, F>(result: Result<T, AppError>, on_error: F) -> Result<T, String>
where
F: FnOnce(&AppError),
{
match result {
Ok(value) => Ok(value),
Err(err) => {
on_error(&err);
Err(err.to_string())
}
}
}
#[tauri::command]
pub async fn webdav_test_connection(
settings: WebDavSyncSettings,
#[allow(non_snake_case)] preserveEmptyPassword: Option<bool>,
) -> Result<Value, String> {
let preserve_empty = preserveEmptyPassword.unwrap_or(true);
let resolved = resolve_password_for_request(
settings,
settings::get_webdav_sync_settings(),
preserve_empty,
);
webdav_sync_service::check_connection(&resolved)
.await
.map_err(|e| e.to_string())?;
Ok(json!({
"success": true,
"message": "WebDAV connection ok"
}))
}
#[tauri::command]
pub async fn webdav_sync_upload(state: State<'_, AppState>) -> Result<Value, String> {
let db = state.db.clone();
let mut settings = require_enabled_webdav_settings()?;
let result = run_with_webdav_lock(webdav_sync_service::upload(&db, &mut settings)).await;
map_sync_result(result, |error| {
persist_sync_error(&mut settings, error, "manual")
})
}
#[tauri::command]
pub async fn webdav_sync_download(state: State<'_, AppState>) -> Result<Value, String> {
let db = state.db.clone();
let db_for_sync = db.clone();
let mut settings = require_enabled_webdav_settings()?;
let _auto_sync_suppression = crate::services::webdav_auto_sync::AutoSyncSuppressionGuard::new();
let sync_result = run_with_webdav_lock(webdav_sync_service::download(&db, &mut settings)).await;
let mut result = map_sync_result(sync_result, |error| {
persist_sync_error(&mut settings, error, "manual")
})?;
// Post-download sync is best-effort: snapshot restore has already succeeded.
let warning = post_sync_warning_from_result(
tauri::async_runtime::spawn_blocking(move || run_post_import_sync(db_for_sync))
.await
.map_err(|e| e.to_string()),
);
if let Some(msg) = warning.as_ref() {
log::warn!("[WebDAV] post-download sync warning: {msg}");
}
result = attach_warning(result, warning);
Ok(result)
}
#[tauri::command]
pub async fn webdav_sync_save_settings(
settings: WebDavSyncSettings,
#[allow(non_snake_case)] passwordTouched: Option<bool>,
) -> Result<Value, String> {
let password_touched = passwordTouched.unwrap_or(false);
let existing = settings::get_webdav_sync_settings();
let mut sync_settings =
resolve_password_for_request(settings, existing.clone(), !password_touched);
// Preserve server-owned fields that the frontend does not manage
if let Some(existing_settings) = existing {
sync_settings.status = existing_settings.status;
}
sync_settings.normalize();
sync_settings.validate().map_err(|e| e.to_string())?;
settings::set_webdav_sync_settings(Some(sync_settings)).map_err(|e| e.to_string())?;
Ok(json!({ "success": true }))
}
#[tauri::command]
pub async fn webdav_sync_fetch_remote_info() -> Result<Value, String> {
let settings = require_enabled_webdav_settings()?;
let info = webdav_sync_service::fetch_remote_info(&settings)
.await
.map_err(|e| e.to_string())?;
Ok(info.unwrap_or(json!({ "empty": true })))
}
#[cfg(test)]
mod tests {
use super::{
map_sync_result, persist_sync_error, require_enabled_webdav_settings,
resolve_password_for_request, run_with_webdav_lock, webdav_sync_mutex,
};
use crate::error::AppError;
use crate::settings::{AppSettings, WebDavSyncSettings};
use serial_test::serial;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
#[tokio::test]
async fn webdav_sync_mutex_is_singleton() {
let a = webdav_sync_mutex() as *const _;
let b = webdav_sync_mutex() as *const _;
assert_eq!(a, b);
}
#[tokio::test]
#[serial]
async fn webdav_sync_mutex_serializes_concurrent_access() {
let guard = webdav_sync_mutex().lock().await;
let acquired = Arc::new(AtomicBool::new(false));
let acquired_bg = Arc::clone(&acquired);
let waiter = tokio::spawn(async move {
let _inner_guard = webdav_sync_mutex().lock().await;
acquired_bg.store(true, Ordering::SeqCst);
});
tokio::time::sleep(Duration::from_millis(40)).await;
assert!(!acquired.load(Ordering::SeqCst));
drop(guard);
tokio::time::timeout(Duration::from_secs(1), waiter)
.await
.expect("background task should complete after lock release")
.expect("background task should not panic");
assert!(acquired.load(Ordering::SeqCst));
}
#[tokio::test]
#[serial]
async fn map_sync_result_runs_error_handler_after_lock_release() {
let result = run_with_webdav_lock(async {
Err::<(), AppError>(AppError::Config("boom".to_string()))
})
.await;
let mut lock_released = false;
let mapped = map_sync_result(result, |_| {
lock_released = webdav_sync_mutex().try_lock().is_ok();
});
assert!(mapped.is_err());
assert!(lock_released);
}
#[test]
fn resolve_password_for_request_preserves_existing_when_requested() {
let incoming = WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: String::new(),
..WebDavSyncSettings::default()
};
let existing = Some(WebDavSyncSettings {
password: "secret".to_string(),
..WebDavSyncSettings::default()
});
let resolved = resolve_password_for_request(incoming, existing, true);
assert_eq!(resolved.password, "secret");
}
#[test]
fn resolve_password_for_request_allows_explicit_empty_password() {
let incoming = WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: String::new(),
..WebDavSyncSettings::default()
};
let existing = Some(WebDavSyncSettings {
password: "secret".to_string(),
..WebDavSyncSettings::default()
});
let resolved = resolve_password_for_request(incoming, existing, false);
assert!(resolved.password.is_empty());
}
#[test]
#[serial]
fn persist_sync_error_updates_status_without_overwriting_credentials() {
let test_home = std::env::temp_dir().join("cc-switch-sync-error-status-test");
let _ = std::fs::remove_dir_all(&test_home);
std::fs::create_dir_all(&test_home).expect("create test home");
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
crate::settings::update_settings(AppSettings::default()).expect("reset settings");
let mut current = WebDavSyncSettings {
enabled: true,
base_url: "https://dav.example.com/dav/".to_string(),
username: "alice".to_string(),
password: "secret".to_string(),
remote_root: "cc-switch-sync".to_string(),
profile: "default".to_string(),
..WebDavSyncSettings::default()
};
crate::settings::set_webdav_sync_settings(Some(current.clone()))
.expect("seed webdav settings");
persist_sync_error(
&mut current,
&crate::error::AppError::Config("boom".to_string()),
"manual",
);
let after = crate::settings::get_webdav_sync_settings().expect("read webdav settings");
assert_eq!(after.base_url, "https://dav.example.com/dav/");
assert_eq!(after.username, "alice");
assert_eq!(after.password, "secret");
assert_eq!(after.remote_root, "cc-switch-sync");
assert_eq!(after.profile, "default");
assert!(
after
.status
.last_error
.as_deref()
.unwrap_or_default()
.contains("boom"),
"status error should be updated"
);
assert_eq!(after.status.last_error_source.as_deref(), Some("manual"));
}
#[test]
#[serial]
fn require_enabled_webdav_settings_rejects_disabled_config() {
let test_home = std::env::temp_dir().join("cc-switch-sync-enabled-disabled-test");
let _ = std::fs::remove_dir_all(&test_home);
std::fs::create_dir_all(&test_home).expect("create test home");
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
crate::settings::update_settings(AppSettings::default()).expect("reset settings");
crate::settings::set_webdav_sync_settings(Some(WebDavSyncSettings {
enabled: false,
base_url: "https://dav.example.com/dav/".to_string(),
username: "alice".to_string(),
password: "secret".to_string(),
..WebDavSyncSettings::default()
}))
.expect("seed disabled webdav settings");
let err = require_enabled_webdav_settings().expect_err("disabled settings should fail");
assert!(
err.contains("disabled") || err.contains("未启用"),
"unexpected error: {err}"
);
}
#[test]
#[serial]
fn require_enabled_webdav_settings_returns_settings_when_enabled() {
let test_home = std::env::temp_dir().join("cc-switch-sync-enabled-ok-test");
let _ = std::fs::remove_dir_all(&test_home);
std::fs::create_dir_all(&test_home).expect("create test home");
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
crate::settings::update_settings(AppSettings::default()).expect("reset settings");
crate::settings::set_webdav_sync_settings(Some(WebDavSyncSettings {
enabled: true,
base_url: "https://dav.example.com/dav/".to_string(),
username: "alice".to_string(),
password: "secret".to_string(),
..WebDavSyncSettings::default()
}))
.expect("seed enabled webdav settings");
let settings =
require_enabled_webdav_settings().expect("enabled settings should be accepted");
assert!(settings.enabled);
assert_eq!(settings.base_url, "https://dav.example.com/dav/");
}
}
+203
View File
@@ -0,0 +1,203 @@
use regex::Regex;
use std::sync::LazyLock;
use crate::config::write_text_file;
use crate::openclaw_config::get_openclaw_dir;
/// Allowed workspace filenames (whitelist for security)
const ALLOWED_FILES: &[&str] = &[
"AGENTS.md",
"SOUL.md",
"USER.md",
"IDENTITY.md",
"TOOLS.md",
"MEMORY.md",
"HEARTBEAT.md",
"BOOTSTRAP.md",
"BOOT.md",
];
fn validate_filename(filename: &str) -> Result<(), String> {
if !ALLOWED_FILES.contains(&filename) {
return Err(format!(
"Invalid workspace filename: {filename}. Allowed: {}",
ALLOWED_FILES.join(", ")
));
}
Ok(())
}
// --- Daily memory files (memory/YYYY-MM-DD.md) ---
static DAILY_MEMORY_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\d{4}-\d{2}-\d{2}\.md$").unwrap());
fn validate_daily_memory_filename(filename: &str) -> Result<(), String> {
if !DAILY_MEMORY_RE.is_match(filename) {
return Err(format!(
"Invalid daily memory filename: {filename}. Expected: YYYY-MM-DD.md"
));
}
Ok(())
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DailyMemoryFileInfo {
pub filename: String,
pub date: String,
pub size_bytes: u64,
pub modified_at: u64,
pub preview: String,
}
// --- Daily memory commands ---
/// List all daily memory files under `workspace/memory/`.
#[tauri::command]
pub async fn list_daily_memory_files() -> Result<Vec<DailyMemoryFileInfo>, String> {
let memory_dir = get_openclaw_dir().join("workspace").join("memory");
if !memory_dir.exists() {
return Ok(Vec::new());
}
let mut files: Vec<DailyMemoryFileInfo> = Vec::new();
let entries = std::fs::read_dir(&memory_dir)
.map_err(|e| format!("Failed to read memory directory: {e}"))?;
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if !name.ends_with(".md") {
continue;
}
let meta = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if !meta.is_file() {
continue;
}
let date = name.trim_end_matches(".md").to_string();
let size_bytes = meta.len();
let modified_at = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let preview = std::fs::read_to_string(entry.path())
.unwrap_or_default()
.chars()
.take(200)
.collect::<String>();
files.push(DailyMemoryFileInfo {
filename: name,
date,
size_bytes,
modified_at,
preview,
});
}
// Sort by filename descending (newest date first, YYYY-MM-DD.md)
files.sort_by(|a, b| b.filename.cmp(&a.filename));
Ok(files)
}
/// Read a daily memory file.
#[tauri::command]
pub async fn read_daily_memory_file(filename: String) -> Result<Option<String>, String> {
validate_daily_memory_filename(&filename)?;
let path = get_openclaw_dir()
.join("workspace")
.join("memory")
.join(&filename);
if !path.exists() {
return Ok(None);
}
std::fs::read_to_string(&path)
.map(Some)
.map_err(|e| format!("Failed to read daily memory file {filename}: {e}"))
}
/// Write a daily memory file (atomic write).
#[tauri::command]
pub async fn write_daily_memory_file(filename: String, content: String) -> Result<(), String> {
validate_daily_memory_filename(&filename)?;
let memory_dir = get_openclaw_dir().join("workspace").join("memory");
std::fs::create_dir_all(&memory_dir)
.map_err(|e| format!("Failed to create memory directory: {e}"))?;
let path = memory_dir.join(&filename);
write_text_file(&path, &content)
.map_err(|e| format!("Failed to write daily memory file {filename}: {e}"))
}
/// Delete a daily memory file (idempotent).
#[tauri::command]
pub async fn delete_daily_memory_file(filename: String) -> Result<(), String> {
validate_daily_memory_filename(&filename)?;
let path = get_openclaw_dir()
.join("workspace")
.join("memory")
.join(&filename);
if path.exists() {
std::fs::remove_file(&path)
.map_err(|e| format!("Failed to delete daily memory file {filename}: {e}"))?;
}
Ok(())
}
// --- Workspace file commands ---
/// Read an OpenClaw workspace file content.
/// Returns None if the file does not exist.
#[tauri::command]
pub async fn read_workspace_file(filename: String) -> Result<Option<String>, String> {
validate_filename(&filename)?;
let path = get_openclaw_dir().join("workspace").join(&filename);
if !path.exists() {
return Ok(None);
}
std::fs::read_to_string(&path)
.map(Some)
.map_err(|e| format!("Failed to read workspace file {filename}: {e}"))
}
/// Write content to an OpenClaw workspace file (atomic write).
/// Creates the workspace directory if it does not exist.
#[tauri::command]
pub async fn write_workspace_file(filename: String, content: String) -> Result<(), String> {
validate_filename(&filename)?;
let workspace_dir = get_openclaw_dir().join("workspace");
// Ensure workspace directory exists
std::fs::create_dir_all(&workspace_dir)
.map_err(|e| format!("Failed to create workspace directory: {e}"))?;
let path = workspace_dir.join(&filename);
write_text_file(&path, &content)
.map_err(|e| format!("Failed to write workspace file {filename}: {e}"))
}
+48 -4
View File
@@ -6,7 +6,26 @@ use std::path::{Path, PathBuf};
use crate::error::AppError;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
///
/// ## Windows 注意事项
///
/// - `dirs::home_dir()` 在 Windows 上使用 `SHGetKnownFolderPath(FOLDERID_Profile)`
/// 返回的是真实用户目录(类似 `C:\\Users\\Alice`),与 v3.10.2 行为一致。
/// - 不要直接使用 `HOME` 环境变量:它可能由 Git/Cygwin/MSYS 等第三方工具注入,
/// 且不一定等于用户目录,可能导致 `.cc-switch/cc-switch.db` 路径变化,从而“看起来像数据丢失”。
///
/// ## 测试隔离
///
/// 为了让 Windows CI/本地测试能稳定隔离真实用户数据,可通过 `CC_SWITCH_TEST_HOME`
/// 显式覆盖 home dir(仅用于测试/调试场景)。
pub fn get_home_dir() -> PathBuf {
if let Ok(home) = std::env::var("CC_SWITCH_TEST_HOME") {
let trimmed = home.trim();
if !trimmed.is_empty() {
return PathBuf::from(trimmed);
}
}
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
@@ -72,9 +91,34 @@ pub fn get_app_config_dir() -> PathBuf {
return custom;
}
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".cc-switch")
let default_dir = get_home_dir().join(".cc-switch");
// 兼容 v3.10.3:当用户环境存在 `HOME` 且与真实用户目录不同,
// v3.10.3 可能在 `HOME/.cc-switch/` 下创建/使用了数据库。
// 这里仅在“默认位置没有数据库”时回退到旧位置,避免再次出现“供应商消失”问题,
// 同时也避免新安装因为 `HOME` 被设置而写入非预期路径。
#[cfg(windows)]
{
let default_db = default_dir.join("cc-switch.db");
if !default_db.exists() {
if let Ok(home_env) = std::env::var("HOME") {
let trimmed = home_env.trim();
if !trimmed.is_empty() {
let legacy_dir = PathBuf::from(trimmed).join(".cc-switch");
if legacy_dir.join("cc-switch.db").exists() {
log::info!(
"Detected v3.10.3 legacy database at {}, using it instead of {}",
legacy_dir.display(),
default_dir.display()
);
return legacy_dir;
}
}
}
}
}
default_dir
}
/// 获取应用配置文件路径
+215 -6
View File
@@ -2,7 +2,7 @@
//!
//! 提供 SQL 导出/导入和二进制快照备份功能。
use super::{lock_conn, Database, DB_BACKUP_RETAIN};
use super::{lock_conn, Database};
use crate::config::get_app_config_dir;
use crate::error::AppError;
use chrono::Utc;
@@ -15,11 +15,25 @@ use tempfile::NamedTempFile;
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
/// A database backup entry for the UI
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BackupEntry {
pub filename: String,
pub size_bytes: u64,
pub created_at: String, // ISO 8601
}
impl Database {
/// 导出为 SQLite 兼容的 SQL 文本(内存字符串)
pub fn export_sql_string(&self) -> Result<String, AppError> {
let snapshot = self.snapshot_to_memory()?;
Self::dump_sql(&snapshot)
}
/// 导出为 SQLite 兼容的 SQL 文本
pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
let snapshot = self.snapshot_to_memory()?;
let dump = Self::dump_sql(&snapshot)?;
let dump = self.export_sql_string()?;
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
@@ -38,6 +52,12 @@ impl Database {
}
let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?;
let sql_content = sql_raw.trim_start_matches('\u{feff}');
self.import_sql_string(sql_content)
}
/// 从 SQL 字符串导入,返回生成的备份 ID(若无备份则为空字符串)
pub fn import_sql_string(&self, sql_raw: &str) -> Result<String, AppError> {
let sql_content = sql_raw.trim_start_matches('\u{feff}');
Self::validate_cc_switch_sql_export(sql_content)?;
@@ -109,8 +129,47 @@ impl Database {
))
}
/// Periodic backup: create a new backup if the latest one is older than the configured interval
pub(crate) fn periodic_backup_if_needed(&self) -> Result<(), AppError> {
let interval_hours = crate::settings::effective_backup_interval_hours();
if interval_hours == 0 {
return Ok(()); // Auto-backup disabled
}
let backup_dir = get_app_config_dir().join("backups");
if !backup_dir.exists() {
self.backup_database_file()?;
return Ok(());
}
let latest = fs::read_dir(&backup_dir).ok().and_then(|entries| {
entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
.filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()))
.max()
});
let interval_secs = u64::from(interval_hours) * 3600;
let needs_backup = match latest {
None => true,
Some(last_modified) => {
last_modified.elapsed().unwrap_or_default()
> std::time::Duration::from_secs(interval_secs)
}
};
if needs_backup {
log::info!(
"Periodic backup: latest backup is older than {interval_hours} hours, creating new backup"
);
self.backup_database_file()?;
}
Ok(())
}
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
fn backup_database_file(&self) -> Result<Option<PathBuf>, AppError> {
pub(crate) fn backup_database_file(&self) -> Result<Option<PathBuf>, AppError> {
let db_path = get_app_config_dir().join("cc-switch.db");
if !db_path.exists() {
return Ok(None);
@@ -150,6 +209,7 @@ impl Database {
/// 清理旧的数据库备份,保留最新的 N 个
fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> {
let retain = crate::settings::effective_backup_retain_count();
let entries = match fs::read_dir(dir) {
Ok(iter) => iter
.filter_map(|entry| entry.ok())
@@ -164,11 +224,11 @@ impl Database {
Err(_) => return Ok(()),
};
if entries.len() <= DB_BACKUP_RETAIN {
if entries.len() <= retain {
return Ok(());
}
let remove_count = entries.len().saturating_sub(DB_BACKUP_RETAIN);
let remove_count = entries.len().saturating_sub(retain);
let mut sorted = entries;
sorted.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok());
@@ -322,4 +382,153 @@ impl Database {
}
}
}
/// List all database backup files, sorted by creation time (newest first)
pub fn list_backups() -> Result<Vec<BackupEntry>, AppError> {
let backup_dir = get_app_config_dir().join("backups");
if !backup_dir.exists() {
return Ok(vec![]);
}
let mut entries: Vec<BackupEntry> = fs::read_dir(&backup_dir)
.map_err(|e| AppError::io(&backup_dir, e))?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
.filter_map(|e| {
let metadata = e.metadata().ok()?;
let filename = e.file_name().to_string_lossy().to_string();
let size_bytes = metadata.len();
let created_at = metadata
.modified()
.ok()
.map(|t| {
let dt: chrono::DateTime<Utc> = t.into();
dt.to_rfc3339()
})
.unwrap_or_default();
Some(BackupEntry {
filename,
size_bytes,
created_at,
})
})
.collect();
// Sort by created_at descending (newest first)
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(entries)
}
/// Restore database from a backup file. Returns the safety backup ID.
pub fn restore_from_backup(&self, filename: &str) -> Result<String, AppError> {
// Security: validate filename to prevent path traversal
if filename.contains("..")
|| filename.contains('/')
|| filename.contains('\\')
|| !filename.ends_with(".db")
{
return Err(AppError::InvalidInput(
"Invalid backup filename".to_string(),
));
}
let backup_dir = get_app_config_dir().join("backups");
let backup_path = backup_dir.join(filename);
if !backup_path.exists() {
return Err(AppError::InvalidInput(format!(
"Backup file not found: {filename}"
)));
}
// Step 1: Create safety backup of current database
let safety_backup = self.backup_database_file()?;
let safety_id = safety_backup
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
.unwrap_or_default();
// Step 2: Open the backup file and restore it to the main database
let source_conn =
Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?;
{
let mut main_conn = lock_conn!(self.conn);
let backup = Backup::new(&source_conn, &mut main_conn)
.map_err(|e| AppError::Database(e.to_string()))?;
backup
.step(-1)
.map_err(|e| AppError::Database(e.to_string()))?;
}
// Step 3: Run schema migrations (backup may be from an older version)
self.create_tables()?;
self.apply_schema_migrations()?;
self.ensure_model_pricing_seeded()?;
log::info!("Database restored from backup: {filename}, safety backup: {safety_id}");
Ok(safety_id)
}
/// Rename a backup file. Returns the new filename.
pub fn rename_backup(old_filename: &str, new_name: &str) -> Result<String, AppError> {
// Validate old filename (path traversal + .db suffix)
if old_filename.contains("..")
|| old_filename.contains('/')
|| old_filename.contains('\\')
|| !old_filename.ends_with(".db")
{
return Err(AppError::InvalidInput(
"Invalid backup filename".to_string(),
));
}
// Clean new name
let trimmed = new_name.trim();
if trimmed.is_empty() {
return Err(AppError::InvalidInput(
"New name cannot be empty".to_string(),
));
}
// Length limit (without .db suffix)
let name_part = trimmed.strip_suffix(".db").unwrap_or(trimmed);
if name_part.len() > 100 {
return Err(AppError::InvalidInput(
"Name too long (max 100 characters)".to_string(),
));
}
// Prevent path traversal in new name
if name_part.contains("..")
|| name_part.contains('/')
|| name_part.contains('\\')
|| name_part.contains('\0')
{
return Err(AppError::InvalidInput(
"Invalid characters in new name".to_string(),
));
}
let new_filename = format!("{name_part}.db");
let backup_dir = get_app_config_dir().join("backups");
let old_path = backup_dir.join(old_filename);
let new_path = backup_dir.join(&new_filename);
if !old_path.exists() {
return Err(AppError::InvalidInput(format!(
"Backup file not found: {old_filename}"
)));
}
if new_path.exists() {
return Err(AppError::InvalidInput(format!(
"A backup named '{new_filename}' already exists"
)));
}
fs::rename(&old_path, &new_path).map_err(|e| AppError::io(&old_path, e))?;
log::info!("Renamed backup: {old_filename} -> {new_filename}");
Ok(new_filename)
}
}
+2
View File
@@ -4,6 +4,7 @@
pub mod failover;
pub mod mcp;
pub mod omo;
pub mod prompts;
pub mod providers;
pub mod proxy;
@@ -15,3 +16,4 @@ pub mod universal_providers;
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
// 导出 FailoverQueueItem 供外部使用
pub use failover::FailoverQueueItem;
pub use omo::OmoGlobalConfig;
+77
View File
@@ -0,0 +1,77 @@
use crate::database::Database;
use crate::error::AppError;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OmoGlobalConfig {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sisyphus_agent: Option<serde_json::Value>,
#[serde(default)]
pub disabled_agents: Vec<String>,
#[serde(default)]
pub disabled_mcps: Vec<String>,
#[serde(default)]
pub disabled_hooks: Vec<String>,
#[serde(default)]
pub disabled_skills: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lsp: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experimental: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub background_task: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub browser_automation_engine: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub claude_code: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub other_fields: Option<serde_json::Value>,
pub updated_at: String,
}
impl Default for OmoGlobalConfig {
fn default() -> Self {
Self {
id: "global".to_string(),
schema_url: None,
sisyphus_agent: None,
disabled_agents: vec![],
disabled_mcps: vec![],
disabled_hooks: vec![],
disabled_skills: vec![],
lsp: None,
experimental: None,
background_task: None,
browser_automation_engine: None,
claude_code: None,
other_fields: None,
updated_at: chrono::Utc::now().to_rfc3339(),
}
}
}
impl Database {
pub fn get_omo_global_config(&self, key: &str) -> Result<OmoGlobalConfig, AppError> {
let json_str = self.get_setting(key)?;
match json_str {
Some(s) => serde_json::from_str::<OmoGlobalConfig>(&s)
.map_err(|e| AppError::Config(format!("Failed to parse {key}: {e}"))),
None => Ok(OmoGlobalConfig::default()),
}
}
pub fn save_omo_global_config(
&self,
key: &str,
config: &OmoGlobalConfig,
) -> Result<(), AppError> {
let json_str = serde_json::to_string(config)
.map_err(|e| AppError::Config(format!("JSON serialization failed: {e}")))?;
self.set_setting(key, &json_str)?;
Ok(())
}
}
+140 -24
View File
@@ -1,7 +1,3 @@
//! 供应商数据访问对象
//!
//! 提供供应商(Provider)的 CRUD 操作。
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::provider::{Provider, ProviderMeta};
@@ -9,8 +5,18 @@ use indexmap::IndexMap;
use rusqlite::params;
use std::collections::HashMap;
type OmoProviderRow = (
String,
String,
String,
Option<String>,
Option<i64>,
Option<usize>,
Option<String>,
String,
);
impl Database {
/// 获取指定应用类型的所有供应商
pub fn get_all_providers(
&self,
app_type: &str,
@@ -66,7 +72,6 @@ impl Database {
let (id, mut provider) = provider_res.map_err(|e| AppError::Database(e.to_string()))?;
provider.id = id.clone();
// 加载 endpoints
let mut stmt_endpoints = conn.prepare(
"SELECT url, added_at FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 ORDER BY added_at ASC, url ASC"
).map_err(|e| AppError::Database(e.to_string()))?;
@@ -103,7 +108,6 @@ impl Database {
Ok(providers)
}
/// 获取当前激活的供应商 ID
pub fn get_current_provider(&self, app_type: &str) -> Result<Option<String>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
@@ -123,7 +127,6 @@ impl Database {
}
}
/// 根据 ID 获取单个供应商
pub fn get_provider_by_id(
&self,
id: &str,
@@ -174,21 +177,15 @@ impl Database {
}
}
/// 保存供应商(新增或更新)
///
/// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理
/// add_custom_endpoint / remove_custom_endpoint),避免覆盖用户的修改。
pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
// 处理 meta:取出 endpoints 以便单独处理
let mut meta_clone = provider.meta.clone().unwrap_or_default();
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
// 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue
let existing: Option<(bool, bool)> = tx
.query_row(
"SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
@@ -202,7 +199,6 @@ impl Database {
existing.unwrap_or((false, provider.in_failover_queue));
if is_update {
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
tx.execute(
"UPDATE providers SET
name = ?1,
@@ -241,7 +237,6 @@ impl Database {
)
.map_err(|e| AppError::Database(e.to_string()))?;
} else {
// 新增模式:使用 INSERT
tx.execute(
"INSERT INTO providers (
id, app_type, name, settings_config, website_url, category,
@@ -268,7 +263,6 @@ impl Database {
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 只有新增时才同步 endpoints
for (url, endpoint) in endpoints {
tx.execute(
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)
@@ -283,7 +277,6 @@ impl Database {
Ok(())
}
/// 删除供应商
pub fn delete_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
@@ -294,21 +287,18 @@ impl Database {
Ok(())
}
/// 设置当前供应商
pub fn set_current_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
// 重置所有为 0
tx.execute(
"UPDATE providers SET is_current = 0 WHERE app_type = ?1",
params![app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 设置新的当前供应商
tx.execute(
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2",
params![id, app_type],
@@ -319,7 +309,6 @@ impl Database {
Ok(())
}
/// 更新供应商的 settings_config(仅更新配置,不改变其他字段)
pub fn update_provider_settings_config(
&self,
app_type: &str,
@@ -341,7 +330,6 @@ impl Database {
Ok(())
}
/// 添加自定义端点
pub fn add_custom_endpoint(
&self,
app_type: &str,
@@ -357,7 +345,6 @@ impl Database {
Ok(())
}
/// 移除自定义端点
pub fn remove_custom_endpoint(
&self,
app_type: &str,
@@ -372,4 +359,133 @@ impl Database {
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
pub fn set_omo_provider_current(
&self,
app_type: &str,
provider_id: &str,
category: &str,
) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
tx.execute(
"UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = ?2",
params![app_type, category],
)
.map_err(|e| AppError::Database(e.to_string()))?;
let updated = tx
.execute(
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2 AND category = ?3",
params![provider_id, app_type, category],
)
.map_err(|e| AppError::Database(e.to_string()))?;
if updated != 1 {
return Err(AppError::Database(format!(
"Failed to set {category} provider current: provider '{provider_id}' not found in app '{app_type}'"
)));
}
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
pub fn is_omo_provider_current(
&self,
app_type: &str,
provider_id: &str,
category: &str,
) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
match conn.query_row(
"SELECT is_current FROM providers
WHERE id = ?1 AND app_type = ?2 AND category = ?3",
params![provider_id, app_type, category],
|row| row.get(0),
) {
Ok(is_current) => Ok(is_current),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
Err(e) => Err(AppError::Database(e.to_string())),
}
}
pub fn clear_omo_provider_current(
&self,
app_type: &str,
provider_id: &str,
category: &str,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE providers SET is_current = 0
WHERE id = ?1 AND app_type = ?2 AND category = ?3",
params![provider_id, app_type, category],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
pub fn get_current_omo_provider(
&self,
app_type: &str,
category: &str,
) -> Result<Option<Provider>, AppError> {
let conn = lock_conn!(self.conn);
let row_data: Result<OmoProviderRow, rusqlite::Error> = conn.query_row(
"SELECT id, name, settings_config, category, created_at, sort_index, notes, meta
FROM providers
WHERE app_type = ?1 AND category = ?2 AND is_current = 1
LIMIT 1",
params![app_type, category],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
row.get(6)?,
row.get(7)?,
))
},
);
let (id, name, settings_config_str, _row_category, created_at, sort_index, notes, meta_str) =
match row_data {
Ok(v) => v,
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
Err(e) => return Err(AppError::Database(e.to_string())),
};
let settings_config = serde_json::from_str(&settings_config_str).map_err(|e| {
AppError::Database(format!(
"Failed to parse {category} provider settings_config (provider_id={id}): {e}"
))
})?;
let meta: crate::provider::ProviderMeta = if meta_str.trim().is_empty() {
crate::provider::ProviderMeta::default()
} else {
serde_json::from_str(&meta_str).map_err(|e| {
AppError::Database(format!(
"Failed to parse {category} provider meta (provider_id={id}): {e}"
))
})?
};
Ok(Some(Provider {
id,
name,
settings_config,
website_url: None,
category: Some(category.to_string()),
created_at,
sort_index,
notes,
meta: Some(meta),
icon: None,
icon_color: None,
in_failover_queue: false,
}))
}
}
+259 -7
View File
@@ -4,6 +4,7 @@
use crate::error::AppError;
use crate::proxy::types::*;
use rust_decimal::Decimal;
use super::super::{lock_conn, Database};
@@ -75,6 +76,117 @@ impl Database {
Ok(())
}
/// 获取默认成本倍率
pub async fn get_default_cost_multiplier(&self, app_type: &str) -> Result<String, AppError> {
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT default_cost_multiplier FROM proxy_config WHERE app_type = ?1",
[app_type],
|row| row.get(0),
)
};
match result {
Ok(value) => Ok(value),
Err(rusqlite::Error::QueryReturnedNoRows) => {
self.init_proxy_config_rows().await?;
Ok("1".to_string())
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 设置默认成本倍率
pub async fn set_default_cost_multiplier(
&self,
app_type: &str,
value: &str,
) -> Result<(), AppError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(AppError::localized(
"error.multiplierEmpty",
"倍率不能为空",
"Multiplier cannot be empty",
));
}
trimmed.parse::<Decimal>().map_err(|e| {
AppError::localized(
"error.invalidMultiplier",
format!("无效倍率: {value} - {e}"),
format!("Invalid multiplier: {value} - {e}"),
)
})?;
// 确保行存在
self.ensure_proxy_config_row_exists(app_type)?;
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE proxy_config SET
default_cost_multiplier = ?2,
updated_at = datetime('now')
WHERE app_type = ?1",
rusqlite::params![app_type, trimmed],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 获取计费模式来源
pub async fn get_pricing_model_source(&self, app_type: &str) -> Result<String, AppError> {
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT pricing_model_source FROM proxy_config WHERE app_type = ?1",
[app_type],
|row| row.get(0),
)
};
match result {
Ok(value) => Ok(value),
Err(rusqlite::Error::QueryReturnedNoRows) => {
self.init_proxy_config_rows().await?;
Ok("response".to_string())
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 设置计费模式来源
pub async fn set_pricing_model_source(
&self,
app_type: &str,
value: &str,
) -> Result<(), AppError> {
let trimmed = value.trim();
if !matches!(trimmed, "response" | "request") {
return Err(AppError::localized(
"error.invalidPricingMode",
format!("无效计费模式: {value}"),
format!("Invalid pricing mode: {value}"),
));
}
// 确保行存在
self.ensure_proxy_config_row_exists(app_type)?;
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE proxy_config SET
pricing_model_source = ?2,
updated_at = datetime('now')
WHERE app_type = ?1",
rusqlite::params![app_type, trimmed],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 获取应用级代理配置
pub async fn get_proxy_config_for_app(
&self,
@@ -177,17 +289,90 @@ impl Database {
Ok(())
}
/// 确保指定 app_type 的 proxy_config 行存在(同步版本,用于 set_* 函数)
///
/// 使用与 schema.rs seed 相同的 per-app 默认值
fn ensure_proxy_config_row_exists(&self, app_type: &str) -> Result<(), AppError> {
let conn = self
.conn
.lock()
.map_err(|e| AppError::Lock(e.to_string()))?;
// 根据 app_type 使用不同的默认值(与 schema.rs seed 保持一致)
let (retries, fb_timeout, idle_timeout, cb_fail, cb_succ, cb_timeout, cb_rate, cb_min) =
match app_type {
"claude" => (6, 90, 180, 8, 3, 90, 0.7, 15),
"codex" => (3, 60, 120, 4, 2, 60, 0.6, 10),
"gemini" => (5, 60, 120, 4, 2, 60, 0.6, 10),
_ => (3, 60, 120, 4, 2, 60, 0.6, 10), // 默认值
};
conn.execute(
"INSERT OR IGNORE INTO proxy_config (
app_type, 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
) VALUES (?1, ?2, ?3, ?4, 600, ?5, ?6, ?7, ?8, ?9)",
rusqlite::params![
app_type,
retries,
fb_timeout,
idle_timeout,
cb_fail,
cb_succ,
cb_timeout,
cb_rate,
cb_min
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 初始化 proxy_config 表的三行数据
///
/// 使用与 schema.rs seed 相同的 per-app 默认值
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()))?;
}
// 使用与 schema.rs seed 相同的 per-app 默认值
// claude: 更激进的重试和超时配置
conn.execute(
"INSERT OR IGNORE INTO proxy_config (
app_type, 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
) VALUES ('claude', 6, 90, 180, 600, 8, 3, 90, 0.7, 15)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// codex: 默认配置
conn.execute(
"INSERT OR IGNORE INTO proxy_config (
app_type, 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
) VALUES ('codex', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// gemini: 稍高的重试次数
conn.execute(
"INSERT OR IGNORE INTO proxy_config (
app_type, 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
) VALUES ('gemini', 5, 60, 120, 600, 4, 2, 60, 0.6, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
@@ -662,3 +847,70 @@ impl Database {
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::database::Database;
use crate::error::AppError;
#[tokio::test]
async fn test_default_cost_multiplier_round_trip() -> Result<(), AppError> {
let db = Database::memory()?;
let default = db.get_default_cost_multiplier("claude").await?;
assert_eq!(default, "1");
db.set_default_cost_multiplier("claude", "1.5").await?;
let updated = db.get_default_cost_multiplier("claude").await?;
assert_eq!(updated, "1.5");
Ok(())
}
#[tokio::test]
async fn test_default_cost_multiplier_validation() -> Result<(), AppError> {
let db = Database::memory()?;
let err = db
.set_default_cost_multiplier("claude", "not-a-number")
.await
.unwrap_err();
// AppError::localized returns AppError::Localized variant
assert!(matches!(
err,
AppError::Localized {
key: "error.invalidMultiplier",
..
}
));
Ok(())
}
#[tokio::test]
async fn test_pricing_model_source_round_trip_and_validation() -> Result<(), AppError> {
let db = Database::memory()?;
let default = db.get_pricing_model_source("claude").await?;
assert_eq!(default, "response");
db.set_pricing_model_source("claude", "request").await?;
let updated = db.get_pricing_model_source("claude").await?;
assert_eq!(updated, "request");
let err = db
.set_pricing_model_source("claude", "invalid")
.await
.unwrap_err();
// AppError::localized returns AppError::Localized variant
assert!(matches!(
err,
AppError::Localized {
key: "error.invalidPricingMode",
..
}
));
Ok(())
}
}
+1 -1
View File
@@ -168,7 +168,7 @@ impl Database {
/// 获取整流器配置
///
/// 返回整流器配置,如果不存在则返回默认值(全部启
/// 返回整流器配置,如果不存在则返回默认值(全部启)
pub fn get_rectifier_config(&self) -> Result<crate::proxy::types::RectifierConfig, AppError> {
match self.get_setting("rectifier_config")? {
Some(json) => serde_json::from_str(&json)
-33
View File
@@ -58,9 +58,6 @@ impl Database {
// 4. 迁移 Skills
Self::migrate_skills(tx, config)?;
// 5. 迁移 Common Config
Self::migrate_common_config(tx, config)?;
Ok(())
}
@@ -212,34 +209,4 @@ impl Database {
Ok(())
}
/// 迁移通用配置片段
fn migrate_common_config(
tx: &rusqlite::Transaction<'_>,
config: &MultiAppConfig,
) -> Result<(), AppError> {
if let Some(snippet) = &config.common_config_snippets.claude {
tx.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params!["common_config_claude", snippet],
)
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
}
if let Some(snippet) = &config.common_config_snippets.codex {
tx.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params!["common_config_codex", snippet],
)
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
}
if let Some(snippet) = &config.common_config_snippets.gemini {
tx.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params!["common_config_gemini", snippet],
)
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
}
Ok(())
}
}
+33 -6
View File
@@ -23,7 +23,7 @@
//! └── settings.rs
//! ```
mod backup;
pub(crate) mod backup;
mod dao;
mod migration;
mod schema;
@@ -33,21 +33,19 @@ mod tests;
// DAO 类型导出供外部使用
pub use dao::FailoverQueueItem;
pub use dao::OmoGlobalConfig;
use crate::config::get_app_config_dir;
use crate::error::AppError;
use rusqlite::Connection;
use rusqlite::{hooks::Action, Connection};
use serde::Serialize;
use std::sync::Mutex;
// DAO 方法通过 impl Database 提供,无需额外导出
/// 数据库备份保留数量
const DB_BACKUP_RETAIN: usize = 10;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 4;
pub(crate) const SCHEMA_VERSION: i32 = 5;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
@@ -75,6 +73,17 @@ pub struct Database {
pub(crate) conn: Mutex<Connection>,
}
fn register_db_change_hook(conn: &Connection) {
conn.update_hook(Some(
|action: Action, _database: &str, table: &str, _row_id: i64| match action {
Action::SQLITE_INSERT | Action::SQLITE_UPDATE | Action::SQLITE_DELETE => {
crate::services::webdav_auto_sync::notify_db_changed(table);
}
_ => {}
},
));
}
impl Database {
/// 初始化数据库连接并创建表
///
@@ -92,11 +101,28 @@ impl Database {
// 启用外键约束
conn.execute("PRAGMA foreign_keys = ON;", [])
.map_err(|e| AppError::Database(e.to_string()))?;
register_db_change_hook(&conn);
let db = Self {
conn: Mutex::new(conn),
};
db.create_tables()?;
// Pre-migration backup: only when upgrading from an existing database
{
let conn = lock_conn!(db.conn);
let version = Self::get_user_version(&conn)?;
drop(conn);
if version > 0 && version < SCHEMA_VERSION {
log::info!(
"Creating pre-migration database backup (v{version} → v{SCHEMA_VERSION})"
);
if let Err(e) = db.backup_database_file() {
log::warn!("Pre-migration backup failed, continuing migration: {e}");
}
}
}
db.apply_schema_migrations()?;
db.ensure_model_pricing_seeded()?;
@@ -110,6 +136,7 @@ impl Database {
// 启用外键约束
conn.execute("PRAGMA foreign_keys = ON;", [])
.map_err(|e| AppError::Database(e.to_string()))?;
register_db_change_hook(&conn);
let db = Self {
conn: Mutex::new(conn),
+82 -10
View File
@@ -120,6 +120,8 @@ impl Database {
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
pricing_model_source TEXT NOT NULL DEFAULT 'response',
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", []).map_err(|e| AppError::Database(e.to_string()))?;
@@ -170,6 +172,7 @@ impl Database {
// 10. Proxy Request Logs 表
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
request_model TEXT,
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
@@ -352,6 +355,11 @@ impl Database {
Self::migrate_v3_to_v4(conn)?;
Self::set_user_version(conn, 4)?;
}
4 => {
log::info!("迁移数据库从 v4 到 v5(计费模式支持)");
Self::migrate_v4_to_v5(conn)?;
Self::set_user_version(conn, 5)?;
}
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -521,6 +529,7 @@ impl Database {
// proxy_request_logs 表
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
request_model TEXT,
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
@@ -677,6 +686,8 @@ impl Database {
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
pricing_model_source TEXT NOT NULL DEFAULT 'response',
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", [])?;
@@ -879,12 +890,45 @@ impl Database {
Ok(())
}
/// v4 -> v5 迁移:新增计费模式配置与请求模型字段
fn migrate_v4_to_v5(conn: &Connection) -> Result<(), AppError> {
if Self::table_exists(conn, "proxy_config")? {
Self::add_column_if_missing(
conn,
"proxy_config",
"default_cost_multiplier",
"TEXT NOT NULL DEFAULT '1'",
)?;
Self::add_column_if_missing(
conn,
"proxy_config",
"pricing_model_source",
"TEXT NOT NULL DEFAULT 'response'",
)?;
}
if Self::table_exists(conn, "proxy_request_logs")? {
Self::add_column_if_missing(conn, "proxy_request_logs", "request_model", "TEXT")?;
}
log::info!("v4 -> v5 迁移完成:已添加计费模式与请求模型字段");
Ok(())
}
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
let pricing_data = [
// Claude 4.5 系列 (Latest Models)
// Claude 4.6 系列
(
"claude-opus-4-6-20260206",
"Claude Opus 4.6",
"5",
"25",
"0.50",
"6.25",
),
// Claude 4.5 系列
(
"claude-opus-4-5-20251101",
"Claude Opus 4.5",
@@ -990,6 +1034,40 @@ impl Database {
"0.175",
"0",
),
// GPT-5.3 Codex 系列
("gpt-5.3-codex", "GPT-5.3 Codex", "1.75", "14", "0.175", "0"),
(
"gpt-5.3-codex-low",
"GPT-5.3 Codex",
"1.75",
"14",
"0.175",
"0",
),
(
"gpt-5.3-codex-medium",
"GPT-5.3 Codex",
"1.75",
"14",
"0.175",
"0",
),
(
"gpt-5.3-codex-high",
"GPT-5.3 Codex",
"1.75",
"14",
"0.175",
"0",
),
(
"gpt-5.3-codex-xhigh",
"GPT-5.3 Codex",
"1.75",
"14",
"0.175",
"0",
),
// GPT-5.1 系列
("gpt-5.1", "GPT-5.1", "1.25", "10", "0.125", "0"),
("gpt-5.1-low", "GPT-5.1", "1.25", "10", "0.125", "0"),
@@ -1177,7 +1255,7 @@ impl Database {
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
conn.execute(
"INSERT OR REPLACE INTO model_pricing (
"INSERT OR IGNORE INTO model_pricing (
model_id, display_name, input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
@@ -1204,14 +1282,8 @@ impl Database {
}
fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> {
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))
.map_err(|e| AppError::Database(format!("统计模型定价数据失败: {e}")))?;
if count == 0 {
Self::seed_model_pricing(conn)?;
}
Ok(())
// 每次启动都执行 INSERT OR IGNORE,增量追加新模型,已有数据不覆盖
Self::seed_model_pricing(conn)
}
// --- 辅助方法 ---
+76 -11
View File
@@ -151,7 +151,7 @@ fn normalize_default(default: &Option<String>) -> Option<String> {
}
#[test]
fn migration_sets_user_version_when_missing() {
fn schema_migration_sets_user_version_when_missing() {
let conn = Connection::open_in_memory().expect("open memory db");
Database::create_tables_on_conn(&conn).expect("create tables");
@@ -169,7 +169,7 @@ fn migration_sets_user_version_when_missing() {
}
#[test]
fn migration_rejects_future_version() {
fn schema_migration_rejects_future_version() {
let conn = Connection::open_in_memory().expect("open memory db");
Database::create_tables_on_conn(&conn).expect("create tables");
Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version");
@@ -183,7 +183,7 @@ fn migration_rejects_future_version() {
}
#[test]
fn migration_adds_missing_columns_for_providers() {
fn schema_migration_adds_missing_columns_for_providers() {
let conn = Connection::open_in_memory().expect("open memory db");
// 创建旧版 providers 表,缺少新增列
@@ -224,7 +224,7 @@ fn migration_adds_missing_columns_for_providers() {
}
#[test]
fn migration_aligns_column_defaults_and_types() {
fn schema_migration_aligns_column_defaults_and_types() {
let conn = Connection::open_in_memory().expect("open memory db");
conn.execute_batch(LEGACY_SCHEMA_SQL)
.expect("seed old schema");
@@ -268,7 +268,76 @@ fn migration_aligns_column_defaults_and_types() {
}
#[test]
fn create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
fn schema_create_tables_include_pricing_model_columns() {
let conn = Connection::open_in_memory().expect("open memory db");
Database::create_tables_on_conn(&conn).expect("create tables");
let multiplier = get_column_info(&conn, "proxy_config", "default_cost_multiplier");
assert_eq!(multiplier.r#type, "TEXT");
assert_eq!(multiplier.notnull, 1);
assert_eq!(normalize_default(&multiplier.default).as_deref(), Some("1"));
let pricing_source = get_column_info(&conn, "proxy_config", "pricing_model_source");
assert_eq!(pricing_source.r#type, "TEXT");
assert_eq!(pricing_source.notnull, 1);
assert_eq!(
normalize_default(&pricing_source.default).as_deref(),
Some("response")
);
let request_model = get_column_info(&conn, "proxy_request_logs", "request_model");
assert_eq!(request_model.r#type, "TEXT");
assert_eq!(request_model.notnull, 0);
}
#[test]
fn schema_migration_v4_adds_pricing_model_columns() {
let conn = Connection::open_in_memory().expect("open memory db");
conn.execute_batch(
r#"
CREATE TABLE proxy_config (app_type TEXT PRIMARY KEY);
CREATE TABLE proxy_request_logs (request_id TEXT PRIMARY KEY, model TEXT NOT NULL);
CREATE TABLE mcp_servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
server_config TEXT NOT NULL,
enabled_claude INTEGER NOT NULL DEFAULT 0,
enabled_codex INTEGER NOT NULL DEFAULT 0,
enabled_gemini INTEGER NOT NULL DEFAULT 0,
enabled_opencode INTEGER NOT NULL DEFAULT 0
);
"#,
)
.expect("seed v4 schema");
Database::set_user_version(&conn, 4).expect("set user_version=4");
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
let multiplier = get_column_info(&conn, "proxy_config", "default_cost_multiplier");
assert_eq!(multiplier.r#type, "TEXT");
assert_eq!(multiplier.notnull, 1);
assert_eq!(normalize_default(&multiplier.default).as_deref(), Some("1"));
let pricing_source = get_column_info(&conn, "proxy_config", "pricing_model_source");
assert_eq!(pricing_source.r#type, "TEXT");
assert_eq!(pricing_source.notnull, 1);
assert_eq!(
normalize_default(&pricing_source.default).as_deref(),
Some("response")
);
let request_model = get_column_info(&conn, "proxy_request_logs", "request_model");
assert_eq!(request_model.r#type, "TEXT");
assert_eq!(request_model.notnull, 0);
assert_eq!(
Database::get_user_version(&conn).expect("version after migration"),
SCHEMA_VERSION
);
}
#[test]
fn schema_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
@@ -433,7 +502,7 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
}
#[test]
fn dry_run_does_not_write_to_disk() {
fn schema_dry_run_does_not_write_to_disk() {
// Create minimal valid config for migration
let mut apps = HashMap::new();
apps.insert("claude".to_string(), ProviderManager::default());
@@ -444,8 +513,6 @@ fn dry_run_does_not_write_to_disk() {
mcp: Default::default(),
prompts: Default::default(),
skills: Default::default(),
common_config_snippets: Default::default(),
claude_common_config_snippet: None,
};
// Dry-run should succeed without any file I/O errors
@@ -494,8 +561,6 @@ fn dry_run_validates_schema_compatibility() {
mcp: Default::default(),
prompts: Default::default(),
skills: Default::default(),
common_config_snippets: Default::default(),
claude_common_config_snippet: None,
};
// Dry-run should validate the full migration path
@@ -507,7 +572,7 @@ fn dry_run_validates_schema_compatibility() {
}
#[test]
fn model_pricing_is_seeded_on_init() {
fn schema_model_pricing_is_seeded_on_init() {
let db = Database::memory().expect("create memory db");
let conn = db.conn.lock().expect("lock conn");
+4
View File
@@ -175,6 +175,10 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
"codex" => apps.codex = true,
"gemini" => apps.gemini = true,
"opencode" => apps.opencode = true,
"openclaw" => {
// OpenClaw doesn't support MCP, ignore silently
log::debug!("OpenClaw doesn't support MCP, ignoring in apps parameter");
}
other => {
return Err(AppError::InvalidInput(format!(
"Invalid app in 'apps': {other}"
+75
View File
@@ -146,6 +146,7 @@ pub(crate) fn build_provider_from_request(
AppType::Codex => build_codex_settings(request),
AppType::Gemini => build_gemini_settings(request),
AppType::OpenCode => build_opencode_settings(request),
AppType::OpenClaw => build_openclaw_settings(request),
};
// Build usage script configuration if provided
@@ -391,6 +392,35 @@ fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value
})
}
fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
let endpoint = get_primary_endpoint(request);
// Build OpenClaw provider config
// Format: { baseUrl, apiKey, api, models }
let mut config = serde_json::Map::new();
if !endpoint.is_empty() {
config.insert("baseUrl".to_string(), json!(endpoint));
}
if let Some(api_key) = &request.api_key {
config.insert("apiKey".to_string(), json!(api_key));
}
// Default to OpenAI-compatible API
config.insert("api".to_string(), json!("openai-completions"));
// Build models array
if let Some(model) = &request.model {
config.insert(
"models".to_string(),
json!([{ "id": model, "name": model }]),
);
}
json!(config)
}
// =============================================================================
// Config Merge Logic
// =============================================================================
@@ -452,6 +482,10 @@ pub fn parse_and_merge_config(
"claude" => merge_claude_config(&mut merged, &config_value)?,
"codex" => merge_codex_config(&mut merged, &config_value)?,
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
// Additive mode apps use JSON config directly; pass through as-is
"openclaw" | "opencode" => {
merge_additive_config(&mut merged, &config_value)?;
}
"" => {
// No app specified, skip merging
return Ok(merged);
@@ -623,6 +657,47 @@ fn merge_gemini_config(
Ok(())
}
/// Merge configuration for additive mode apps (OpenClaw, OpenCode)
///
/// These apps use JSON config directly, so we only extract common fields
/// (api_key, endpoint, model) from the config if not already set in URL params.
fn merge_additive_config(
request: &mut DeepLinkImportRequest,
config: &serde_json::Value,
) -> Result<(), AppError> {
// Extract api_key from config if not provided in URL
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(api_key) = config
.get("apiKey")
.or_else(|| config.get("api_key"))
.and_then(|v| v.as_str())
{
request.api_key = Some(api_key.to_string());
}
}
// Extract endpoint from config if not provided in URL
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(base_url) = config
.get("baseUrl")
.or_else(|| config.get("base_url"))
.or_else(|| config.get("options").and_then(|o| o.get("baseURL")))
.and_then(|v| v.as_str())
{
request.endpoint = Some(base_url.to_string());
}
}
// Auto-fill homepage from endpoint
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
request.homepage = infer_homepage_from_endpoint(endpoint);
}
}
Ok(())
}
/// Extract base_url from Codex TOML config
fn extract_codex_base_url(toml_value: &toml::Value) -> Option<String> {
// Try to find base_url in model_providers section
+2
View File
@@ -52,6 +52,8 @@ pub enum AppError {
},
#[error("数据库错误: {0}")]
Database(String),
#[error("OMO 配置文件不存在")]
OmoConfigNotFound,
#[error("所有供应商已熔断,无可用渠道")]
AllProvidersCircuitOpen,
#[error("未配置供应商")]
+1 -9
View File
@@ -1,18 +1,10 @@
use crate::config::write_text_file;
use crate::config::{get_home_dir, write_text_file};
use crate::error::AppError;
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
/// 获取用户主目录,带回退和日志
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| {
log::warn!("无法获取用户主目录,回退到当前目录");
PathBuf::from(".")
})
}
/// 获取 Gemini 配置目录路径(支持设置覆盖)
pub fn get_gemini_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_gemini_override_dir() {

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