Compare commits

..

177 Commits

Author SHA1 Message Date
Jason ac23328119 chore: bump version to v3.11.0 and add release notes
- Update version numbers in package.json, Cargo.toml, tauri.conf.json
- Add CHANGELOG.md entry for v3.11.0
- Add trilingual release notes (zh/en/ja)
- Update user manual version info
2026-02-26 23:13:59 +08:00
Jason 0c4a8d0569 fix: use local time instead of UTC for backup file names 2026-02-26 22:14:37 +08:00
Jason 01cc766a05 feat: add delete backup functionality with confirmation dialog 2026-02-26 22:06:10 +08:00
Jason 3590df68b8 fix: treat missing db file as error in manual backup to prevent false success toast
backup_database_file() returning Ok(None) was silently resolved as null
on the frontend, bypassing try/catch and showing a success toast without
actually creating a backup file. Now None is converted to an explicit
Err so the frontend correctly displays an error toast.
2026-02-26 21:53:59 +08:00
Jason 54876612b3 feat: show silent startup option only when launch on startup is enabled
Add conditional rendering with animated transition for the silent startup
toggle, so it only appears when the launch on startup option is checked.
2026-02-26 21:25:14 +08:00
Jason 3348b39089 fix: restore API Key input visibility when creating new Claude providers
The hasApiKeyField() gate added for Bedrock AKSK/API Key distinction
was incorrectly hiding the API Key input for all new providers with
empty env config. Scope the gate to cloud_provider category only.
2026-02-26 21:19:49 +08:00
Jason 99e392518a fix: remove outdated partner status from Zhipu GLM presets 2026-02-26 21:04:53 +08:00
Jason 201527c9d9 fix: merge duplicate openclaw i18n keys to restore provider form translations
JSON does not support duplicate keys — the second `openclaw` object was
silently overwriting the first, causing all provider form field translations
(providerKey, apiProtocol, baseUrl, models, etc.) to be lost at runtime.
2026-02-26 20:43:39 +08:00
Jason 924f38ebe1 fix: remove last-provider deletion restriction for OMO/OMO Slim plugins
OMO and OMO Slim are OpenCode plugins, not standalone apps — users
should be able to fully remove them. Remove the count-based guard that
prevented deleting the last active provider, and clean up the now-unused
provider-count API surface across the full stack.
2026-02-26 20:27:04 +08:00
Jason e7766d4d22 refactor: remove OMO common config two-layer merge system
Each OMO provider now stores its complete configuration directly in
settings_config.otherFields instead of relying on a shared OmoGlobalConfig
merged at write time. This simplifies the data flow from a 4-tuple
(agents, categories, otherFields, useCommonConfig) to a 3-tuple and
eliminates an entire DB table, two Tauri commands, and ~1700 lines of
merge/sync code across frontend and backend.

Backend:
- Delete database/dao/omo.rs (OmoGlobalConfig struct + get/save methods)
- Remove get/set_config_snippet from settings DAO
- Remove get/set_common_config_snippet Tauri commands
- Replace merge_config() with build_config() in services/omo.rs
- Simplify OmoVariant (remove config_key, known_keys)
- Simplify import_from_local and build_local_file_data
- Rewrite all OMO service tests

Frontend:
- Delete OmoCommonConfigEditor.tsx and OmoGlobalConfigFields.tsx
- Delete src/lib/api/config.ts
- Remove OmoGlobalConfig type and merge preview functions
- Remove useGlobalConfig/useSaveGlobalConfig query hooks
- Simplify useOmoDraftState (remove all common config state)
- Replace OmoCommonConfigEditor with read-only JsonEditor preview
- Clean i18n keys (zh/en/ja)
2026-02-26 19:31:43 +08:00
Jason 3e9815f5d2 chore: add .worktrees/ to .gitignore 2026-02-26 19:15:54 +08:00
Jason 082cb0327d fix: enforce OMO ↔ OMO Slim cross-category mutual exclusion
When activating an OMO provider, deactivate all OMO Slim providers
in the same transaction and delete the Slim config file, and vice
versa. This prevents both plugin variants from being active
simultaneously.
2026-02-26 18:08:46 +08:00
Jason 55e21c3c19 fix: invalidate OMO Slim query cache after provider mutations
OMO Slim queries (["omo-slim", ...]) were not invalidated alongside
OMO queries, causing stale UI state when switching/adding/deleting
OMO Slim providers.
2026-02-26 17:51:50 +08:00
Jason 3e2c8c12a5 fix: sync OMO agent/category recommended models with upstream sources
Regular agents & categories: align with oh-my-opencode model-requirements.ts
fallback chains (oracle→gpt-5.2, librarian→gemini-3-flash, etc.)

Slim agents: derive from Regular's design philosophy — match each agent
to its functional counterpart's first-choice model instead of using
the outdated Slim defaults. Also fix provider/model format to pure
model IDs for suffix matching compatibility.
2026-02-26 17:39:08 +08:00
Jason 90cb8db16b fix: add toast feedback for OMO "Fill Recommended" button silent failures 2026-02-26 16:02:18 +08:00
Jason 2b30819510 chore: pre-release cleanup — remove debug logs, fix clippy warning, add missing ja translations, and format code
- Remove 2 console.log statements from DeepLinkImportDialog
- Fix clippy unnecessary_map_or: use is_some_and in live.rs
- Add 17 missing Japanese i18n keys (skills, proxy, circuitBreaker, universalProvider)
- Run prettier and cargo fmt to fix pre-existing formatting drift
2026-02-26 15:11:13 +08:00
Jason 434392a669 perf: optimize session panel loading with parallel scan and head-tail JSONL reading
- Parallelize 5 provider scans using std::thread::scope
- Add read_head_tail_lines() to read only first 10 + last 30 lines from JSONL files, skipping potentially large middle sections
- Cache Codex UUID regex with LazyLock to avoid repeated compilation
- Skip expensive I/O in OpenCode when session title is already available
2026-02-26 11:16:04 +08:00
Jason 24fa8a18ef fix: show context-aware JSON editor hint and hide quick toggles for non-active providers
The hint text and ClaudeQuickToggles were misleading when editing
non-current providers or creating new ones, since the editor only
contains a config snippet rather than the full live settings.json.
2026-02-26 09:55:09 +08:00
Jason 87604b1809 fix: hide ClaudeQuickToggles when creating a new provider
The quick toggles (hide AI attribution, extended thinking, teammates
mode) patch the live config of the currently active provider, which
is incorrect during provider creation. Only show them in edit mode.
2026-02-26 09:32:51 +08:00
Jason 47435bd647 chore: update Claude model references from 4.5 to 4.6 in provider presets
Update Sonnet and Opus model IDs/names to 4.6 across Claude, OpenClaw,
and OpenCode provider preset configurations. OPENCODE_PRESET_MODEL_VARIANTS
(SDK model catalog) is intentionally left unchanged.
2026-02-25 23:25:41 +08:00
Jason 73ca8340bb feat: add SSAI Code partner provider presets, i18n, icon, and fix models
Add SSAI Code as a partner provider across all five apps with endpoint,
API key URL, and partner promotion config. Rename brand from SSSAiCode
to SSAI Code. Rename sssaicoding.svg to sssaicode.svg and register icon.
Add trilingual promotion text for $10 bonus credit. Add missing models
arrays in OpenClaw presets for CrazyRouter and SSAI Code.
2026-02-25 22:21:15 +08:00
Jason 31712f076f feat: add CrazyRouter partner provider presets, i18n, and register icons
Add CrazyRouter as a partner provider across all five apps with endpoint,
API key URL, and partner promotion config. Add trilingual promotion text
for the 30% bonus credit offer. Register aicoding and crazyrouter icons
in the icon index. Fix indentation in openclawProviderPresets.
2026-02-25 15:39:50 +08:00
Jason a254304a00 feat: add AICoding partner provider presets and i18n promotion text
Add AICoding as a partner provider across all five apps (Claude, Codex,
Gemini, OpenClaw, OpenCode) with endpoint, API key URL, and partner
promotion configuration. Add trilingual (zh/en/ja) promotion text for
the first top-up discount.
2026-02-24 23:08:22 +08:00
Jason e516f83c19 fix(test): add ResizeObserver polyfill and fix WebDAV accordion target in settings test 2026-02-24 22:28:41 +08:00
Jason b98c3ddb29 fix: add #[cfg(target_os = "windows")] to WSL helper functions to eliminate dead_code warnings 2026-02-24 22:28:41 +08:00
Jason bb3130cbe0 fix(opencode): add missing omo-slim category checks across add/form/mutation paths
Several code paths only checked for "omo" category but missed "omo-slim",
causing OMO Slim providers to be treated as regular OpenCode providers
(triggering invalid write_live_snapshot, requiring manual provider key,
and showing wrong form fields).
2026-02-24 22:28:41 +08:00
Keith Yu 8ea9638b9d feat: Add AWS Bedrock Provider Support (AKSK & API Key) (#1047)
* Add AWS Bedrock provider integration design document

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

* Add AWS Bedrock provider implementation plan

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

* Update implementation plan: add OpenCode Bedrock support

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

* feat: add cloud_provider category to ProviderCategory type

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

* feat: add AWS Bedrock (AKSK) Claude Code provider preset with tests

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

* feat: add AWS Bedrock (API Key) Claude Code provider preset with tests

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

* feat: add AWS Bedrock OpenCode provider preset with @ai-sdk/amazon-bedrock

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

* docs: add AWS Bedrock provider feature summary for PR

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

* chore: remove internal planning documents

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

* docs: add AWS Bedrock support to README (EN/ZH/JA)

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

* Add AWS Bedrock UI merge design document

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

* Add AWS Bedrock UI merge implementation plan

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

* feat: skip optional template values in validation

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

* feat: support isSecret template fields and hide base URL for Bedrock

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

* feat: add Bedrock validation, cleanup, and isBedrock prop in ProviderForm

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

* feat: extend TemplateValueConfig and merge Bedrock presets

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

* fix: mask Bedrock API Key as secret and support GovCloud regions

- Add isSecret: true to BEDROCK_API_KEY template value
- Update region regex to support multi-segment regions (us-gov-west-1)

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

* style: replace AWS icon with updated logo

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

* style: replace AWS icon with updated logo

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

* style: replace AWS icon with new PNG image

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

* fix: address code review findings

- Fix AWS icon: use SVG with embedded <image> instead of raw <img> tag
- Hide duplicate ApiKeySection for Bedrock (auth via template fields only)
- Guard settingsConfig cleanup against unresolved template placeholders

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

* chore: remove planning documents

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

* refactor: address PR review - split Bedrock into two presets, restore SVG icon

Based on maintainer review feedback on PR #1047:

1. Split merged "AWS Bedrock" back into two separate presets:
   - "AWS Bedrock (AKSK)": uses AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY
   - "AWS Bedrock (API Key)": uses top-level apiKey field via standard UI input

2. Restore aws.svg to pure vector SVG (was PNG-in-SVG)

3. Remove all Bedrock-specific logic from shared components:
   - Remove isBedrock prop from ClaudeFormFields
   - Remove Bedrock validation/cleanup blocks from ProviderForm
   - Remove optional/isSecret from TemplateValueConfig
   - Remove optional skip from useTemplateValues

4. Add cloud_provider category handling:
   - Skip API Key/Base URL required validation
   - Hide Speed Test and Base URL for cloud_provider
   - Hide API format selector for cloud_provider (always Anthropic)
   - Show API Key input only when config has apiKey field

5. Fix providerConfigUtils to support top-level apiKey:
   - getApiKeyFromConfig: check config.apiKey before env fields
   - setApiKeyInConfig: write to config.apiKey when present
   - hasApiKeyField: detect top-level apiKey property

6. Add OpenClaw Bedrock preset (bedrock-converse-stream protocol)

7. Update model IDs:
   - Sonnet: global.anthropic.claude-sonnet-4-6
   - Opus: global.anthropic.claude-opus-4-6-v1
   - Haiku: global.anthropic.claude-haiku-4-5-20251001-v1:0

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

* fix(test): align Bedrock API Key test assertions with preset implementation

The API Key preset was refactored to use standard UI input (apiKey: "")
instead of template variables, but the tests were not updated accordingly.

---------

Co-authored-by: root <root@ip-10-0-11-189.ap-northeast-1.compute.internal>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-02-24 21:11:18 +08:00
Jason e1e2bdef2a fix(about): show update badge instead of green check when newer version available 2026-02-24 10:23:18 +08:00
Jason c380528a27 feat(workspace): make directory paths clickable and rename "Today's Note" to "Add Memory"
Add open_workspace_directory Tauri command to open workspace/memory dirs
in the system file manager. Rename dailyMemory.createToday across all locales.
2026-02-24 09:45:10 +08:00
Jason a8dbea1398 feat(workspace): add full-text search for daily memory files
Add backend search command that performs case-insensitive matching
across all daily memory files, supporting both date and content queries.
Frontend includes animated search bar (⌘F), debounced input, snippet
display with match count badge, and search state preservation across
edits.
2026-02-24 09:03:30 +08:00
Jason 7138aca310 feat(preset): update domestic model providers to latest versions
- MiniMax: M2.1 → M2.5
- Zhipu GLM: glm-4.7 → glm-5
- BaiLing: Ling-1T → Ling-2.5-1T
- DouBaoSeed: doubao-seed-code-preview-latest → doubao-seed-2-0-code-preview-latest
- Qwen (Bailian): qwen3-max → qwen3.5-plus
2026-02-23 22:39:42 +08:00
Jason Young 992dda5c5c refactor(provider): switch from full config overwrite to partial key-field merging (#1098)
* 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

* 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)

* 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.

* 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)

* 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.

* 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.

* 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:57:04 +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
294 changed files with 33532 additions and 6125 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 ' }'
+2 -1
View File
@@ -8,7 +8,7 @@ release/
*.tsbuildinfo
.npmrc
CLAUDE.md
AGENTS.md
# AGENTS.md
GEMINI.md
/.claude
/.codex
@@ -23,3 +23,4 @@ nul
flatpak/cc-switch.deb
flatpak-build/
flatpak-repo/
.worktrees/
+230
View File
@@ -9,6 +9,236 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [3.11.0] - 2026-02-26
### Feature Release
This release introduces **OpenClaw** as the fifth supported application, a full **Session Manager** for browsing conversation history across all apps, an independent **Backup Management** panel, **Oh My OpenCode (OMO)** integration, and 50+ other features, fixes, and improvements across 147 commits.
**Stats**: 147 commits | 274 files changed | +32,179 insertions | -5,467 deletions
### Added
#### OpenClaw Support (New Application)
- **OpenClaw Integration**: Full management support for OpenClaw as the fifth application in CC Switch, including provider switching, configuration panels (Env / Tools / Agents Defaults), Workspace file management (HEARTBEAT / BOOTSTRAP / BOOT), daily memory files, and additive overlay mode
- **OpenClaw Provider Presets**: 13+ built-in provider presets with brand icon and complete i18n (zh/en/ja)
- **OpenClaw Form Fields**: Dedicated provider form with providerKey input, model allowlist auto-registration, and default model button
- **OpenClaw Config Panels**: Env editor, Tools editor, and Agents Defaults editor backed by JSON5 read/write (`openclaw_config.rs`)
#### Session Manager
- **Session Manager**: Browse and search conversation history for Claude Code, Codex, Gemini CLI, OpenCode, and OpenClaw with table-of-contents navigation and in-session search
- **Session App Filter**: Auto-filter sessions by current app when entering the session page
- **Session Performance**: Parallel directory scanning and head-tail JSONL reading for faster session list loading
#### Backup Management
- **Backup Panel**: Independent backup management panel with configurable backup policy (max count, auto-cleanup) and backup rename support
- **Periodic Backup**: Hourly automatic backup timer during runtime
- **Pre-Migration Backup**: Automatic backup before database schema migrations with backfill warning
- **Delete Backup**: Delete individual backup files with confirmation dialog
- **Backup Time Fix**: Use local time instead of UTC for backup file names
#### Oh My OpenCode (OMO)
- **OMO Integration**: Full Oh My OpenCode config file management with agent model selection, category configuration, and recommended model fill
- **OMO Slim**: Lightweight oh-my-opencode-slim mode support with OmoVariant parameterization
- **OMO Cross-Exclusion**: Enforce OMO ↔ OMO Slim mutual exclusion at the database level
#### Workspace
- **Daily Memory Search**: Full-text search across daily memory files with date-sorted display
- **Clickable Paths**: Directory paths in workspace panels are now clickable; renamed “Today's Note” to “Add Memory”
- **Workspace Files Panel**: Manage bootstrap markdown files for OpenClaw (HEARTBEAT / BOOTSTRAP / BOOT types)
#### Provider Presets
- **AWS Bedrock**: Support for AKSK and API Key authentication modes (Claude and OpenCode)
- **SSAI Code**: Partner provider preset across all five apps
- **CrazyRouter**: Partner provider preset with custom icon
- **AICoding**: Partner provider preset with i18n promotion text
- **Bailian**: Renamed from Qwen Coder with new icon; updated domestic model providers to latest versions
#### Proxy & Network
- **Thinking Budget Rectifier**: New rectifier for thinking budget parameters with dedicated module (`thinking_budget_rectifier.rs`)
- **WebDAV Auto Sync**: Automatic periodic sync with large file protection mechanism
#### UI & UX
- **Theme Animation**: Circular reveal animation when toggling between light and dark themes
- **Claude Quick Toggles**: Quick toggle switches in the Claude config JSON editor for common settings
- **Dynamic Endpoint Hint**: Context-aware hint text in endpoint input based on API format selection
- **AppSwitcher Auto Compact**: Automatically collapse to compact mode based on available width, with smooth transition animation
- **App Transition**: Fade-in/fade-out animation when switching between OpenClaw and other apps
- **Silent Startup Conditional**: Show silent startup option only when launch-on-startup is enabled
#### Settings & Environment
- **First-Run Confirmation**: Confirmation dialogs for proxy and usage features on first use
- **Local Proxy Toggle**: `enableLocalProxy` setting to control proxy UI visibility on the home page
- **Environment Check**: More granular local environment detection (installed CLI tool versions, Volta path detection)
#### Usage & Pricing
- **Usage Dashboard Enhancement**: Auto-refresh control, robust formatting, and request log table improvements
- **New Model Pricing**: Added pricing data for claude-opus-4-6 and gpt-5.3-codex with incremental data seeding
### Changed
#### Architecture
- **Partial Key-Field Merging**: Provider switching now uses partial key-field merging instead of full config overwrite, preserving user's non-provider settings (plugins, MCP, permissions). Removes 6 frontend files and ~150 lines of backend dead code (#1098)
- **Manual Import**: Replaced auto-import on startup with manual “Import Current Config” button in empty state, reducing ~47 lines of startup code
- **OMO Variant Parameterization**: Eliminated ~250 lines of OMO/OMO Slim code duplication via `OmoVariant` struct with STANDARD/SLIM constants
- **OMO Common Config Removal**: Removed the two-layer merge system for OMO common config (-1,733 lines across 21 files)
#### Code Quality
- **ProviderForm Decomposition**: Extracted ProviderForm.tsx from 2,227 lines to 1,526 lines by splitting into 5 focused modules (opencodeFormUtils, useOmoModelSource, useOpencodeFormState, useOmoDraftState, useOpenclawFormState)
- **Shared MCP/Skills Components**: Extracted AppCountBar, AppToggleGroup, and ListItemRow shared components to eliminate duplication across MCP and Skills panels
- **OpenClaw TanStack Query Migration**: Migrated Env, Tools, and AgentsDefaults panels from manual useState/useEffect to centralized TanStack Query hooks
#### Settings Layout
- **Proxy Tab**: Split Advanced tab into dedicated Proxy tab (local proxy, failover, rectifiers, global outbound proxy); moved pricing config to Usage dashboard as collapsible accordion. SettingsPage reduced from ~716 to ~426 lines with 5-tab layout: General | Proxy | Advanced | Usage | About
- **Data Section Split**: Split data accordion into Import/Export and Cloud Sync sections for better discoverability
#### Terminal & Config
- **Unified Terminal Selection**: Consolidated terminal preference to global settings; added WezTerm support and terminal name mapping (iterm2 → iterm)
- **OpenClaw Agents Panel**: Primary model field set to read-only; detailed model fields (context window, max tokens, reasoning, cost) moved to advanced options
- **Claude Model Update**: Updated Claude model references from 4.5 to 4.6 across all provider presets
### Fixed
#### Critical
- **Windows Home Dir Regression**: Restored default home directory resolution on Windows to prevent providers/settings “disappearing” when `HOME` env var differs from the real user profile directory (Git/MSYS environments); auto-detects v3.10.3 legacy database location
- **Linux White Screen**: Disabled WebKitGTK hardware acceleration on AMD GPUs (Cezanne/Radeon Vega) to prevent EGL initialization failure causing blank screen on startup
- **OpenAI Beta Parameter**: Stopped appending `?beta=true` to OpenAI Chat Completions endpoints, fixing request failures for Nvidia and other `apiFormat=”openai_chat”` providers
- **Health Check Auth Mode**: Health check now respects provider's auth_mode setting instead of always using x-api-key header
#### Provider & Preset
- **OpenClaw /v1 Prefix**: Removed /v1 prefix from OpenClaw anthropic-messages presets to prevent double path (/v1/v1/messages) with Anthropic SDK auto-append
- **Opus Pricing**: Corrected Opus pricing from $15/$75 to $5/$25 and upgraded model ID to claude-opus-4-6
- **AIGoCode URLs**: Unified API base URL to https://api.aigocode.com across all apps; removed trailing /v1 suffix
- **Zhipu GLM**: Removed outdated partner status from Claude, OpenCode, and OpenClaw presets
- **API Key Visibility**: Restored API Key input field when creating new Claude providers (was incorrectly hidden for non-cloud_provider categories)
#### OMO / OMO Slim
- **OMO Slim Category Checks**: Added missing omo-slim category checks across add/form/mutation paths
- **OMO Slim Cache Invalidation**: Invalidate OMO Slim query cache after provider mutations to prevent stale UI state
- **OMO Recommended Models**: Synced agent/category recommended models with upstream sources; fixed provider/model format to pure model IDs
- **OMO Fill Feedback**: Added toast feedback when “Fill Recommended” button silently fails
- **OMO Last-Provider Restriction**: Removed last-provider deletion restriction for OMO/OMO Slim plugins
- **OpenCode Model Validation**: Reject saving OpenCode providers without at least one configured model
#### OpenClaw
- **OpenClaw P0-P3 Fixes**: Fixed 25 missing i18n keys, replaced key={index} with stable crypto.randomUUID(), excluded openclaw from ProxyToggle/FailoverToggle, added deep link merge_additive_config(), unified serde(flatten) naming, added directory existence checks, removed dead code, added duplicate key validation
- **OpenClaw Robustness**: Fixed EnvPanel visibleKeys using entry key names instead of array indices; added NaN guards; validated provider ID and model before import
- **OpenClaw i18n Dedup**: Merged duplicate openclaw i18n keys to restore provider form translations
#### Platform
- **Window Flash**: Prevented window flicker on silent startup (Windows)
- **Title Bar Theme**: Title bar now follows dark/light mode theme changes
- **Skills Path Separator**: Fixed path separator matching for skill installation status on Windows (supports both `/` and `\`)
- **WSL Conditional Compilation**: Added `#[cfg(target_os = “windows”)]` to WSL helper functions to eliminate dead_code warnings on non-Windows platforms
#### UI
- **Toolbar Clipping**: Removed toolbar height limit that was clipping AppSwitcher
- **Update Badge**: Show update badge instead of green check when a newer version is available
- **Session Button Visibility**: Only show Session Manager button for Claude and Codex apps
- **Directory Spacing**: Added vertical spacing between directory setting sections
- **Dark Mode Cards**: Unified SQL import/export card styling in dark mode
- **OpenClaw Scroll**: Enabled scrolling for OpenClaw configuration panel content
#### i18n & Localization
- **Session Manager i18n**: Replaced hardcoded Chinese strings with i18n keys for relative time, role labels, and UI elements
- **OpenClaw Default Model Label**: Renamed “Enable/Default” to “Set as Default / Current Default” with wider button
- **Daily Memory Sort**: Sort daily memory files by filename date (YYYY-MM-DD.md) instead of modification time
- **Backup Name i18n**: Use local time for backup file names
#### Other
- **Skill Doc URL**: Use actual branch from download_repo for documentation URL; switched from /tree/ to /blob/ pointing to SKILL.md
- **OpenCode Install Detection**: Added install.sh priority paths (OPENCODE_INSTALL_DIR > XDG_BIN_DIR > ~/bin > ~/.opencode/bin) with path dedup and cross-platform executable candidates
- **Provider Auto-Import**: Removed auto-import side effect from useProvidersQuery queryFn; users now trigger import manually via empty state button
- **Manual Backup Validation**: Treat missing database file as error during manual backup to prevent false success toast
### Performance
- **Session Panel Loading**: Parallel directory scanning and head-tail JSONL reading for Codex, OpenClaw, and OpenCode session providers
- **Query Cache Cleanup**: Removed unnecessary TanStack Query cache overhead for Tauri local IPC calls
### Documentation
- **Sponsors**: Added/updated SSSAiCode, Crazyrouter, AICoding, Right Code, and MiniMax sponsor entries across all README languages
- **User Manual**: Added user manual documentation (#979)
### Maintenance
- **Pre-Release Cleanup**: Removed debug logs, fixed clippy warnings, added missing Japanese translations, and formatted code
- **UI Exclusions**: Hidden MCP, Skills, proxy/pricing, stream check, and model test panels for OpenClaw where not applicable
---
## [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
+34 -5
View File
@@ -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
@@ -128,6 +156,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
**Core Capabilities**
- **Provider Management**: One-click switching between Claude Code, Codex, and Gemini API configurations
- **AWS Bedrock Support**: Built-in AWS Bedrock provider presets with AKSK and API Key authentication, cross-region inference support (global/us/eu/apac), covering Claude Code and OpenCode
- **Speed Testing**: Measure API endpoint latency with visual quality indicators
- **Import/Export**: Backup and restore configs with auto-rotation (keep 10 most recent)
- **i18n Support**: Complete Chinese/English localization (UI, errors, tray)
+33 -4
View File
@@ -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>
## スクリーンショット
@@ -128,6 +156,7 @@
**コア機能**
- **プロバイダ管理**Claude Code、Codex、Gemini の API 設定をワンクリックで切り替え
- **AWS Bedrock 対応**AWS Bedrock プロバイダプリセットを内蔵、AKSK および API Key 認証に対応、クロスリージョン推論(global/us/eu/apac)をサポート、Claude Code と OpenCode に対応
- **速度テスト**:エンドポイント遅延を計測し、品質を可視化
- **インポート/エクスポート**:設定をバックアップ・復元(最新 10 件を自動ローテーション)
- **多言語対応**:UI/エラー/トレイを含む中国語・英語・日本語ローカライズ
+35 -5
View File
@@ -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>
@@ -128,6 +157,7 @@
**核心功能**
- **供应商管理**:一键切换 Claude Code、Codex 与 Gemini 的 API 配置
- **AWS Bedrock 支持**:内置 AWS Bedrock 供应商预设,支持 AKSK 和 API Key 两种认证方式,支持跨区域推理(global/us/eu/apac),覆盖 Claude Code 和 OpenCode
- **速度测试**:测量 API 端点延迟,可视化连接质量指示器
- **导入导出**:备份和恢复配置,自动轮换(保留最近 10 个)
- **国际化支持**:完整的中英文本地化(UI、错误、托盘)
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);
}
+291
View File
@@ -0,0 +1,291 @@
# CC Switch v3.11.0
> OpenClaw Support, Session Manager, Backup Management & 50+ Improvements
**[中文版 →](release-note-v3.11.0-zh.md) | [日本語版 →](release-note-v3.11.0-ja.md)**
---
## Overview
CC Switch v3.11.0 is a major update that adds full management support for **OpenClaw** as the fifth application, introduces a new **Session Manager** and **Backup Management** feature. Additionally, **Oh My OpenCode (OMO) integration**, the **partial key-field merging** architecture upgrade for provider switching, **settings page refactoring**, and many other improvements make the overall experience more polished.
**Release Date**: 2026-02-26
**Update Scale**: 147 commits | 274 files changed | +32,179 / -5,467 lines
---
## Highlights
- **OpenClaw Support**: Fifth managed application with 13 provider presets, Env/Tools/AgentsDefaults config editors, and Workspace file management
- **Session Manager**: Browse conversation history across all five apps with table-of-contents navigation and in-session search
- **Backup Management**: Independent backup panel with configurable policies, periodic backups, and pre-migration auto-backup
- **Oh My OpenCode Integration**: Full OMO config management with OMO Slim lightweight mode support
- **Partial Key-Field Merging**: Provider switching now preserves user's non-provider settings
- **Settings Page Refactoring**: 5-tab layout with ~40% code reduction
- **6 New Provider Presets**: AWS Bedrock, SSAI Code, CrazyRouter, AICoding, and more
- **Thinking Budget Rectifier**: Fine-grained thinking budget control
- **Theme Switch Animation**: Circular reveal transition animation
- **WebDAV Auto Sync**: Automatic sync with large file protection
---
## Main Features
### OpenClaw Support (New Fifth App)
Full management support for OpenClaw, the fifth managed application following Claude Code, Codex, Gemini CLI, and OpenCode.
- **Provider Management**: Add, edit, switch, and delete OpenClaw providers with 13 built-in presets
- **Config Editors**: Three dedicated panels for Env (environment variables), Tools, and AgentsDefaults
- **Workspace Panel**: HEARTBEAT/BOOTSTRAP/BOOT file management and daily memory
- **Additive Overlay Mode**: Support config overlay instead of overwrite
- **Default Model Button**: One-click to fill recommended models; auto-register suggested models to allowlist when adding providers
- **Brand & Interaction**: Dedicated brand icon, fade-in/fade-out transition animation when switching apps
- **Deep Link Support**: Import OpenClaw provider configurations via URL
- **Full Internationalization**: Complete Chinese/English/Japanese support
### Session Manager
A brand-new session manager to browse and search conversation history.
- Browse conversation history across Claude Code, Codex, Gemini CLI, OpenCode, and OpenClaw (#867, thanks @TinsFox)
- Table-of-contents navigation and in-session search
- Auto-filter by current app when entering the session page
- Parallel directory scanning + head-tail JSONL reading for optimized loading performance
### Backup Management
An independent backup management panel for better data safety.
- Configurable backup policy: maximum backup count and auto-cleanup rules
- Hourly automatic backup timer during runtime
- Auto-backup before database schema migrations with backfill warning
- Support backup rename and deletion (with confirmation dialog)
- Backup filenames use local time for better clarity
### Oh My OpenCode (OMO) Integration
Full Oh My OpenCode config file management.
- Agent model selection, category configuration, and recommended model fill (#972, thanks @yovinchen)
- Improved agent model selection UX with lowercase key fix (#1004, thanks @yovinchen)
- OMO Slim lightweight mode support
- OMO ↔ OMO Slim mutual exclusion (enforced at database level)
### Workspace
- Full-text search across daily memory files, sorted by date
- Clickable directory paths for quick file location access
### Toolbar
- AppSwitcher auto-collapses to compact mode based on available width
- Smooth transition animation for compact mode toggle
### Settings
- First-use confirmation dialogs for proxy and usage features to prevent accidental operations
- New `enableLocalProxy` switch to control proxy UI visibility on home page
- More granular local environment checks: CLI tool version detection (#870, thanks @kv-chiu), Volta path detection (#969, thanks @myjustify)
### Provider Presets
- **AWS Bedrock**: Support for AKSK and API Key authentication modes (#1047, thanks @keithyt06)
- **SSAI Code**: Partner preset across all five apps
- **CrazyRouter**: Partner preset with dedicated icon
- **AICoding**: Partner preset with i18n promotion text
- Updated domestic model provider presets to latest versions
- Renamed Qwen Coder to Bailian (#965, thanks @zhu-jl18)
### Other New Features
- **Thinking Budget Rectifier**: Fine-grained thinking budget allocation control (#1005, thanks @yovinchen)
- **WebDAV Auto Sync**: Automatic sync with large file protection (#923, thanks @clx20000410; #1043, thanks @SaladDay)
- **Theme Switch Animation**: Circular reveal transition for a smoother visual experience (#905, thanks @funnytime75)
- **Claude Config Editor Quick Toggles**: Quick toggle switches for common settings (#1012, thanks @JIA-ss)
- **Dynamic Endpoint Hint**: Context-aware hint text based on API format selection (#860, thanks @zhu-jl18)
- **Usage Dashboard Enhancement**: Auto-refresh control and robust formatting (#942, thanks @yovinchen)
- **New Pricing Data**: claude-opus-4-6 and gpt-5.3-codex (#943, thanks @yovinchen)
- **Silent Startup Optimization**: Silent startup option only shown when launch-on-startup is enabled
---
## Architecture Improvements
### Partial Key-Field Merging (Important Change)
Provider switching now uses partial key-field merging instead of full config overwrite (#1098). When switching providers, only provider-related key-values are updated, preserving user's non-provider settings (plugins, MCP, permissions, etc.). This refactoring removed 6 frontend files and ~150 lines of backend dead code.
### Manual Import Replaces Auto-Import
Startup no longer auto-imports external configurations. Users now click "Import Current Config" manually, preventing accidental data overwrites.
### OmoVariant Parameterization
Eliminated ~250 lines of duplicated code in the OMO module via `OmoVariant` struct parameterization.
### OMO Common Config Removal
Removed the two-layer merge system, reducing ~1,733 lines of code and simplifying the architecture.
### ProviderForm Decomposition
Reduced ProviderForm component from 2,227 lines to 1,526 lines by extracting 5 independent modules (opencodeFormUtils, useOmoModelSource, useOpencodeFormState, useOmoDraftState, useOpenclawFormState), significantly improving maintainability.
### Shared MCP/Skills Components
Extracted AppCountBar, AppToggleGroup, and ListItemRow shared components to reduce duplication across MCP and Skills panels (#897, thanks @PeanutSplash).
### Settings Page Refactoring
Refactored settings page to a 5-tab layout (General | Proxy | Advanced | Usage | About), reducing SettingsPage code from ~716 to ~426 lines.
### Other Improvements
- Unified terminal selection via global settings with WezTerm support added
- Updated Claude model references from 4.5 to 4.6
---
## Bug Fixes
### Critical Fixes
- **Windows Home Dir Regression**: Restored default home directory resolution to prevent providers/settings "disappearing" when `HOME` env var differs from the real user profile directory in Git/MSYS environments
- **Linux White Screen**: Disabled WebKitGTK hardware acceleration on AMD GPUs (Cezanne/Radeon Vega) to prevent blank screen on startup (#986, thanks @ThendCN)
- **OpenAI Beta Parameter**: Stopped appending `?beta=true` to `/v1/chat/completions` endpoints, fixing request failures for Nvidia and other `apiFormat="openai_chat"` providers (#1052, thanks @jnorthrup)
- **Health Check Auth**: Health check now respects provider's `auth_mode` setting, preventing failures for proxy services that only support Bearer authentication (#824, thanks @Jassy930)
### Provider Preset Fixes
- Fixed OpenClaw `/v1` prefix causing double path (/v1/v1/messages)
- Corrected Opus pricing ($15/$75 → $5/$25) and upgraded to 4.6
- Unified AIGoCode URL to `https://api.aigocode.com` across all apps
- Removed outdated partner status from Zhipu GLM presets
- Restored API Key input visibility when creating new Claude providers
- Hide quick toggles for non-active providers, show context-aware JSON editor hints
### OMO Fixes
- Added missing omo-slim category checks across add/form/mutation paths
- Fixed OMO Slim query cache invalidation after provider mutations
- Synced OMO agent/category recommended models with upstream sources
- Added toast feedback for "Fill Recommended" button silent failures
- Removed last-provider deletion restriction for OMO/OMO Slim
- Reject saving OpenCode providers without configured models (#932, thanks @yovinchen)
### OpenClaw Fixes
- Fixed 25 missing i18n keys, replaced key={index} with stable IDs, added deep link additive merge, and other code review issues
- Enhanced EnvPanel robustness (NaN guards, entry key names instead of array indices)
- Merged duplicate i18n keys to restore provider form translations
### Platform Fixes
- Windows silent startup window flicker (#901, thanks @funnytime75)
- Title bar dark mode theme following (#903, thanks @funnytime75)
- Windows Skills path separator matching (#868, thanks @stmoonar)
- WSL helper functions conditional compilation
### UI Fixes
- Toolbar height clipping causing AppSwitcher to be obscured
- Show update badge instead of green checkmark when newer version available
- Session Manager button only visible for Claude/Codex apps
- Unified SQL import/export card dark mode styling (#1067, thanks @SaladDay)
### Other Fixes
- Replaced hardcoded Chinese strings in Session Manager with i18n keys
- Fixed Skill documentation URL branch and path resolution (#977, thanks @yovinchen)
- Added missing OpenCode install.sh installation path detection (#988, thanks @zhu-jl18)
- Fixed Skill ZIP symlink resolution (#1040, thanks @yovinchen)
- Added missing OpenCode checkbox in MCP add/edit form (#1026, thanks @yovinchen)
- Removed auto-import side effect from useProvidersQuery queryFn
---
## Performance
- Parallel directory scanning + head-tail JSONL reading for session panel, significantly improving session list loading speed
- Removed unnecessary TanStack Query cache overhead for Tauri local IPC calls
---
## Documentation
- Sponsor updates: SSSAiCode, Crazyrouter, AICoding, Right Code, MiniMax
- Added user manual documentation (#979, thanks @yovinchen)
---
## Notes & Considerations
- **OpenClaw is a newly supported app**: OpenClaw CLI must be installed first to use related features.
- **Partial key-field merging is an important architecture change**: Provider switching no longer overwrites the entire config file, but only merges provider-related key-values. Please note this change if you previously relied on full overwrite behavior.
- **Auto-import changed to manual**: External configurations are no longer auto-imported on startup. Click "Import Current Config" manually when needed.
- **OMO and OMO Slim are mutually exclusive**: Only one can be active at a time. Switching to one automatically disables the other.
- **Backup is enabled by default**: Automatic hourly backup during runtime. Adjust the policy in the Backup panel.
---
## Special Thanks
Thanks to all contributors for their contributions to this release!
@TinsFox @keithyt06 @kv-chiu @SaladDay @jnorthrup @JIA-ss @clx20000410 @ThendCN @yovinchen @zhu-jl18 @myjustify @funnytime75 @PeanutSplash @Jassy930 @stmoonar
---
## 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.11.0-Windows.msi` | **Recommended** - MSI installer with auto-update |
| `CC-Switch-v3.11.0-Windows-Portable.zip` | Portable version, extract and run, no registry write |
### macOS
| File | Description |
| -------------------------------- | -------------------------------------------------------------------- |
| `CC-Switch-v3.11.0-macOS.zip` | **Recommended** - Extract and drag to Applications, Universal Binary |
| `CC-Switch-v3.11.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` |
+291
View File
@@ -0,0 +1,291 @@
# CC Switch v3.11.0
> OpenClaw サポート、セッションマネージャー、バックアップ管理と 50 以上の改善
**[中文版 →](release-note-v3.11.0-zh.md) | [English →](release-note-v3.11.0-en.md)**
---
## 概要
CC Switch v3.11.0 は大規模なアップデートです。5番目のアプリケーション **OpenClaw** の完全管理サポートを追加し、新しい**セッションマネージャー**と**バックアップ管理**機能を導入しました。さらに、**Oh My OpenCode (OMO) 統合**、プロバイダー切り替えの**部分キーフィールドマージ**アーキテクチャアップグレード、**設定ページのリファクタリング**など、多数の改善により全体的な体験がさらに向上しました。
**リリース日**: 2026-02-26
**更新規模**: 147 commits | 274 files changed | +32,179 / -5,467 lines
---
## ハイライト
- **OpenClaw サポート**: 5番目の管理対象アプリ、13 のプロバイダープリセット、Env/Tools/AgentsDefaults 設定エディター、Workspace ファイル管理
- **セッションマネージャー**: 5つのアプリの会話履歴を閲覧、目次ナビゲーションとセッション内検索
- **バックアップ管理**: 独立バックアップパネル、設定可能なポリシー、定期バックアップ、マイグレーション前自動バックアップ
- **Oh My OpenCode 統合**: 完全な OMO 設定管理、OMO Slim 軽量モードサポート
- **部分キーフィールドマージ**: プロバイダー切り替え時にユーザーの非プロバイダー設定を保持
- **設定ページリファクタリング**: 5タブレイアウト、コード量約 40% 削減
- **6つの新プロバイダープリセット**: AWS Bedrock、SSAI Code、CrazyRouter、AICoding など
- **Thinking Budget Rectifier**: より精密な thinking budget 制御
- **テーマ切り替えアニメーション**: 円形リビール遷移アニメーション
- **WebDAV 自動同期**: 自動同期と大容量ファイル保護
---
## 主な機能
### OpenClaw サポート(新しい5番目のアプリ)
Claude Code、Codex、Gemini CLI、OpenCode に続く5番目の管理対象アプリケーションとして OpenClaw の完全管理サポートを追加しました。
- **プロバイダー管理**: OpenClaw プロバイダーの追加、編集、切り替え、削除、13 の内蔵プリセット
- **設定エディター**: Env(環境変数)、Tools(ツール)、AgentsDefaults(エージェントデフォルト)の3つの専用パネル
- **Workspace パネル**: HEARTBEAT/BOOTSTRAP/BOOT ファイル管理とデイリーメモリ
- **Additive オーバーレイモード**: 上書きではなく設定の重ね合わせをサポート
- **デフォルトモデルボタン**: ワンクリックで推奨モデルを入力、プロバイダー追加時に候補モデルを allowlist に自動登録
- **ブランドとインタラクション**: 専用ブランドアイコン、アプリ切り替えフェード遷移アニメーション
- **ディープリンクサポート**: URL 経由で OpenClaw プロバイダー設定をインポート
- **完全な国際化**: 中/英/日 三言語完全対応
### セッションマネージャー
会話履歴を閲覧・検索できる新しいセッションマネージャーです。
- Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw の5つのアプリの会話履歴を閲覧(#867@TinsFox に感謝)
- 目次ナビゲーションとセッション内検索
- セッションページに入ると現在のアプリで自動フィルター
- 並列ディレクトリスキャン + ヘッドテール JSONL 読み取りで読み込みパフォーマンスを最適化
### バックアップ管理
データの安全性を高める独立バックアップ管理パネルです。
- 設定可能なバックアップポリシー: 最大バックアップ数、自動クリーンアップルール
- ランタイム中の1時間ごとの定期自動バックアップ
- データベースマイグレーション前の自動バックアップ、バックフィル警告プロンプト
- バックアップのリネームと削除をサポート(確認ダイアログ付き)
- バックアップファイル名にローカルタイムを使用、より直感的に
### Oh My OpenCode (OMO) 統合
完全な Oh My OpenCode 設定ファイル管理です。
- エージェントモデル選択、カテゴリ設定、推奨モデル入力(#972@yovinchen に感謝)
- エージェントモデル選択 UX の改善、lowercase key 問題の修正(#1004@yovinchen に感謝)
- OMO Slim 軽量モードサポート
- OMO と OMO Slim の相互排他(データベースレベルで一貫性を保証)
### ワークスペース
- デイリーメモリファイルの全文検索、日付順ソート
- ディレクトリパスがクリック可能に、ファイル位置をすばやく開く
### ツールバー
- AppSwitcher がウィンドウ幅に応じて自動的にコンパクトモードに折りたたみ
- コンパクトモード切り替えのスムーズ遷移アニメーション
### 設定
- プロキシと使用量機能に初回使用確認ダイアログを追加、誤操作を防止
- `enableLocalProxy` スイッチを追加、ホーム画面のプロキシ UI 表示を制御
- より詳細なローカル環境チェック: CLI ツールバージョン検出(#870@kv-chiu に感謝)、Volta パス検出(#969@myjustify に感謝)
### プロバイダープリセット
- **AWS Bedrock**: AKSK と API Key の2種類の認証方式をサポート(#1047@keithyt06 に感謝)
- **SSAI Code**: パートナープリセット、5アプリ対応
- **CrazyRouter**: パートナープリセットと専用アイコン
- **AICoding**: パートナープリセットとプロモーションテキスト
- 国内モデルプロバイダープリセットを最新版に更新
- Qwen Coder を百炼 (Bailian) にリネーム(#965@zhu-jl18 に感謝)
### その他の新機能
- **Thinking Budget Rectifier**: より精密な thinking budget 制御(#1005@yovinchen に感謝)
- **WebDAV 自動同期**: 自動同期設定と大容量ファイル保護(#923@clx20000410 に感謝;#1043@SaladDay に感謝)
- **テーマ切り替えアニメーション**: 円形リビール遷移アニメーション(#905@funnytime75 に感謝)
- **Claude 設定エディタークイックトグル**: よく使う設定項目のクイック切り替え(#1012@JIA-ss に感謝)
- **動的エンドポイントヒント**: API フォーマット選択に基づく動的ヒントテキスト(#860@zhu-jl18 に感謝)
- **使用量ダッシュボード強化**: 自動更新、堅牢なフォーマット(#942@yovinchen に感謝)
- **新しい価格データ**: claude-opus-4-6 と gpt-5.3-codex#943@yovinchen に感謝)
- **サイレント起動の最適化**: サイレント起動オプションは自動起動が有効な場合のみ表示
---
## アーキテクチャ改善
### 部分キーフィールドマージ(重要な変更)
プロバイダー切り替えを完全な設定上書きから部分キーフィールドマージ戦略に変更しました(#1098)。プロバイダー切り替え時に、プロバイダー関連のキー値のみを更新し、ユーザーの非プロバイダー設定(プラグイン設定、MCP 設定、権限設定など)を保持します。このリファクタリングにより、フロントエンドファイル6つと約150行のバックエンドデッドコードを削除しました。
### 手動インポートに変更
起動時の自動インポートを廃止し、手動の「現在の設定をインポート」ボタンに変更。意図しないユーザーデータの上書きを防止します。
### OmoVariant パラメータ化
`OmoVariant` 構造体によるパラメータ化で、OMO モジュールの約250行の重複コードを削除しました。
### OMO 共通設定の削除
2層マージシステムを削除し、約1,733行のコードを削減、アーキテクチャを簡素化しました。
### ProviderForm 分割
ProviderForm コンポーネントを2,227行から1,526行に削減し、5つの独立モジュール(opencodeFormUtils、useOmoModelSource、useOpencodeFormState、useOmoDraftState、useOpenclawFormState)に分離。保守性が大幅に向上しました。
### MCP/Skills 共有コンポーネント
AppCountBar、AppToggleGroup、ListItemRow などの共有コンポーネントを抽出し、MCP と Skills パネルの重複コードを削減(#897@PeanutSplash に感謝)。
### 設定ページリファクタリング
設定ページを5タブレイアウト(一般 | プロキシ | 詳細 | 使用量 | 情報)にリファクタリング。SettingsPage のコードを約716行から約426行に削減しました。
### その他の改善
- ターミナル統一: グローバル設定でターミナル選択を統一、WezTerm サポートを追加
- Claude モデル参照を 4.5 から 4.6 に更新
---
## バグ修正
### 重大な修正
- **Windows ホームディレクトリ回帰**: デフォルトのホームディレクトリ解決を復元し、Git/MSYS 環境でのデータベースパス変更によるデータ「消失」を防止
- **Linux 白画面**: AMD GPU の WebKitGTK ハードウェアアクセラレーションを無効化し、一部の Linux システムの起動白画面問題を解決(#986@ThendCN に感謝)
- **OpenAI Beta パラメータ**: `/v1/chat/completions``?beta=true` を追加しないように修正、Nvidia など OpenAI Chat 形式を使用するプロバイダーのリクエスト失敗を修正(#1052@jnorthrup に感謝)
- **ヘルスチェック認証**: プロバイダーの `auth_mode` 設定を尊重し、Bearer 認証のみをサポートするプロキシサービスのヘルスチェック失敗を回避(#824@Jassy930 に感謝)
### プロバイダープリセット修正
- OpenClaw `/v1` プレフィックスの二重パス問題を修正
- Opus 価格修正($15/$75 → $5/$25)と 4.6 へのアップグレード
- AIGoCode URL を `https://api.aigocode.com` に統一
- Zhipu GLM の古いパートナーステータスを削除
- 新規 Claude プロバイダー作成時の API Key 入力フィールドの表示を復元
- 非アクティブプロバイダーのクイックトグルを非表示、コンテキスト対応の JSON エディターヒントを表示
### OMO 修正
- omo-slim カテゴリチェックの補完(add/form/mutation パス)
- OMO Slim プロバイダー変更後のクエリキャッシュ無効化を修正
- OMO agent/category 推奨モデルをアップストリームソースと同期
- 「推奨を入力」ボタン失敗時の toast フィードバックを追加
- OMO/OMO Slim の最後のプロバイダー削除制限を撤廃
- OpenCode でモデル未設定時の保存を拒否(#932@yovinchen に感謝)
### OpenClaw 修正
- 25個の欠落 i18n キー、key={index} を安定 ID に置換、ディープリンク additive マージなどのコードレビュー問題を修正
- EnvPanel 堅牢性強化(NaN ガード、配列インデックスではなくエントリーキー名を使用)
- i18n 重複キーのマージ、プロバイダーフォーム翻訳を復元
### プラットフォーム修正
- Windows サイレント起動時のウィンドウフラッシュ(#901@funnytime75 に感謝)
- タイトルバーのダークモード追従(#903@funnytime75 に感謝)
- Windows の Skills パスセパレーターマッチング(#868@stmoonar に感謝)
- WSL ヘルパー関数の条件付きコンパイル
### UI 修正
- ツールバーの高さクリッピングによる AppSwitcher の遮蔽を修正
- 新バージョンがある場合、緑のチェックマークではなく更新バッジを表示
- セッションマネージャーボタンを Claude/Codex アプリでのみ表示
- SQL インポート/エクスポートカードのダークモードスタイルを統一(#1067@SaladDay に感謝)
### その他の修正
- セッションマネージャーのハードコードされた中国語文字列を i18n キーに置換
- Skill ドキュメント URL のブランチとパスを修正(#977@yovinchen に感謝)
- OpenCode install.sh インストールパス検出の補完(#988@zhu-jl18 に感謝)
- Skill ZIP シンボリックリンク解決の修正(#1040@yovinchen に感謝)
- MCP フォームに OpenCode チェックボックスを追加(#1026@yovinchen に感謝)
- useProvidersQuery の自動インポート副作用を削除
---
## パフォーマンス最適化
- セッションパネルの並列ディレクトリスキャン + ヘッドテール JSONL 読み取りで、セッションリスト読み込み速度を大幅向上
- Tauri ローカル IPC の不要な query cache を削除し、メモリ使用量を削減
---
## ドキュメント
- スポンサー更新: SSSAiCode、Crazyrouter、AICoding、Right Code、MiniMax
- ユーザーマニュアルを追加(#979@yovinchen に感謝)
---
## 注意事項
- **OpenClaw は新しくサポートされたアプリです**: 関連機能を使用するには、先に OpenClaw CLI をインストールする必要があります。
- **部分キーフィールドマージは重要なアーキテクチャ変更です**: プロバイダー切り替え時に設定ファイルを完全上書きするのではなく、プロバイダー関連のキー値のみをマージするようになりました。以前の完全上書き動作に依存していた場合はご注意ください。
- **自動インポートは手動に変更されました**: 起動時に外部設定を自動インポートしなくなりました。必要に応じて「現在の設定をインポート」を手動でクリックしてください。
- **OMO と OMO Slim は相互排他**: 同時に一つだけ有効にできます。切り替え時にもう一方は自動的に無効になります。
- **バックアップ機能はデフォルトで有効**: ランタイム中に1時間ごとに自動バックアップします。バックアップパネルでポリシーを調整できます。
---
## 特別な感謝
以下のコントリビューターの皆様、このリリースへの貢献に感謝します!
@TinsFox @keithyt06 @kv-chiu @SaladDay @jnorthrup @JIA-ss @clx20000410 @ThendCN @yovinchen @zhu-jl18 @myjustify @funnytime75 @PeanutSplash @Jassy930 @stmoonar
---
## ダウンロードとインストール
[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.11.0-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
| `CC-Switch-v3.11.0-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし |
### macOS
| ファイル | 説明 |
| -------------------------------- | ----------------------------------------------------------------- |
| `CC-Switch-v3.11.0-macOS.zip` | **推奨** - 解凍して Applications にドラッグ、Universal Binary |
| `CC-Switch-v3.11.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` |
+291
View File
@@ -0,0 +1,291 @@
# CC Switch v3.11.0
> OpenClaw 支持、会话管理器、备份管理与 50+ 项改进
**[English →](release-note-v3.11.0-en.md) | [日本語版 →](release-note-v3.11.0-ja.md)**
---
## 概览
CC Switch v3.11.0 是一次大规模更新,新增第五个应用 **OpenClaw** 的完整管理支持,同时带来全新的**会话管理器**和**备份管理**功能。此外,**Oh My OpenCode (OMO) 集成**、供应商切换的**部分键值合并**架构升级、**设置页面重构**等多项改进使整体体验更加完善。
**发布日期**2026-02-26
**更新规模**147 commits | 274 files changed | +32,179 / -5,467 lines
---
## 重点内容
- **OpenClaw 支持**:第五个受管理应用,含 13 个供应商预设、Env/Tools/AgentsDefaults 配置编辑器、Workspace 文件管理
- **会话管理器**:浏览五个应用的历史会话,支持目录导航和会话内搜索
- **备份管理**:独立备份面板,可配置策略、定时备份、迁移前自动备份
- **Oh My OpenCode 集成**:完整 OMO 配置管理,支持 OMO Slim 轻量模式
- **部分键值合并**:供应商切换不再全量覆写,保留用户的非供应商设置
- **设置页面重构**:5 标签页布局,代码量减少约 40%
- **6 组新供应商预设**AWS Bedrock、SSAI Code、CrazyRouter、AICoding 等
- **Thinking Budget Rectifier**:代理矫正器,更精细的 thinking budget 控制
- **主题切换动画**:圆形揭示过渡动画,视觉体验升级
- **WebDAV 自动同步**:支持自动同步与大文件防护
---
## 主要功能
### OpenClaw 支持(新增第五应用)
CC Switch 新增对 OpenClaw 的完整管理支持,这是继 Claude Code、Codex、Gemini CLI、OpenCode 之后的第五个受管理应用。
- **供应商管理**:新增、编辑、切换、删除 OpenClaw 供应商,含 13 个内置预设
- **配置编辑器**:Env(环境变量)、Tools(工具)、AgentsDefaults(代理默认值)三个专属配置面板
- **Workspace 面板**:支持 HEARTBEAT/BOOTSTRAP/BOOT 文件管理及每日记忆
- **Additive 叠加模式**:支持配置叠加而非覆盖
- **默认模型按钮**:一键填充推荐模型,添加供应商时自动将建议模型注册到 allowlist
- **品牌与交互**:专属品牌图标、应用切换淡入淡出过渡动画
- **深链接支持**:通过 URL 导入 OpenClaw 供应商配置
- **完整国际化**:中/英/日三语全面支持
### 会话管理器 Sessions
全新的会话管理器,帮助你浏览和检索历史会话记录。
- 支持浏览 Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw 五个应用的历史会话(#867,感谢 @TinsFox
- 目录导航和会话内搜索
- 进入会话页面时默认过滤为当前应用,快速定位
- 并行目录扫描 + 头尾 JSONL 读取,优化加载性能
### 备份管理 Backup
独立的备份管理面板,让数据安全更有保障。
- 可配置备份策略:最大备份数量、自动清理规则
- 运行时每小时定期自动备份
- 数据库迁移前自动备份,带回填警告提示
- 支持备份重命名和删除(含确认对话框)
- 备份文件名使用本地时间,更直观
### Oh My OpenCode (OMO) 集成
完整的 Oh My OpenCode 配置文件管理。
- Agent 模型选择、Category 配置、推荐模型填充(#972,感谢 @yovinchen
- 改进 Agent 模型选择 UX,修复 lowercase key 问题(#1004,感谢 @yovinchen
- OMO Slim 轻量模式支持
- OMO 与 OMO Slim 互斥切换(数据库层级强制保证一致性)
### 工作空间 Workspace
- 每日记忆文件全文搜索,按日期排序
- 目录路径可点击跳转,快速打开文件位置
### 工具栏 Toolbar
- AppSwitcher 根据窗口宽度自动折叠为紧凑模式
- 紧凑模式切换平滑过渡动画
### 设置 Settings
- 代理和用量功能新增首次使用确认对话框,避免误操作
- 新增 `enableLocalProxy` 开关,控制主页代理 UI 显示
- 更精细的本地环境检查:CLI 工具版本检测(#870,感谢 @kv-chiu)、Volta 路径检测(#969,感谢 @myjustify
### 供应商预设 Preset
- **AWS Bedrock**:支持 AKSK 和 API Key 两种认证方式(#1047,感谢 @keithyt06
- **SSAI Code**:合作伙伴预设,覆盖五端
- **CrazyRouter**:合作伙伴预设及专属图标
- **AICoding**:合作伙伴预设及推广文案
- 更新国内模型供应商预设至最新版本
- Qwen Coder 重命名为百炼 (Bailian)#965,感谢 @zhu-jl18
### 其他新功能
- **Thinking Budget Rectifier**:代理矫正器,更精细地控制 thinking budget 分配(#1005,感谢 @yovinchen
- **WebDAV 自动同步**:支持自动同步配置,并增加大文件防护(#923,感谢 @clx20000410#1043,感谢 @SaladDay
- **主题切换动画**:圆形揭示过渡动画,视觉体验更流畅(#905,感谢 @funnytime75
- **Claude 配置编辑器快速开关**:快速切换常用配置项(#1012,感谢 @JIA-ss
- **动态端点提示**:根据 API 格式选择动态显示端点提示文本(#860,感谢 @zhu-jl18
- **用量仪表盘增强**:自动刷新、更强健的数据格式化(#942,感谢 @yovinchen
- **新增定价数据**claude-opus-4-6 和 gpt-5.3-codex#943,感谢 @yovinchen
- **静默启动优化**:静默启动选项仅在开机启动开启时显示
---
## 架构改进
### 部分键值合并(重要变更)
供应商切换从全量配置覆写改为部分键值合并策略(#1098)。切换供应商时,仅更新供应商相关的键值,保留用户的非供应商设置(如插件配置、MCP 配置、权限设置等)。此次重构删除了 6 个前端文件、约 150 行后端死代码。
### 手动导入替代自动导入
启动时不再自动导入外部配置,改为手动点击"导入当前配置"按钮,避免意外覆盖用户数据。
### OMO Variant 参数化
通过 `OmoVariant` 结构体参数化消除 OMO 模块约 250 行重复代码。
### OMO 公共配置移除
删除二层合并系统,减少约 1,733 行代码,简化架构。
### ProviderForm 拆分
ProviderForm 组件从 2,227 行减至 1,526 行,提取 5 个独立模块(opencodeFormUtils、useOmoModelSource、useOpencodeFormState、useOmoDraftState、useOpenclawFormState),可维护性显著提升。
### MCP/Skills 共享组件
提取 AppCountBar、AppToggleGroup、ListItemRow 等共享组件,减少 MCP 和 Skills 面板的重复代码(#897,感谢 @PeanutSplash)。
### 设置页面重构
设置页面重构为 5 标签页布局(通用 | 代理 | 高级 | 用量 | 关于),SettingsPage 代码从约 716 行减至约 426 行。
### 其他改进
- 终端统一:全局设置统一终端选择,新增 WezTerm 支持
- Claude 模型引用从 4.5 更新到 4.6
---
## Bug 修复
### 严重修复
- **Windows 主目录回归**:恢复默认主目录解析,防止 Git/MSYS 环境下数据库路径变更导致数据"丢失"
- **Linux 白屏**:禁用 AMD GPU 的 WebKitGTK 硬件加速,解决部分 Linux 系统启动白屏问题(#986,感谢 @ThendCN
- **OpenAI Beta 参数**:不再为 `/v1/chat/completions` 添加 `?beta=true`,修复 Nvidia 等使用 OpenAI Chat 格式的供应商请求失败(#1052,感谢 @jnorthrup
- **健康检查认证**:尊重供应商 `auth_mode` 设置,避免仅支持 Bearer 认证的代理服务健康检查失败(#824,感谢 @Jassy930
### 供应商预设修复
- 修复 OpenClaw `/v1` 前缀双重路径问题
- Opus 定价修正($15/$75 → $5/$25)并升级到 4.6
- AIGoCode URL 统一为 `https://api.aigocode.com`
- Zhipu GLM 移除过时合作伙伴状态
- 新建 Claude 供应商时 API Key 输入框可见性恢复
- 非活跃供应商隐藏快速开关,显示上下文感知的 JSON 编辑器提示
### OMO 修复
- omo-slim 分类检查补齐(add/form/mutation 路径)
- OMO Slim 供应商变更后正确失效查询缓存
- OMO agent/category 推荐模型与上游源同步
- "填充推荐"按钮失败时增加 toast 反馈
- 移除 OMO/OMO Slim 最后一个供应商的删除限制
- OpenCode 未配置模型时拒绝保存(#932,感谢 @yovinchen
### OpenClaw 修复
- 修复 25 个缺失 i18n key、替换 key={index} 为稳定 ID、深链接 additive 合并等代码审查问题
- EnvPanel 健壮性增强(NaN 守卫、使用条目键名而非数组索引)
- i18n 重复键合并,恢复供应商表单翻译
### 平台修复
- Windows 静默启动时窗口闪烁(#901,感谢 @funnytime75
- 标题栏暗黑模式跟随主题(#903,感谢 @funnytime75
- Windows Skills 路径分隔符匹配(#868,感谢 @stmoonar
- WSL 辅助函数条件编译
### UI 修复
- 工具栏高度裁切导致 AppSwitcher 被遮挡
- 有新版本时显示更新徽章而非绿色对勾
- 仅 Claude/Codex 应用显示会话管理器按钮
- SQL 导入/导出卡片暗黑模式样式统一(#1067,感谢 @SaladDay
### 其他修复
- 会话管理器硬编码中文字符串替换为 i18n key
- Skill 文档 URL 分支和路径修正(#977,感谢 @yovinchen
- OpenCode install.sh 安装路径检测补齐(#988,感谢 @zhu-jl18
- Skill ZIP 符号链接解析修复(#1040,感谢 @yovinchen
- MCP 表单补齐 OpenCode 复选框(#1026,感谢 @yovinchen
- useProvidersQuery 中自动导入副作用移除
---
## 性能优化
- 会话面板并行目录扫描 + 头尾 JSONL 读取,大幅提升会话列表加载速度
- 移除 Tauri 本地 IPC 不必要的 query cache,减少内存占用
---
## 文档
- 赞助商更新:SSSAiCode、Crazyrouter、AICoding、Right Code、MiniMax
- 新增用户手册(#979,感谢 @yovinchen
---
## 说明与注意事项
- **OpenClaw 为新支持的应用**:需要先安装 OpenClaw CLI 才能使用相关功能。
- **部分键值合并是重要架构变更**:供应商切换不再全量覆写配置文件,而是仅合并供应商相关的键值。如果你之前依赖全量覆写行为,请注意此变更。
- **自动导入已改为手动**:启动时不再自动导入外部配置,请在需要时手动点击"导入当前配置"。
- **OMO 与 OMO Slim 互斥**:同一时间只能启用其中一个,切换时另一个会自动禁用。
- **备份功能默认开启**:运行时每小时自动备份,可在备份面板调整策略。
---
## 特别感谢
感谢以下贡献者为本版本做出的贡献!
@TinsFox @keithyt06 @kv-chiu @SaladDay @jnorthrup @JIA-ss @clx20000410 @ThendCN @yovinchen @zhu-jl18 @myjustify @funnytime75 @PeanutSplash @Jassy930 @stmoonar
---
## 下载与安装
访问 [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.11.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
| `CC-Switch-v3.11.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
### macOS
| 文件 | 说明 |
| -------------------------------- | --------------------------------------------------------- |
| `CC-Switch-v3.11.0-macOS.zip` | **推荐** - 解压后拖入 Applications 即可,Universal Binary |
| `CC-Switch-v3.11.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.11.0
- 最后更新:2026-02-26
- 适用于 CC Switch v3.11.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.2",
"version": "3.11.0",
"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.2"
version = "3.11.0"
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.2"
version = "3.11.0"
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"
+48 -60
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,15 +307,16 @@ 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)
/// - Additive mode (true): All providers are written to live config (OpenCode, OpenClaw)
pub fn is_additive_mode(&self) -> bool {
matches!(self, AppType::OpenCode)
matches!(self, AppType::OpenCode | AppType::OpenClaw)
}
/// Return an iterator over all app types
@@ -298,6 +326,7 @@ impl AppType {
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
AppType::OpenClaw,
]
.into_iter()
}
@@ -313,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 {
@@ -377,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 {
@@ -396,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,
@@ -403,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,
}
}
}
@@ -498,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()?;
@@ -555,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,
}
}
@@ -565,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,
}
}
@@ -579,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)
}
@@ -599,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);
}
@@ -611,6 +591,7 @@ impl MultiAppConfig {
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
AppType::OpenClaw,
] {
// 复用已有的单应用导入逻辑
if Self::auto_import_prompt_if_exists(self, app)? {
@@ -681,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);
@@ -709,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 {
+11 -131
View File
@@ -7,9 +7,7 @@ use tauri_plugin_opener::OpenerExt;
use crate::app_config::AppType;
use crate::codex_config;
use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::settings;
/// 获取 Claude Code 配置状态
#[tauri::command]
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
Ok(config::get_claude_config_status())
@@ -17,18 +15,6 @@ pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
use std::str::FromStr;
fn invalid_json_format_error(error: serde_json::Error) -> String {
let lang = settings::get_settings()
.language
.unwrap_or_else(|| "zh".to_string());
match lang.as_str() {
"en" => format!("Invalid JSON format: {error}"),
"ja" => format!("JSON形式が無効です: {error}"),
_ => format!("无效的 JSON 格式: {error}"),
}
}
#[tauri::command]
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
match AppType::from_str(&app).map_err(|e| e.to_string())? {
@@ -60,16 +46,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 +70,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 +83,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 +98,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 +129,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();
@@ -159,114 +150,3 @@ 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,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<Option<String>, String> {
state
.db
.get_config_snippet(&app_type)
.map_err(|e| e.to_string())
}
/// 设置通用配置片段(统一接口)
#[tauri::command]
pub async fn set_common_config_snippet(
app_type: String,
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
// 验证格式(根据应用类型)
if !snippet.trim().is_empty() {
match app_type.as_str() {
"claude" | "gemini" => {
// 验证 JSON 格式
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(invalid_json_format_error)?;
}
"codex" => {
// TOML 格式暂不验证(或可使用 toml crate)
// 注意:TOML 验证较为复杂,暂时跳过
}
_ => {}
}
}
let value = if snippet.trim().is_empty() {
None
} else {
Some(snippet)
};
state
.db
.set_config_snippet(&app_type, value)
.map_err(|e| e.to_string())?;
Ok(())
}
/// 提取通用配置片段
///
/// 优先从 `settingsConfig`(编辑器当前内容)提取;若未提供,则从当前激活供应商提取。
///
/// 提取时会自动排除差异化字段(API Key、模型配置、端点等),返回可复用的通用配置片段。
#[tauri::command]
pub async fn extract_common_config_snippet(
appType: String,
settingsConfig: Option<String>,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<String, String> {
let app = AppType::from_str(&appType).map_err(|e| e.to_string())?;
if let Some(settings_config) = settingsConfig.filter(|s| !s.trim().is_empty()) {
let settings: serde_json::Value =
serde_json::from_str(&settings_config).map_err(invalid_json_format_error)?;
return crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(
app,
&settings,
)
.map_err(|e| e.to_string());
}
crate::services::provider::ProviderService::extract_common_config_snippet(&state, app)
.map_err(|e| e.to_string())
}
+82 -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,71 @@ 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 ─────────────────────────────
/// Manually create a database backup
#[tauri::command]
pub async fn create_db_backup(state: State<'_, AppState>) -> Result<String, String> {
let db = state.db.clone();
tauri::async_runtime::spawn_blocking(move || match db.backup_database_file()? {
Some(path) => Ok(path
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_default()),
None => Err(AppError::Config(
"Database file not found, backup skipped".to_string(),
)),
})
.await
.map_err(|e| format!("Backup failed: {e}"))?
.map_err(|e: AppError| e.to_string())
}
/// 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())
}
/// Delete a database backup file
#[tauri::command]
pub fn delete_db_backup(filename: String) -> Result<(), String> {
Database::delete_backup(&filename).map_err(|e| e.to_string())
}
+470 -80
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)
@@ -400,11 +657,13 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
(None, Some("not installed or not executable".to_string()))
}
#[cfg(target_os = "windows")]
fn wsl_distro_for_tool(tool: &str) -> Option<String> {
let override_dir = match tool {
"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,
}?;
@@ -436,12 +695,6 @@ fn wsl_distro_from_path(path: &Path) -> Option<String> {
}
}
/// 非 Windows 平台不支持 WSL 路径解析
#[cfg(not(target_os = "windows"))]
fn wsl_distro_from_path(_path: &Path) -> Option<String> {
None
}
/// 打开指定提供商的终端
///
/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端
@@ -619,6 +872,7 @@ exec bash --norc --noprofile
"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
};
@@ -721,7 +975,7 @@ fn launch_macos_open_app(
let output = cmd
.output()
.map_err(|e| format!("启动 {} 失败: {e}", app_name))?;
.map_err(|e| format!("启动 {app_name} 失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
@@ -921,3 +1175,139 @@ fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), S
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::*;
+73
View File
@@ -0,0 +1,73 @@
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(())
}
// ── 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(())
}
+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
// ============================================================================
+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/");
}
}
+362
View File
@@ -0,0 +1,362 @@
use regex::Regex;
use std::sync::LazyLock;
use tauri::AppHandle;
use tauri_plugin_opener::OpenerExt;
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}"))
}
/// Find the largest index `<= i` that is a valid UTF-8 char boundary.
/// Equivalent to the unstable `str::floor_char_boundary` (stabilized in 1.91).
fn floor_char_boundary(s: &str, mut i: usize) -> usize {
if i >= s.len() {
return s.len();
}
while !s.is_char_boundary(i) {
i -= 1;
}
i
}
/// Find the smallest index `>= i` that is a valid UTF-8 char boundary.
/// Equivalent to the unstable `str::ceil_char_boundary` (stabilized in 1.91).
fn ceil_char_boundary(s: &str, mut i: usize) -> usize {
if i >= s.len() {
return s.len();
}
while !s.is_char_boundary(i) {
i += 1;
}
i
}
/// Search result for daily memory full-text search.
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DailyMemorySearchResult {
pub filename: String,
pub date: String,
pub size_bytes: u64,
pub modified_at: u64,
pub snippet: String,
pub match_count: usize,
}
/// Full-text search across all daily memory files.
///
/// Performs case-insensitive search on both the date field and file content.
/// Returns results sorted by filename descending (newest first), each with a
/// snippet showing ~120 characters of context around the first match.
#[tauri::command]
pub async fn search_daily_memory_files(
query: String,
) -> Result<Vec<DailyMemorySearchResult>, String> {
let memory_dir = get_openclaw_dir().join("workspace").join("memory");
if !memory_dir.exists() || query.is_empty() {
return Ok(Vec::new());
}
let query_lower = query.to_lowercase();
let mut results: Vec<DailyMemorySearchResult> = 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) if m.is_file() => m,
_ => continue,
};
let date = name.trim_end_matches(".md").to_string();
let content = std::fs::read_to_string(entry.path()).unwrap_or_default();
let content_lower = content.to_lowercase();
// Count matches in content
let content_matches: Vec<usize> = content_lower
.match_indices(&query_lower)
.map(|(i, _)| i)
.collect();
// Also check date field
let date_matches = date.to_lowercase().contains(&query_lower);
if content_matches.is_empty() && !date_matches {
continue;
}
// Build snippet around first content match (~120 chars of context)
let snippet = if let Some(&first_pos) = content_matches.first() {
let start = if first_pos > 50 {
floor_char_boundary(&content, first_pos - 50)
} else {
0
};
let end = ceil_char_boundary(&content, (first_pos + 70).min(content.len()));
let mut s = String::new();
if start > 0 {
s.push_str("...");
}
s.push_str(&content[start..end]);
if end < content.len() {
s.push_str("...");
}
s
} else {
// Date-only match — use beginning of file as preview
let end = ceil_char_boundary(&content, 120.min(content.len()));
let mut s = content[..end].to_string();
if end < content.len() {
s.push_str("...");
}
s
};
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);
results.push(DailyMemorySearchResult {
filename: name,
date,
size_bytes,
modified_at,
snippet,
match_count: content_matches.len(),
});
}
// Sort by filename descending (newest date first)
results.sort_by(|a, b| b.filename.cmp(&a.filename));
Ok(results)
}
/// 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}"))
}
/// Open the workspace or memory directory in the system file manager.
/// `subdir`: "workspace" opens `~/.openclaw/workspace/`,
/// "memory" opens `~/.openclaw/workspace/memory/`.
#[tauri::command]
pub async fn open_workspace_directory(handle: AppHandle, subdir: String) -> Result<bool, String> {
let dir = match subdir.as_str() {
"memory" => get_openclaw_dir().join("workspace").join("memory"),
_ => get_openclaw_dir().join("workspace"),
};
if !dir.exists() {
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create directory: {e}"))?;
}
handle
.opener()
.open_path(dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("Failed to open directory: {e}"))?;
Ok(true)
}
+42 -5
View File
@@ -7,16 +7,25 @@ use crate::error::AppError;
/// 获取用户主目录,带回退和日志
///
/// On Windows, respects the `HOME` environment variable (if set) to support
/// test isolation. Falls back to `dirs::home_dir()` otherwise.
/// ## 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 {
#[cfg(windows)]
if let Ok(home) = std::env::var("HOME") {
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(".")
@@ -81,7 +90,35 @@ pub fn get_app_config_dir() -> PathBuf {
if let Some(custom) = crate::app_store::get_app_config_dir_override() {
return custom;
}
get_home_dir().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
}
/// 获取应用配置文件路径
+242 -8
View File
@@ -2,10 +2,10 @@
//!
//! 提供 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;
use chrono::{Local, Utc};
use rusqlite::backup::Backup;
use rusqlite::types::ValueRef;
use rusqlite::Connection;
@@ -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);
@@ -123,7 +182,7 @@ impl Database {
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
let base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
let base_id = format!("db_backup_{}", Local::now().format("%Y%m%d_%H%M%S"));
let mut backup_id = base_id.clone();
let mut backup_path = backup_dir.join(format!("{backup_id}.db"));
let mut counter = 1;
@@ -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,178 @@ 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)
}
/// Delete a backup file permanently.
pub fn delete_backup(filename: &str) -> Result<(), AppError> {
// Validate filename (path traversal + .db suffix)
if filename.contains("..")
|| filename.contains('/')
|| filename.contains('\\')
|| !filename.ends_with(".db")
{
return Err(AppError::InvalidInput(
"Invalid backup filename".to_string(),
));
}
let backup_path = get_app_config_dir().join("backups").join(filename);
if !backup_path.exists() {
return Err(AppError::InvalidInput(format!(
"Backup file not found: {filename}"
)));
}
fs::remove_file(&backup_path).map_err(|e| AppError::io(&backup_path, e))?;
log::info!("Deleted backup: {filename}");
Ok(())
}
}
+153 -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,146 @@ 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()))?;
// OMO ↔ OMO Slim mutually exclusive: deactivate the opposite category
let opposite = match category {
"omo" => Some("omo-slim"),
"omo-slim" => Some("omo"),
_ => None,
};
if let Some(opp) = opposite {
tx.execute(
"UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = ?2",
params![app_type, opp],
)
.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,
}))
}
}
+14 -2
View File
@@ -876,7 +876,13 @@ mod tests {
.await
.unwrap_err();
// AppError::localized returns AppError::Localized variant
assert!(matches!(err, AppError::Localized { key: "error.invalidMultiplier", .. }));
assert!(matches!(
err,
AppError::Localized {
key: "error.invalidMultiplier",
..
}
));
Ok(())
}
@@ -897,7 +903,13 @@ mod tests {
.await
.unwrap_err();
// AppError::localized returns AppError::Localized variant
assert!(matches!(err, AppError::Localized { key: "error.invalidPricingMode", .. }));
assert!(matches!(
err,
AppError::Localized {
key: "error.invalidPricingMode",
..
}
));
Ok(())
}
+1 -26
View File
@@ -38,31 +38,6 @@ impl Database {
Ok(())
}
// --- Config Snippets 辅助方法 ---
/// 获取通用配置片段
pub fn get_config_snippet(&self, app_type: &str) -> Result<Option<String>, AppError> {
self.get_setting(&format!("common_config_{app_type}"))
}
/// 设置通用配置片段
pub fn set_config_snippet(
&self,
app_type: &str,
snippet: Option<String>,
) -> Result<(), AppError> {
let key = format!("common_config_{app_type}");
if let Some(value) = snippet {
self.set_setting(&key, &value)
} else {
// 如果为 None 则删除
let conn = lock_conn!(self.conn);
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
}
// --- 全局出站代理 ---
/// 全局代理 URL 的存储键名
@@ -168,7 +143,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(())
}
}
+31 -5
View File
@@ -23,7 +23,7 @@
//! └── settings.rs
//! ```
mod backup;
pub(crate) mod backup;
mod dao;
mod migration;
mod schema;
@@ -36,15 +36,12 @@ pub use dao::FailoverQueueItem;
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 = 5;
@@ -75,6 +72,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 +100,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 +135,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),
+47 -10
View File
@@ -919,7 +919,16 @@ impl Database {
/// 注意: 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",
@@ -1025,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"),
@@ -1212,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)",
@@ -1239,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)
}
// --- 辅助方法 ---
+9 -4
View File
@@ -297,6 +297,15 @@ fn schema_migration_v4_adds_pricing_model_columns() {
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");
@@ -504,8 +513,6 @@ fn schema_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
@@ -554,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
+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("未配置供应商")]
-9
View File
@@ -1,4 +1,3 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::fs;
use std::path::{Path, PathBuf};
@@ -7,14 +6,6 @@ use crate::config::atomic_write;
use crate::error::AppError;
use crate::gemini_config::get_gemini_settings_path;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpStatus {
pub user_config_path: String,
pub user_config_exists: bool,
pub server_count: usize,
}
/// 获取 Gemini MCP 配置文件路径(~/.gemini/settings.json
fn user_config_path() -> PathBuf {
get_gemini_settings_path()
+154 -52
View File
@@ -13,6 +13,7 @@ mod gemini_config;
mod gemini_mcp;
mod init_status;
mod mcp;
mod openclaw_config;
mod opencode_config;
mod panic_hook;
mod prompt;
@@ -21,6 +22,7 @@ mod provider;
mod provider_defaults;
mod proxy;
mod services;
mod session_manager;
mod settings;
mod store;
mod tray;
@@ -446,52 +448,7 @@ pub fn run() {
Err(e) => log::warn!("✗ Failed to read skills migration flag: {e}"),
}
// 2. 导入供应商配置(已有内置检查:该应用已有供应商则跳过
for app in [
crate::app_config::AppType::Claude,
crate::app_config::AppType::Codex,
crate::app_config::AppType::Gemini,
] {
match crate::services::provider::ProviderService::import_default_config(
&app_state,
app.clone(),
) {
Ok(true) => {
log::info!("✓ Imported default provider for {}", app.as_str());
// 首次运行:自动提取通用配置片段(仅当通用配置为空时)
if app_state
.db
.get_config_snippet(app.as_str())
.ok()
.flatten()
.is_none()
{
match crate::services::provider::ProviderService::extract_common_config_snippet(&app_state, app.clone()) {
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
if let Err(e) = app_state.db.set_config_snippet(app.as_str(), Some(snippet)) {
log::warn!("✗ Failed to save common config snippet for {}: {e}", app.as_str());
} else {
log::info!("✓ Extracted common config snippet for {}", app.as_str());
}
}
Ok(_) => log::debug!("○ No common config to extract for {}", app.as_str()),
Err(e) => log::debug!("○ Failed to extract common config for {}: {e}", app.as_str()),
}
}
}
Ok(false) => {} // 已有供应商,静默跳过
Err(e) => {
log::debug!(
"○ No default provider to import for {}: {}",
app.as_str(),
e
);
}
}
}
// 2.1 OpenCode 供应商导入(累加式模式,需特殊处理)
// 2. OpenCode 供应商导入(累加式模式,需特殊处理
// OpenCode 与其他应用不同:配置文件中可同时存在多个供应商
// 需要遍历 provider 字段下的每个供应商并导入
match crate::services::provider::import_opencode_providers_from_live(&app_state) {
@@ -499,7 +456,69 @@ pub fn run() {
log::info!("✓ Imported {count} OpenCode provider(s) from live config");
}
Ok(_) => log::debug!("○ No OpenCode providers found to import"),
Err(e) => log::debug!("○ Failed to import OpenCode providers: {e}"),
Err(e) => log::warn!("○ Failed to import OpenCode providers: {e}"),
}
// 2.2 OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入)
{
let has_omo = app_state
.db
.get_all_providers("opencode")
.map(|providers| providers.values().any(|p| p.category.as_deref() == Some("omo")))
.unwrap_or(false);
if !has_omo {
match crate::services::OmoService::import_from_local(&app_state, &crate::services::omo::STANDARD) {
Ok(provider) => {
log::info!("✓ Imported OMO config from local as provider '{}'", provider.name);
}
Err(AppError::OmoConfigNotFound) => {
log::debug!("○ No OMO config to import");
}
Err(e) => {
log::warn!("✗ Failed to import OMO config from local: {e}");
}
}
}
}
// 2.3 OMO Slim config import (when no omo-slim provider in DB, import from local)
{
let has_omo_slim = app_state
.db
.get_all_providers("opencode")
.map(|providers| {
providers
.values()
.any(|p| p.category.as_deref() == Some("omo-slim"))
})
.unwrap_or(false);
if !has_omo_slim {
match crate::services::OmoService::import_from_local(&app_state, &crate::services::omo::SLIM) {
Ok(provider) => {
log::info!(
"✓ Imported OMO Slim config from local as provider '{}'",
provider.name
);
}
Err(AppError::OmoConfigNotFound) => {
log::debug!("○ No OMO Slim config to import");
}
Err(e) => {
log::warn!("✗ Failed to import OMO Slim config from local: {e}");
}
}
}
}
// 2.4 OpenClaw 供应商导入(累加式模式,需特殊处理)
// OpenClaw 与 OpenCode 类似:配置文件中可同时存在多个供应商
// 需要遍历 models.providers 字段下的每个供应商并导入
match crate::services::provider::import_openclaw_providers_from_live(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} OpenClaw provider(s) from live config");
}
Ok(_) => log::debug!("○ No OpenClaw providers found to import"),
Err(e) => log::warn!("○ Failed to import OpenClaw providers: {e}"),
}
// 3. 导入 MCP 服务器配置(表空时触发)
@@ -547,6 +566,8 @@ pub fn run() {
crate::app_config::AppType::Claude,
crate::app_config::AppType::Codex,
crate::app_config::AppType::Gemini,
crate::app_config::AppType::OpenCode,
crate::app_config::AppType::OpenClaw,
] {
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
&app_state,
@@ -665,6 +686,10 @@ pub fn run() {
}
let _tray = tray_builder.build(app)?;
crate::services::webdav_auto_sync::start_worker(
app_state.db.clone(),
app.handle().clone(),
);
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
app.manage(app_state);
@@ -743,8 +768,42 @@ pub fn run() {
// 检查 settings 表中的代理状态,自动恢复代理服务
restore_proxy_state_on_startup(&state).await;
// Periodic backup check (on startup)
if let Err(e) = state.db.periodic_backup_if_needed() {
log::warn!("Periodic backup failed on startup: {e}");
}
// Periodic backup timer: check every hour while the app is running
let db_for_timer = state.db.clone();
tauri::async_runtime::spawn(async move {
let mut interval =
tokio::time::interval(std::time::Duration::from_secs(3600));
interval.tick().await; // skip immediate first tick (already checked above)
loop {
interval.tick().await;
if let Err(e) = db_for_timer.periodic_backup_if_needed() {
log::warn!("Periodic backup timer failed: {e}");
}
}
});
});
// Linux: 禁用 WebKitGTK 硬件加速,防止 EGL 初始化失败导致白屏
#[cfg(target_os = "linux")]
{
if let Some(window) = app.get_webview_window("main") {
let _ = window.with_webview(|webview| {
use webkit2gtk::{WebViewExt, SettingsExt, HardwareAccelerationPolicy};
let wk_webview = webview.inner();
if let Some(settings) = WebViewExt::settings(&wk_webview) {
SettingsExt::set_hardware_acceleration_policy(&settings, HardwareAccelerationPolicy::Never);
log::info!("已禁用 WebKitGTK 硬件加速");
}
});
}
}
// 静默启动:根据设置决定是否显示主窗口
let settings = crate::settings::get_settings();
if let Some(window) = app.get_webview_window("main") {
@@ -786,12 +845,8 @@ pub fn run() {
commands::get_skills_migration_result,
commands::get_app_config_path,
commands::open_app_config_folder,
commands::get_claude_common_config_snippet,
commands::set_claude_common_config_snippet,
commands::get_common_config_snippet,
commands::set_common_config_snippet,
commands::extract_common_config_snippet,
commands::read_live_provider_settings,
commands::patch_claude_live_settings,
commands::get_settings,
commands::save_settings,
commands::get_rectifier_config,
@@ -848,8 +903,19 @@ pub fn run() {
// theirs: config import/export and dialogs
commands::export_config_to_file,
commands::import_config_from_file,
commands::webdav_test_connection,
commands::webdav_sync_upload,
commands::webdav_sync_download,
commands::webdav_sync_save_settings,
commands::webdav_sync_fetch_remote_info,
commands::save_file_dialog,
commands::open_file_dialog,
commands::open_zip_file_dialog,
commands::create_db_backup,
commands::list_db_backups,
commands::restore_db_backup,
commands::rename_db_backup,
commands::delete_db_backup,
commands::sync_current_providers_live,
// Deep link import
commands::parse_deeplink,
@@ -879,6 +945,7 @@ pub fn run() {
commands::get_skill_repos,
commands::add_skill_repo,
commands::remove_skill_repo,
commands::install_skills_from_zip,
// Auto launch
commands::set_auto_launch,
commands::get_auto_launch_status,
@@ -931,6 +998,10 @@ pub fn run() {
commands::stream_check_all_providers,
commands::get_stream_check_config,
commands::save_stream_check_config,
// Session manager
commands::list_sessions,
commands::get_session_messages,
commands::launch_session_terminal,
commands::get_tool_versions,
// Provider terminal
commands::open_provider_terminal,
@@ -943,12 +1014,43 @@ pub fn run() {
// OpenCode specific
commands::import_opencode_providers_from_live,
commands::get_opencode_live_provider_ids,
// OpenClaw specific
commands::import_openclaw_providers_from_live,
commands::get_openclaw_live_provider_ids,
commands::get_openclaw_default_model,
commands::set_openclaw_default_model,
commands::get_openclaw_model_catalog,
commands::set_openclaw_model_catalog,
commands::get_openclaw_agents_defaults,
commands::set_openclaw_agents_defaults,
commands::get_openclaw_env,
commands::set_openclaw_env,
commands::get_openclaw_tools,
commands::set_openclaw_tools,
// Global upstream proxy
commands::get_global_proxy_url,
commands::set_global_proxy_url,
commands::test_proxy_url,
commands::get_upstream_proxy_status,
commands::scan_local_proxies,
// Window theme control
commands::set_window_theme,
commands::read_omo_local_file,
commands::get_current_omo_provider_id,
commands::disable_current_omo,
commands::read_omo_slim_local_file,
commands::get_current_omo_slim_provider_id,
commands::disable_current_omo_slim,
// Workspace files (OpenClaw)
commands::read_workspace_file,
commands::write_workspace_file,
// Daily memory files (OpenClaw workspace)
commands::list_daily_memory_files,
commands::read_daily_memory_file,
commands::write_daily_memory_file,
commands::delete_daily_memory_file,
commands::search_daily_memory_files,
commands::open_workspace_directory,
]);
let app = builder
+546
View File
@@ -0,0 +1,546 @@
//! OpenClaw 配置文件读写模块
//!
//! 处理 `~/.openclaw/openclaw.json` 配置文件的读写操作(JSON5 格式)。
//! OpenClaw 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。
//!
//! ## 配置文件格式
//!
//! ```json5
//! {
//! // 模型供应商配置(映射为 CC Switch 的"供应商"
//! models: {
//! mode: "merge",
//! providers: {
//! "custom-provider": {
//! baseUrl: "https://api.example.com/v1",
//! apiKey: "${API_KEY}",
//! api: "openai-completions",
//! models: [{ id: "model-id", name: "Model Name" }]
//! }
//! }
//! },
//! // 环境变量配置
//! env: {
//! ANTHROPIC_API_KEY: "sk-...",
//! vars: { ... }
//! },
//! // Agent 默认模型配置
//! agents: {
//! defaults: {
//! model: {
//! primary: "provider/model",
//! fallbacks: ["provider2/model2"]
//! }
//! }
//! }
//! }
//! ```
use crate::config::write_json_file;
use crate::error::AppError;
use crate::settings::get_openclaw_override_dir;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::collections::HashMap;
use std::path::PathBuf;
// ============================================================================
// Path Functions
// ============================================================================
/// 获取 OpenClaw 配置目录
///
/// 默认路径: `~/.openclaw/`
/// 可通过 settings.openclaw_config_dir 覆盖
pub fn get_openclaw_dir() -> PathBuf {
if let Some(override_dir) = get_openclaw_override_dir() {
return override_dir;
}
// 所有平台统一使用 ~/.openclaw
dirs::home_dir()
.map(|h| h.join(".openclaw"))
.unwrap_or_else(|| PathBuf::from(".openclaw"))
}
/// 获取 OpenClaw 配置文件路径
///
/// 返回 `~/.openclaw/openclaw.json`
pub fn get_openclaw_config_path() -> PathBuf {
get_openclaw_dir().join("openclaw.json")
}
// ============================================================================
// Type Definitions
// ============================================================================
/// OpenClaw 供应商配置(对应 models.providers 中的条目)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenClawProviderConfig {
/// API 基础 URL
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
/// API Key(支持环境变量引用 ${VAR_NAME}
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
/// API 类型(如 "openai-completions", "anthropic" 等)
#[serde(skip_serializing_if = "Option::is_none")]
pub api: Option<String>,
/// 支持的模型列表
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<OpenClawModelEntry>,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
/// OpenClaw 模型条目
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenClawModelEntry {
/// 模型 ID
pub id: String,
/// 模型显示名称
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// 模型别名(用于快捷引用)
#[serde(skip_serializing_if = "Option::is_none")]
pub alias: Option<String>,
/// 模型成本(输入/输出价格)
#[serde(skip_serializing_if = "Option::is_none")]
pub cost: Option<OpenClawModelCost>,
/// 上下文窗口大小
#[serde(skip_serializing_if = "Option::is_none")]
pub context_window: Option<u32>,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
/// OpenClaw 模型成本配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawModelCost {
/// 输入价格(每百万 token)
pub input: f64,
/// 输出价格(每百万 token)
pub output: f64,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
/// OpenClaw 默认模型配置(agents.defaults.model
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawDefaultModel {
/// 主模型 ID(格式:provider/model
pub primary: String,
/// 回退模型列表
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fallbacks: Vec<String>,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
/// OpenClaw 模型目录条目(agents.defaults.models 中的值)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawModelCatalogEntry {
/// 模型别名(用于 UI 显示)
#[serde(skip_serializing_if = "Option::is_none")]
pub alias: Option<String>,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
/// OpenClaw agents.defaults 配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawAgentsDefaults {
/// 默认模型配置
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<OpenClawDefaultModel>,
/// 模型目录/允许列表(键为 provider/model 格式)
#[serde(skip_serializing_if = "Option::is_none")]
pub models: Option<HashMap<String, OpenClawModelCatalogEntry>>,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
/// OpenClaw agents 顶层配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct OpenClawAgents {
/// 默认配置
#[serde(skip_serializing_if = "Option::is_none")]
pub defaults: Option<OpenClawAgentsDefaults>,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
// ============================================================================
// Core Read/Write Functions
// ============================================================================
/// 读取 OpenClaw 配置文件
///
/// 支持 JSON5 格式,返回完整的配置 JSON 对象
pub fn read_openclaw_config() -> Result<Value, AppError> {
let path = get_openclaw_config_path();
if !path.exists() {
// Return empty config structure
return Ok(json!({
"models": {
"mode": "merge",
"providers": {}
}
}));
}
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
// 尝试 JSON5 解析(支持注释和尾随逗号)
json5::from_str(&content)
.map_err(|e| AppError::Config(format!("Failed to parse OpenClaw config as JSON5: {}", e)))
}
/// 写入 OpenClaw 配置文件(原子写入)
///
/// 使用标准 JSON 格式写入(JSON5 是 JSON 的超集)
pub fn write_openclaw_config(config: &Value) -> Result<(), AppError> {
let path = get_openclaw_config_path();
// 确保目录存在
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
// 复用统一的原子写入逻辑
write_json_file(&path, config)?;
log::debug!("OpenClaw config written to {path:?}");
Ok(())
}
// ============================================================================
// Provider Functions (Untyped - for raw JSON operations)
// ============================================================================
/// 获取所有供应商配置(原始 JSON)
///
/// 从 `models.providers` 读取
pub fn get_providers() -> Result<Map<String, Value>, AppError> {
let config = read_openclaw_config()?;
Ok(config
.get("models")
.and_then(|m| m.get("providers"))
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default())
}
/// 设置供应商配置(原始 JSON)
///
/// 写入到 `models.providers`
pub fn set_provider(id: &str, provider_config: Value) -> Result<(), AppError> {
let mut full_config = read_openclaw_config()?;
// 确保 models 结构存在
if full_config.get("models").is_none() {
full_config["models"] = json!({
"mode": "merge",
"providers": {}
});
}
// 确保 providers 对象存在
if full_config["models"].get("providers").is_none() {
full_config["models"]["providers"] = json!({});
}
// 设置供应商
if let Some(providers) = full_config["models"]
.get_mut("providers")
.and_then(|v| v.as_object_mut())
{
providers.insert(id.to_string(), provider_config);
}
write_openclaw_config(&full_config)
}
/// 删除供应商配置
pub fn remove_provider(id: &str) -> Result<(), AppError> {
let mut config = read_openclaw_config()?;
if let Some(providers) = config
.get_mut("models")
.and_then(|m| m.get_mut("providers"))
.and_then(|v| v.as_object_mut())
{
providers.remove(id);
}
write_openclaw_config(&config)
}
// ============================================================================
// Provider Functions (Typed)
// ============================================================================
/// 获取所有供应商配置(类型化)
pub fn get_typed_providers() -> Result<IndexMap<String, OpenClawProviderConfig>, AppError> {
let providers = get_providers()?;
let mut result = IndexMap::new();
for (id, value) in providers {
match serde_json::from_value::<OpenClawProviderConfig>(value.clone()) {
Ok(config) => {
result.insert(id, config);
}
Err(e) => {
log::warn!("Failed to parse OpenClaw provider '{id}': {e}");
// Skip invalid providers but continue
}
}
}
Ok(result)
}
/// 设置供应商配置(类型化)
pub fn set_typed_provider(id: &str, config: &OpenClawProviderConfig) -> Result<(), AppError> {
let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?;
set_provider(id, value)
}
// ============================================================================
// Agents Configuration Functions
// ============================================================================
/// 读取默认模型配置(agents.defaults.model
pub fn get_default_model() -> Result<Option<OpenClawDefaultModel>, AppError> {
let config = read_openclaw_config()?;
let Some(model_value) = config
.get("agents")
.and_then(|a| a.get("defaults"))
.and_then(|d| d.get("model"))
else {
return Ok(None);
};
let model = serde_json::from_value(model_value.clone())
.map_err(|e| AppError::Config(format!("Failed to parse agents.defaults.model: {e}")))?;
Ok(Some(model))
}
/// 设置默认模型配置(agents.defaults.model
pub fn set_default_model(model: &OpenClawDefaultModel) -> Result<(), AppError> {
let mut config = read_openclaw_config()?;
// Ensure agents.defaults path exists, preserving unknown fields
ensure_agents_defaults_path(&mut config);
let model_value =
serde_json::to_value(model).map_err(|e| AppError::JsonSerialize { source: e })?;
config["agents"]["defaults"]["model"] = model_value;
write_openclaw_config(&config)
}
/// 读取模型目录/允许列表(agents.defaults.models
pub fn get_model_catalog() -> Result<Option<HashMap<String, OpenClawModelCatalogEntry>>, AppError> {
let config = read_openclaw_config()?;
let Some(models_value) = config
.get("agents")
.and_then(|a| a.get("defaults"))
.and_then(|d| d.get("models"))
else {
return Ok(None);
};
let catalog = serde_json::from_value(models_value.clone())
.map_err(|e| AppError::Config(format!("Failed to parse agents.defaults.models: {e}")))?;
Ok(Some(catalog))
}
/// 设置模型目录/允许列表(agents.defaults.models
pub fn set_model_catalog(
catalog: &HashMap<String, OpenClawModelCatalogEntry>,
) -> Result<(), AppError> {
let mut config = read_openclaw_config()?;
// Ensure agents.defaults path exists, preserving unknown fields
ensure_agents_defaults_path(&mut config);
let catalog_value =
serde_json::to_value(catalog).map_err(|e| AppError::JsonSerialize { source: e })?;
config["agents"]["defaults"]["models"] = catalog_value;
write_openclaw_config(&config)
}
/// Ensure the `agents.defaults` path exists in the config,
/// preserving any existing unknown fields.
fn ensure_agents_defaults_path(config: &mut Value) {
if config.get("agents").is_none() {
config["agents"] = json!({});
}
if config["agents"].get("defaults").is_none() {
config["agents"]["defaults"] = json!({});
}
}
// ============================================================================
// Full Agents Defaults Functions
// ============================================================================
/// Read the full agents.defaults config
pub fn get_agents_defaults() -> Result<Option<OpenClawAgentsDefaults>, AppError> {
let config = read_openclaw_config()?;
let Some(defaults_value) = config.get("agents").and_then(|a| a.get("defaults")) else {
return Ok(None);
};
let defaults = serde_json::from_value(defaults_value.clone())
.map_err(|e| AppError::Config(format!("Failed to parse agents.defaults: {e}")))?;
Ok(Some(defaults))
}
/// Write the full agents.defaults config
pub fn set_agents_defaults(defaults: &OpenClawAgentsDefaults) -> Result<(), AppError> {
let mut config = read_openclaw_config()?;
if config.get("agents").is_none() {
config["agents"] = json!({});
}
let value =
serde_json::to_value(defaults).map_err(|e| AppError::JsonSerialize { source: e })?;
config["agents"]["defaults"] = value;
write_openclaw_config(&config)
}
// ============================================================================
// Env Configuration
// ============================================================================
/// OpenClaw env configuration (env section of openclaw.json)
///
/// Stores environment variables like API keys and custom vars.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawEnvConfig {
/// All environment variable key-value pairs
#[serde(flatten)]
pub vars: HashMap<String, Value>,
}
/// Read the env config section
pub fn get_env_config() -> Result<OpenClawEnvConfig, AppError> {
let config = read_openclaw_config()?;
let Some(env_value) = config.get("env") else {
return Ok(OpenClawEnvConfig {
vars: HashMap::new(),
});
};
serde_json::from_value(env_value.clone())
.map_err(|e| AppError::Config(format!("Failed to parse env config: {e}")))
}
/// Write the env config section
pub fn set_env_config(env: &OpenClawEnvConfig) -> Result<(), AppError> {
let mut config = read_openclaw_config()?;
let value = serde_json::to_value(env).map_err(|e| AppError::JsonSerialize { source: e })?;
config["env"] = value;
write_openclaw_config(&config)
}
// ============================================================================
// Tools Configuration
// ============================================================================
/// OpenClaw tools configuration (tools section of openclaw.json)
///
/// Controls tool permissions with profile-based allow/deny lists.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawToolsConfig {
/// Active permission profile (e.g. "default", "strict", "permissive")
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
/// Allowed tool patterns
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allow: Vec<String>,
/// Denied tool patterns
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deny: Vec<String>,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
/// Read the tools config section
pub fn get_tools_config() -> Result<OpenClawToolsConfig, AppError> {
let config = read_openclaw_config()?;
let Some(tools_value) = config.get("tools") else {
return Ok(OpenClawToolsConfig {
profile: None,
allow: Vec::new(),
deny: Vec::new(),
extra: HashMap::new(),
});
};
serde_json::from_value(tools_value.clone())
.map_err(|e| AppError::Config(format!("Failed to parse tools config: {e}")))
}
/// Write the tools config section
pub fn set_tools_config(tools: &OpenClawToolsConfig) -> Result<(), AppError> {
let mut config = read_openclaw_config()?;
let value = serde_json::to_value(tools).map_err(|e| AppError::JsonSerialize { source: e })?;
config["tools"] = value;
write_openclaw_config(&config)
}
+65 -71
View File
@@ -1,26 +1,3 @@
//! OpenCode 配置文件读写模块
//!
//! 处理 `~/.config/opencode/opencode.json` 配置文件的读写操作。
//! OpenCode 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。
//!
//! ## 配置文件格式
//!
//! ```json
//! {
//! "$schema": "https://opencode.ai/config.json",
//! "provider": {
//! "my-provider": {
//! "npm": "@ai-sdk/openai-compatible",
//! "options": { "baseURL": "...", "apiKey": "{env:API_KEY}" },
//! "models": { "gpt-4o": { "name": "GPT-4o" } }
//! }
//! },
//! "mcp": {
//! "my-server": { "type": "local", "command": ["..."] }
//! }
//! }
//! ```
use crate::config::write_json_file;
use crate::error::AppError;
use crate::provider::OpenCodeProviderConfig;
@@ -29,52 +6,29 @@ use indexmap::IndexMap;
use serde_json::{json, Map, Value};
use std::path::PathBuf;
// ============================================================================
// Path Functions
// ============================================================================
/// 获取 OpenCode 配置目录
///
/// 默认路径: `~/.config/opencode/`
/// 可通过 settings.opencode_config_dir 覆盖
pub fn get_opencode_dir() -> PathBuf {
if let Some(override_dir) = get_opencode_override_dir() {
return override_dir;
}
// 所有平台统一使用 ~/.config/opencode
dirs::home_dir()
.map(|h| h.join(".config").join("opencode"))
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
}
/// 获取 OpenCode 配置文件路径
///
/// 返回 `~/.config/opencode/opencode.json`
pub fn get_opencode_config_path() -> PathBuf {
get_opencode_dir().join("opencode.json")
}
/// 获取 OpenCode 环境变量文件路径(如果存在)
///
/// 返回 `~/.config/opencode/.env`
#[allow(dead_code)]
pub fn get_opencode_env_path() -> PathBuf {
get_opencode_dir().join(".env")
}
// ============================================================================
// Core Read/Write Functions
// ============================================================================
/// 读取 OpenCode 配置文件
///
/// 返回完整的配置 JSON 对象
pub fn read_opencode_config() -> Result<Value, AppError> {
let path = get_opencode_config_path();
if !path.exists() {
// Return empty config with schema
return Ok(json!({
"$schema": "https://opencode.ai/config.json"
}));
@@ -84,23 +38,14 @@ pub fn read_opencode_config() -> Result<Value, AppError> {
serde_json::from_str(&content).map_err(|e| AppError::json(&path, e))
}
/// 写入 OpenCode 配置文件(原子写入)
///
/// 使用临时文件 + 重命名确保原子性
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
let path = get_opencode_config_path();
// 复用统一的原子写入逻辑(兼容 Windows 上目标文件已存在的情况)
write_json_file(&path, config)?;
log::debug!("OpenCode config written to {path:?}");
Ok(())
}
// ============================================================================
// Provider Functions (Untyped - for raw JSON operations)
// ============================================================================
/// 获取所有供应商配置(原始 JSON)
pub fn get_providers() -> Result<Map<String, Value>, AppError> {
let config = read_opencode_config()?;
Ok(config
@@ -110,7 +55,6 @@ pub fn get_providers() -> Result<Map<String, Value>, AppError> {
.unwrap_or_default())
}
/// 设置供应商配置(原始 JSON)
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
let mut full_config = read_opencode_config()?;
@@ -128,7 +72,6 @@ pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
write_opencode_config(&full_config)
}
/// 删除供应商配置
pub fn remove_provider(id: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
@@ -139,11 +82,6 @@ pub fn remove_provider(id: &str) -> Result<(), AppError> {
write_opencode_config(&config)
}
// ============================================================================
// Provider Functions (Typed - using OpenCodeProviderConfig)
// ============================================================================
/// 获取所有供应商配置(类型化)
pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>, AppError> {
let providers = get_providers()?;
let mut result = IndexMap::new();
@@ -155,7 +93,6 @@ pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>,
}
Err(e) => {
log::warn!("Failed to parse provider '{id}': {e}");
// Skip invalid providers but continue
}
}
}
@@ -163,17 +100,11 @@ pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>,
Ok(result)
}
/// 设置供应商配置(类型化)
pub fn set_typed_provider(id: &str, config: &OpenCodeProviderConfig) -> Result<(), AppError> {
let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?;
set_provider(id, value)
}
// ============================================================================
// MCP Functions
// ============================================================================
/// 获取所有 MCP 服务器配置
pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
let config = read_opencode_config()?;
Ok(config
@@ -183,7 +114,6 @@ pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
.unwrap_or_default())
}
/// 设置 MCP 服务器配置
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
let mut full_config = read_opencode_config()?;
@@ -198,7 +128,6 @@ pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
write_opencode_config(&full_config)
}
/// 删除 MCP 服务器配置
pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
@@ -208,3 +137,68 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
write_opencode_config(&config)
}
pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
let plugins = config.get_mut("plugin").and_then(|v| v.as_array_mut());
match plugins {
Some(arr) => {
// Mutual exclusion: standard OMO and OMO Slim cannot coexist as plugins
if plugin_name.starts_with("oh-my-opencode")
&& !plugin_name.starts_with("oh-my-opencode-slim")
{
// Adding standard OMO -> remove all Slim variants
arr.retain(|v| {
v.as_str()
.map(|s| !s.starts_with("oh-my-opencode-slim"))
.unwrap_or(true)
});
} else if plugin_name.starts_with("oh-my-opencode-slim") {
// Adding Slim -> remove all standard OMO variants (but keep slim)
arr.retain(|v| {
v.as_str()
.map(|s| {
!s.starts_with("oh-my-opencode") || s.starts_with("oh-my-opencode-slim")
})
.unwrap_or(true)
});
}
let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name));
if !already_exists {
arr.push(Value::String(plugin_name.to_string()));
}
}
None => {
config["plugin"] = json!([plugin_name]);
}
}
write_opencode_config(&config)
}
pub fn remove_plugin_by_prefix(prefix: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) {
arr.retain(|v| {
v.as_str()
.map(|s| {
if !s.starts_with(prefix) {
return true; // Keep: doesn't match prefix at all
}
let rest = &s[prefix.len()..];
rest.starts_with('-')
})
.unwrap_or(true)
});
if arr.is_empty() {
config.as_object_mut().map(|obj| obj.remove("plugin"));
}
}
write_opencode_config(&config)
}
+3
View File
@@ -5,6 +5,7 @@ use crate::codex_config::get_codex_auth_path;
use crate::config::get_claude_settings_path;
use crate::error::AppError;
use crate::gemini_config::get_gemini_dir;
use crate::openclaw_config::get_openclaw_dir;
use crate::opencode_config::get_opencode_dir;
/// 返回指定应用所使用的提示词文件路径。
@@ -14,6 +15,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
AppType::Gemini => get_gemini_dir(),
AppType::OpenCode => get_opencode_dir(),
AppType::OpenClaw => get_openclaw_dir(),
};
let filename = match app {
@@ -21,6 +23,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
AppType::Codex => "AGENTS.md",
AppType::Gemini => "GEMINI.md",
AppType::OpenCode => "AGENTS.md",
AppType::OpenClaw => "AGENTS.md", // OpenClaw uses AGENTS.md for agent instructions
};
Ok(base_dir.join(filename))
+64 -4
View File
@@ -230,6 +230,16 @@ pub struct ProviderMeta {
/// 供应商单独的代理配置
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
pub proxy_config: Option<ProviderProxyConfig>,
/// Claude API 格式(仅 Claude 供应商使用)
/// - "anthropic": 原生 Anthropic Messages API,直接透传
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
pub api_format: Option<String>,
/// Claude 认证字段名(仅 Claude 供应商使用)
/// - "ANTHROPIC_AUTH_TOKEN" (默认): 大多数第三方/聚合供应商
/// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key
#[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")]
pub api_key_field: Option<String>,
}
impl ProviderManager {
@@ -441,11 +451,18 @@ impl UniversalProvider {
.and_then(|m| m.reasoning_effort.clone())
.unwrap_or_else(|| "high".to_string());
// 确保 base_url 以 /v1 结尾(Codex 使用 OpenAI 兼容 API
let codex_base_url = if self.base_url.ends_with("/v1") {
self.base_url.clone()
// Codex/OpenAI 的 base_url 既可能是纯 origin(需要补 /v1),也可能包含自定义前缀(不应强行补版本
let base_trimmed = self.base_url.trim_end_matches('/');
let origin_only = match base_trimmed.split_once("://") {
Some((_scheme, rest)) => !rest.contains('/'),
None => !base_trimmed.contains('/'),
};
let codex_base_url = if base_trimmed.ends_with("/v1") {
base_trimmed.to_string()
} else if origin_only {
format!("{base_trimmed}/v1")
} else {
format!("{}/v1", self.base_url.trim_end_matches('/'))
base_trimmed.to_string()
};
// 生成 Codex 的 config.toml 内容
@@ -880,4 +897,47 @@ mod tests {
assert!(config.options.headers.is_none());
assert!(config.options.extra.is_empty());
}
#[test]
fn universal_codex_provider_origin_base_url_adds_v1() {
let mut p = UniversalProvider::new(
"id".to_string(),
"Test".to_string(),
"custom".to_string(),
"https://api.openai.com".to_string(),
"sk-test".to_string(),
);
p.apps.codex = true;
let provider = p.to_codex_provider().expect("should build codex provider");
let toml = provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.expect("config should be a toml string");
assert!(toml.contains("base_url = \"https://api.openai.com/v1\""));
}
#[test]
fn universal_codex_provider_custom_prefix_does_not_force_v1() {
let mut p = UniversalProvider::new(
"id".to_string(),
"Test".to_string(),
"custom".to_string(),
"https://example.com/openai".to_string(),
"sk-test".to_string(),
);
p.apps.codex = true;
let provider = p.to_codex_provider().expect("should build codex provider");
let toml = provider
.settings_config
.get("config")
.and_then(|v| v.as_str())
.expect("config should be a toml string");
assert!(toml.contains("base_url = \"https://example.com/openai\""));
assert!(!toml.contains("https://example.com/openai/v1"));
}
}
+218 -23
View File
@@ -8,7 +8,10 @@ use super::{
failover_switch::FailoverSwitchManager,
provider_router::ProviderRouter,
providers::{get_adapter, ProviderAdapter, ProviderType},
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
thinking_budget_rectifier::{rectify_thinking_budget, should_rectify_thinking_budget},
thinking_rectifier::{
normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,
},
types::{ProxyStatus, RectifierConfig},
ProxyError,
};
@@ -157,6 +160,7 @@ impl RequestForwarder {
// 整流器重试标记:确保整流最多触发一次
let mut rectifier_retried = false;
let mut budget_rectifier_retried = false;
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
let bypass_circuit_breaker = providers.len() == 1;
@@ -258,6 +262,7 @@ impl RequestForwarder {
provider_type,
ProviderType::Claude | ProviderType::ClaudeAuth
);
let mut signature_rectifier_non_retryable_client_error = false;
if is_anthropic_provider {
let error_message = extract_error_message(&e);
@@ -293,12 +298,185 @@ impl RequestForwarder {
// 首次触发:整流请求体
let rectified = rectify_anthropic_request(&mut body);
// 整流未生效:直接返回错误(不可重试客户端错误)
// 整流未生效:继续尝试 budget 整流路径,避免误判后短路
if !rectified.applied {
log::warn!(
"[{app_type_str}] [RECT-006] 整流器触发但无可整流内容,不做无意义重试"
"[{app_type_str}] [RECT-006] thinking 签名整流器触发但无可整流内容,继续检查 budget;若 budget 也未命中则按客户端错误返回"
);
signature_rectifier_non_retryable_client_error = true;
} else {
log::info!(
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
app_type_str,
rectified.removed_thinking_blocks,
rectified.removed_redacted_thinking_blocks,
rectified.removed_signature_fields
);
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
let _ = std::mem::replace(&mut rectifier_retried, true);
// 使用同一供应商重试(不计入熔断器)
match self
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
.await
{
Ok(response) => {
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
// 记录成功
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
true,
None,
)
.await;
// 更新当前应用类型使用的 provider
{
let mut current_providers =
self.current_providers.write().await;
current_providers.insert(
app_type_str.to_string(),
(provider.id.clone(), provider.name.clone()),
);
}
// 更新成功统计
{
let mut status = self.status.write().await;
status.success_requests += 1;
status.last_error = None;
let should_switch =
self.current_provider_id_at_start.as_str()
!= provider.id.as_str();
if should_switch {
status.failover_count += 1;
// 异步触发供应商切换,更新 UI/托盘
let fm = self.failover_manager.clone();
let ah = self.app_handle.clone();
let pid = provider.id.clone();
let pname = provider.name.clone();
let at = app_type_str.to_string();
tokio::spawn(async move {
let _ = fm
.try_switch(ah.as_ref(), &at, &pid, &pname)
.await;
});
}
if status.total_requests > 0 {
status.success_rate = (status.success_requests
as f32
/ status.total_requests as f32)
* 100.0;
}
}
return Ok(ForwardResult {
response,
provider: provider.clone(),
});
}
Err(retry_err) => {
// 整流重试仍失败:区分错误类型决定是否记录熔断器
log::warn!(
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
);
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
let is_provider_error = match &retry_err {
ProxyError::Timeout(_)
| ProxyError::ForwardFailed(_) => true,
ProxyError::UpstreamError { status, .. } => {
*status >= 500
}
_ => false,
};
if is_provider_error {
// Provider 问题:记录失败到熔断器
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
false,
Some(retry_err.to_string()),
)
.await;
} else {
// 客户端问题:仅释放 permit,不记录熔断器
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
}
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(retry_err.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: retry_err,
provider: Some(provider.clone()),
});
}
}
}
}
}
// 检测是否需要触发 budget 整流器(仅 Claude/ClaudeAuth 供应商)
if is_anthropic_provider {
let error_message = extract_error_message(&e);
if should_rectify_thinking_budget(
error_message.as_deref(),
&self.rectifier_config,
) {
// 已经重试过:直接返回错误(不可重试客户端错误)
if budget_rectifier_retried {
log::warn!(
"[{app_type_str}] [RECT-013] budget 整流器已触发过,不再重试"
);
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(e.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
});
}
let budget_rectified = rectify_thinking_budget(&mut body);
if !budget_rectified.applied {
log::warn!(
"[{app_type_str}] [RECT-014] budget 整流器触发但无可整流内容,不做无意义重试"
);
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
self.router
.release_permit_neutral(
&provider.id,
@@ -321,15 +499,13 @@ impl RequestForwarder {
}
log::info!(
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
"[{}] [RECT-010] thinking budget 整流器触发, before={:?}, after={:?}",
app_type_str,
rectified.removed_thinking_blocks,
rectified.removed_redacted_thinking_blocks,
rectified.removed_signature_fields
budget_rectified.before,
budget_rectified.after
);
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
let _ = std::mem::replace(&mut rectifier_retried, true);
let _ = std::mem::replace(&mut budget_rectifier_retried, true);
// 使用同一供应商重试(不计入熔断器)
match self
@@ -337,8 +513,7 @@ impl RequestForwarder {
.await
{
Ok(response) => {
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
// 记录成功
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
let _ = self
.router
.record_result(
@@ -350,7 +525,6 @@ impl RequestForwarder {
)
.await;
// 更新当前应用类型使用的 provider
{
let mut current_providers =
self.current_providers.write().await;
@@ -360,7 +534,6 @@ impl RequestForwarder {
);
}
// 更新成功统计
{
let mut status = self.status.write().await;
status.success_requests += 1;
@@ -370,14 +543,11 @@ impl RequestForwarder {
!= provider.id.as_str();
if should_switch {
status.failover_count += 1;
// 异步触发供应商切换,更新 UI/托盘
let fm = self.failover_manager.clone();
let ah = self.app_handle.clone();
let pid = provider.id.clone();
let pname = provider.name.clone();
let at = app_type_str.to_string();
tokio::spawn(async move {
let _ = fm
.try_switch(ah.as_ref(), &at, &pid, &pname)
@@ -397,12 +567,10 @@ impl RequestForwarder {
});
}
Err(retry_err) => {
// 整流重试仍失败:区分错误类型决定是否记录熔断器
log::warn!(
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
"[{app_type_str}] [RECT-012] budget 整流重试仍失败: {retry_err}"
);
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
let is_provider_error = match &retry_err {
ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => {
true
@@ -412,7 +580,6 @@ impl RequestForwarder {
};
if is_provider_error {
// Provider 问题:记录失败到熔断器
let _ = self
.router
.record_result(
@@ -424,7 +591,6 @@ impl RequestForwarder {
)
.await;
} else {
// 客户端问题:仅释放 permit,不记录熔断器
self.router
.release_permit_neutral(
&provider.id,
@@ -451,6 +617,28 @@ impl RequestForwarder {
}
}
if signature_rectifier_non_retryable_client_error {
self.router
.release_permit_neutral(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some(e.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
});
}
// 失败:记录失败并更新熔断器
let _ = self
.router
@@ -575,6 +763,9 @@ impl RequestForwarder {
let (mapped_body, _original_model, _mapped_model) =
super::model_mapper::apply_model_mapping(body.clone(), provider);
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
let mapped_body = normalize_thinking_type(mapped_body);
// 转换请求体(如果需要)
let request_body = if needs_transform {
adapter.transform_request(mapped_body, provider)?
@@ -666,7 +857,11 @@ impl RequestForwarder {
// 输出请求信息日志
let tag = adapter.name();
log::debug!("[{tag}] >>> 请求 URL: {url}");
let request_model = filtered_body
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("<none>");
log::info!("[{tag}] >>> 请求 URL: {url} (model={request_model})");
if let Ok(body_str) = serde_json::to_string(&filtered_body) {
log::debug!(
"[{tag}] >>> 请求体内容 ({}字节): {}",

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